From 05d108d39eec7391b92fc393dd4243dab97a9485 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 23 Jul 2026 21:06:31 -0700 Subject: [PATCH] Release readiness: docs accuracy, tests, and UI code-behind decomposition - Reconcile CHANGELOG/README with the shipped app; fix the MANDO001 build-guard message - Add automated tests (40 -> 73): config clone/clamp, agent naming, history flattening, request-preamble composition, and the streamed-response loop - Decompose MainWindow (2877 -> 152 core) and ChatTabView (2414 -> 351) into partials - Externalize TranscriptHtmlBuilder CSS/JS into asset files (1258 -> 344) - Add IAiService/AiServiceAdapter, ResponseStreamer, and ITranscriptHtml seams - Factor shared helpers (ProjectDisplay, ShellOpen, ConfigCloning, CrashLog) - Fix MCP editor modal placement; tighten the chat input placeholder The shared MandoCode harness submodule is untouched; refactors are behavior-preserving. --- CHANGELOG.md | 29 +- README.md | 21 +- .../AgentNamingTests.cs | 31 + .../ConfigCloningTests.cs | 64 + .../HistorySummarizerTests.cs | 58 + .../MandoCode.Desktop.Tests.csproj | 29 +- .../RequestPreambleComposerTests.cs | 92 + .../ResponseStreamerTests.cs | 167 + src/MandoCode.Desktop/App.xaml.cs | 14 +- .../Assets/web/transcript/transcript.css | 452 +++ .../Assets/web/transcript/transcript.js | 464 +++ .../Controls/ChatTabView.Approvals.cs | 342 +++ .../Controls/ChatTabView.Explorer.cs | 920 ++++++ .../Controls/ChatTabView.Header.cs | 88 + .../Controls/ChatTabView.Input.cs | 304 ++ .../Controls/ChatTabView.Snapshot.cs | 251 ++ .../Controls/ChatTabView.Transcript.cs | 168 + .../Controls/ChatTabView.ViewModels.cs | 118 + .../Controls/ChatTabView.xaml | 2 +- .../Controls/ChatTabView.xaml.cs | 2086 ------------- .../MainWindow.Appearance.cs | 229 ++ src/MandoCode.Desktop/MainWindow.History.cs | 475 +++ src/MandoCode.Desktop/MainWindow.Mcp.cs | 390 +++ .../MainWindow.Navigation.cs | 157 + src/MandoCode.Desktop/MainWindow.Settings.cs | 100 + src/MandoCode.Desktop/MainWindow.Shared.cs | 129 + src/MandoCode.Desktop/MainWindow.Skills.cs | 456 +++ src/MandoCode.Desktop/MainWindow.Snapshots.cs | 242 ++ src/MandoCode.Desktop/MainWindow.Split.cs | 346 +++ src/MandoCode.Desktop/MainWindow.Tabs.cs | 95 + src/MandoCode.Desktop/MainWindow.Terminal.cs | 245 ++ .../MainWindow.ViewModels.cs | 145 + src/MandoCode.Desktop/MainWindow.xaml | 2 +- src/MandoCode.Desktop/MainWindow.xaml.cs | 2731 ----------------- .../MandoCode.Desktop.csproj | 2 +- src/MandoCode.Desktop/Services/AgentNaming.cs | 21 + .../Services/AgentSession.cs | 2 +- .../Services/AiServiceAdapter.cs | 62 + .../Services/ConfigCloning.cs | 41 + .../Services/ConfigCoordinator.cs | 34 +- .../Services/ContextSnapshot.cs | 13 +- src/MandoCode.Desktop/Services/CrashLog.cs | 30 + src/MandoCode.Desktop/Services/IAiService.cs | 44 + .../Services/ITranscriptHtml.cs | 16 + .../Services/ProjectDisplay.cs | 22 + .../Services/SessionArchiveStore.cs | 13 +- .../Services/SessionManager.cs | 9 +- src/MandoCode.Desktop/Services/ShellOpen.cs | 25 + .../Services/TranscriptHtmlBuilder.cs | 937 +----- .../ViewModels/ChatController.cs | 170 +- .../ViewModels/RequestPreambleComposer.cs | 67 + .../ViewModels/ResponseStreamer.cs | 137 + 52 files changed, 7123 insertions(+), 5964 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/AgentNamingTests.cs create mode 100644 src/MandoCode.Desktop.Tests/ConfigCloningTests.cs create mode 100644 src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs create mode 100644 src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs create mode 100644 src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs create mode 100644 src/MandoCode.Desktop/Assets/web/transcript/transcript.css create mode 100644 src/MandoCode.Desktop/Assets/web/transcript/transcript.js create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Header.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Input.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Appearance.cs create mode 100644 src/MandoCode.Desktop/MainWindow.History.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Mcp.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Navigation.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Settings.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Shared.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Skills.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Snapshots.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Split.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Tabs.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Terminal.cs create mode 100644 src/MandoCode.Desktop/MainWindow.ViewModels.cs create mode 100644 src/MandoCode.Desktop/Services/AgentNaming.cs create mode 100644 src/MandoCode.Desktop/Services/AiServiceAdapter.cs create mode 100644 src/MandoCode.Desktop/Services/ConfigCloning.cs create mode 100644 src/MandoCode.Desktop/Services/CrashLog.cs create mode 100644 src/MandoCode.Desktop/Services/IAiService.cs create mode 100644 src/MandoCode.Desktop/Services/ITranscriptHtml.cs create mode 100644 src/MandoCode.Desktop/Services/ProjectDisplay.cs create mode 100644 src/MandoCode.Desktop/Services/ShellOpen.cs create mode 100644 src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs create mode 100644 src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f8fc3..b87e138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,10 +40,9 @@ are visible until you actually open a second tab. tab's snapshots. **Import** arms a snapshot so its recap rides along, invisibly, with the *active* agent's next message, carrying the context into any model. The store is app-wide, so a snapshot taken in one tab imports into a brand-new tab on a capable model. **Take snapshot** (tab options - menu) captures on demand without switching. The recap is a deterministic port of the harness's own - compaction summary (`HistorySummarizer`), fed by the public `AIService.GetHistoryAsync()` — no - submodule change. The full history is stored alongside each snapshot so a richer LLM summary can - be generated later without the original conversation still being live. + menu) captures on demand without switching. The recap is written by a summarizer model you pick + (`SnapshotEnhancer`, a tool-less Ollama kernel that map-reduces over the full history so nothing is + truncated), so a snapshot is always born with a real recap — there is no "light"/un-enhanced state. - **Per-tab options menu.** The tab's `⋯` menu carries Rename, Take snapshot, Export transcript, and Close. It replaces the bare close button — which, on the last remaining agent, was an `X` you were not allowed to use; Close is now simply greyed out there. @@ -77,6 +76,22 @@ are visible until you actually open a second tab. - **Unread badges.** The History and Snapshots rail badges are now unread counts — items newer than the last time you opened that panel — and clear when you open it, rather than showing a running total. The "last seen" marks persist across launches. +- **Integrated terminal.** A sliding terminal panel (Ctrl+` toggles it, Ctrl+Shift+` maximizes) + runs a real shell through ConPTY, rendered with xterm.js inside WebView2 — no new native + dependencies. A shell picker (`ShellCatalog`) selects PowerShell/cmd/etc., and the terminal + opens in the active agent's project folder. +- **File explorer with git awareness.** Each agent has a collapsible file tree, kept live by a + `FileSystemWatcher`, alongside a **Changes** tab driven by `GitQuickStatus`: a branch chip, + per-file add/modify/delete status with dirty badges on files and folders in the tree, inline + diff cards, a one-click **commit**, and per-file **undo** (with confirmation). Tree items drag + into the input as `@`-references, and paths can be dropped onto the chat. +- **External-change awareness.** `WorkspaceDeltaTracker` notices when the working tree changed + outside the conversation — a commit, a revert, or a branch switch between your turns — and notes + it to the agent so its next reply reflects the repo as it actually is, not a stale picture. +- **Skills page + AI-assisted authoring.** A **Skills** sidebar page lists installed skills + (searchable, filterable, enabled per agent), installs new ones from a folder or a zip, and its + editor can **generate or refine** a skill body with a model you pick (`SkillAuthor`). + `SkillCoordinator` fans skill changes out to every open agent, mirroring `McpCoordinator`. - **Branded app icon** across the exe, taskbar, and window title bar, plus a lightweight unhandled-exception logger (`crash.log`) to speed up diagnosing native/COM failures. @@ -165,12 +180,6 @@ are visible until you actually open a second tab. - Each agent holds a live WebView2 (tens of MB). A retained transcript log would let background agents defer creating one until first shown. - Agent settings are session-scoped by design and are not restored on launch. -- **LLM-enhanced snapshots.** The snapshot data model reserves an AI recap (`AiRecap`; the `Tag` - flips `Light`→`AI`, and `BestRecap` prefers it), but there is no "Enhance" action yet. A clean - LLM summary needs a small public seam on `AIService` — a no-tools completion on a side history — - added at the next submodule pin roll; the existing side-channels either fire tools - (`ExecutePlanStepAsync`) or would corrupt the live conversation. The panel is already built, so - it's a button plus one method once the seam lands. - **Summarize-at-restore.** The tail-brief restore fallback still excerpts the stored dialogue verbatim rather than running `HistorySummarizer` over it — better coverage of long sessions is a follow-up, at the cost of one LLM call on restore. diff --git a/README.md b/README.md index dce0e76..c96a414 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ graph; `SessionManager` owns the set of them. The split matters: |---|---| | `AIService` (its conversation, its model), `ChatController`, `TaskPlannerService` | The `MandoCodeConfig` on disk — the **defaults** a new agent starts on | | `MandoCodeConfig` clone, `ProjectRootAccessor`, `SkillLoader`, `FileAutocompleteProvider` | `McpClientManager` (one set of server processes) | -| `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager`, `TranscriptHtmlBuilder` | -| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore` | +| `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager` (static), `TranscriptHtmlBuilder`, `SpinnerService` | +| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `UiUpdateCheckService` | Tabs default to `Agent 1`, `Agent 2`, … (the folder shows in the header); the number reuses the lowest free slot, and a rename or folder change never overwrites it. Each tab's `⋯` options menu @@ -179,7 +179,7 @@ within 24 hours. approve / don't-ask-again / deny / new-instructions / cancel-plan semantics) - propose_plan flow: plan table, execute/reject/cancel, per-step progress bar, step-failure skip/cancel -- Slash commands with autocomplete: /help /clear /model /config /retry /learn +- Slash commands with autocomplete: /help /setup /clear /model /config /retry /learn /copy /copy-code /skills /force-skill /mcp /mcp tools /mcp remove /mcp-reload /music* /command /exit — plus `!cmd` shell escape and `@file` references - Token tracking + per-response summaries, per agent @@ -202,7 +202,14 @@ within 24 hours. collapsible groups. Unnamed snapshots get an auto-generated, unique title - Rail badges on History and Snapshots are unread counts that clear when you open the panel (persisted), not running totals -- Sidebar: Settings and MCP as full-screen pages, acting on the selected agent +- Integrated terminal — a sliding panel (Ctrl+` / Ctrl+Shift+` to maximize) running a + real shell via ConPTY, rendered with xterm.js in WebView2; a shell picker chooses the + shell and it opens in the active agent's project folder +- Per-agent file explorer with git awareness — a live file tree (FileSystemWatcher) plus a + Changes tab (GitQuickStatus): branch chip, add/modify/delete status, dirty badges, inline + diff cards, one-click commit and per-file undo; tree items drag into the input as `@`-refs. + WorkspaceDeltaTracker notes commits/reverts/branch switches made outside the conversation +- Sidebar: Settings, MCP, and Skills as full-screen pages, acting on the selected agent - Settings — the whole config as a native form (toggles, sliders, number boxes, grouped Appearance/Connection/Generation/Behavior/Limits/Integrations); every change is validated through the shared ConfigKeySetter, same as the CLI, and @@ -210,6 +217,9 @@ within 24 hours. - MCP — live server list with status/tool counts; add/edit servers in a single form modal with a Test button (isolated connection check + tool table preview). Servers are one app-wide set; each agent chooses whether to attach their tools + - Skills — list installed skills (searchable, filterable, enabled per agent), install + from a folder or zip, and an editor that can generate or refine a skill body with a + model you pick (`SkillAuthor`); `SkillCoordinator` fans changes to every open agent - Guided wizards, built on the approval-overlay select + text primitives: - `/setup` — probe/start Ollama, change endpoint, pull a starter model with live progress, model picker, cloud-auth check + sign-in walkthrough @@ -218,7 +228,8 @@ within 24 hours. - Branded application icon across the exe, taskbar, and window title bar - Update check against this repo's GitHub Releases (24h throttle, fail-silent) -Not ported (yet): matrix easter eggs, terminal theme service (N/A). +Not ported: matrix easter eggs; the CLI's ANSI terminal-theme service (N/A — the app ships +its own integrated terminal instead, see above). ## License diff --git a/src/MandoCode.Desktop.Tests/AgentNamingTests.cs b/src/MandoCode.Desktop.Tests/AgentNamingTests.cs new file mode 100644 index 0000000..e831dca --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentNamingTests.cs @@ -0,0 +1,31 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Default agent labels reuse the lowest free "Agent N" slot, so closing a middle tab and opening a +/// new one refills the gap rather than climbing forever. User-renamed tabs are just taken names. +/// +public sealed class AgentNamingTests +{ + [Fact] + public void FirstAgent_IsAgentOne() + => Assert.Equal("Agent 1", AgentNaming.NextFreeName(Array.Empty())); + + [Fact] + public void SequentialWhenAllTaken() + => Assert.Equal("Agent 3", AgentNaming.NextFreeName(new[] { "Agent 1", "Agent 2" })); + + [Fact] + public void ReusesLowestFreeSlot() + => Assert.Equal("Agent 2", AgentNaming.NextFreeName(new[] { "Agent 1", "Agent 3" })); + + [Fact] + public void RenamedTitlesAreJustTakenNames() + => Assert.Equal("Agent 2", AgentNaming.NextFreeName(new[] { "Frontend", "Agent 1" })); + + [Fact] + public void IgnoresNullAndEmptyTitles() + => Assert.Equal("Agent 1", AgentNaming.NextFreeName(new string?[] { null, "" })); +} diff --git a/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs b/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs new file mode 100644 index 0000000..cd5d8d6 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/ConfigCloningTests.cs @@ -0,0 +1,64 @@ +using MandoCode.Desktop.Services; +using MandoCode.Models; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The config deep-clone every new agent starts from. The behaviour that MUST hold — warned about +/// in three places across the code yet untested until now — is that the JSON round-trip does not +/// leave McpServers with the case-SENSITIVE comparer System.Text.Json hands back: the clone's +/// ValidateAndClamp must rebuild it OrdinalIgnoreCase, or every MCP lookup in the clone +/// silently misses on a casing difference. +/// +public sealed class ConfigCloningTests +{ + [Fact] + public void DeepClone_RebuildsMcpServers_CaseInsensitive() + { + var source = new MandoCodeConfig(); + source.McpServers["Solana"] = new McpServerConfig { Command = "npx" }; + // Sanity: a plain dict is case-sensitive, so the miscased lookup misses on the source. + Assert.False(source.McpServers.ContainsKey("solana")); + + var clone = ConfigCloning.DeepClone(source); + + Assert.True(clone.McpServers.ContainsKey("solana")); + Assert.True(clone.McpServers.ContainsKey("SOLANA")); + } + + [Fact] + public void DeepClone_PreservesScalarValues() + { + var source = new MandoCodeConfig { ModelName = "qwen2.5-coder:14b", OllamaEndpoint = "http://example:1234" }; + + var clone = ConfigCloning.DeepClone(source); + + Assert.Equal("qwen2.5-coder:14b", clone.ModelName); + Assert.Equal("http://example:1234", clone.OllamaEndpoint); + } + + [Fact] + public void DeepClone_IsFullyDetached_MutatingCloneLeavesSourceAlone() + { + var source = new MandoCodeConfig(); + source.McpServers["Solana"] = new McpServerConfig { Command = "npx" }; + + var clone = ConfigCloning.DeepClone(source); + clone.McpServers.Clear(); + clone.McpServers["Other"] = new McpServerConfig { Command = "uvx" }; + + Assert.True(source.McpServers.ContainsKey("Solana")); + Assert.False(source.McpServers.ContainsKey("Other")); + } + + [Fact] + public void DeepClone_AppliesValidateAndClamp_HealsBlankEndpoint() + { + var source = new MandoCodeConfig { OllamaEndpoint = " " }; + + var clone = ConfigCloning.DeepClone(source); + + Assert.Equal("http://localhost:11434", clone.OllamaEndpoint); + } +} diff --git a/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs b/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs new file mode 100644 index 0000000..392c953 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs @@ -0,0 +1,58 @@ +using MandoCode.Desktop.Services; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The plain-text flattening handed to the snapshot summarizer. It must skip the system prompt at +/// index 0, label each turn by role, describe tool turns that carry no text, and produce an honest +/// placeholder when there is nothing to recap. +/// +public sealed class HistorySummarizerTests +{ + private static ChatMessageContent Sys(string t) => new(AuthorRole.System, t); + private static ChatMessageContent Usr(string t) => new(AuthorRole.User, t); + private static ChatMessageContent Asst(string t) => new(AuthorRole.Assistant, t); + + [Fact] + public void HasContent_False_WhenOnlySystemPrompt() + => Assert.False(HistorySummarizer.HasContent(new List { Sys("you are helpful") })); + + [Fact] + public void HasContent_True_WhenUserSpoke() + => Assert.True(HistorySummarizer.HasContent(new List { Sys("sys"), Usr("hello") })); + + [Fact] + public void Full_SkipsSystemPrompt_AndKeepsBothTurns() + { + var history = new List { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") }; + + var text = HistorySummarizer.Full(history); + + Assert.DoesNotContain("SECRET SYSTEM", text); + Assert.Contains("hi there", text); + Assert.Contains("hey back", text); + } + + [Fact] + public void Full_ReturnsPlaceholder_WhenNothingToSummarize() + => Assert.Equal("(no prior activity captured)", + HistorySummarizer.Full(new List { Sys("sys") })); + + [Fact] + public void Full_DescribesFunctionCall_WhenTextIsEmpty() + { + var toolTurn = new ChatMessageContent(AuthorRole.Assistant, content: null) + { + Items = { new FunctionCallContent("read_file", arguments: new KernelArguments { ["path"] = "Program.cs" }) } + }; + var history = new List { Sys("sys"), toolTurn }; + + var text = HistorySummarizer.Full(history); + + Assert.Contains("read_file", text); + Assert.Contains("path=Program.cs", text); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index aa3e567..119dc2f 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -13,6 +13,15 @@ + + + + + + instantiates a store (they hit fixed LocalAppData paths). ProjectDisplay backs the + ProjectLabel/TimeLabel derivations shared by both entry types. --> + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs b/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs new file mode 100644 index 0000000..7da0080 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs @@ -0,0 +1,92 @@ +using MandoCode.Desktop.ViewModels; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The invisible preamble folded into a user's message. It must leave a plain request untouched, +/// frame each ride-along as background (never as typed text), and nest them in a stable order so +/// the model always sees the actual request last. +/// +public sealed class RequestPreambleComposerTests +{ + private static readonly string[] None = System.Array.Empty(); + private static readonly (string, string)[] NoReactions = System.Array.Empty<(string, string)>(); + + [Fact] + public void NoRideAlongs_ReturnsRequestUnchanged() + => Assert.Equal("do the thing", + RequestPreambleComposer.Compose("do the thing", None, NoReactions, None, needsPlanning: false)); + + [Fact] + public void Planning_AppendsProposePlanNudge() + { + var result = RequestPreambleComposer.Compose("build a feature", None, NoReactions, None, needsPlanning: true); + + Assert.StartsWith("build a feature", result); + Assert.Contains("propose_plan", result); + } + + [Fact] + public void ArmedContext_IsFramedAsBackground_AndRequestComesLast() + { + var result = RequestPreambleComposer.Compose( + "current ask", new[] { "earlier recap" }, NoReactions, None, needsPlanning: false); + + Assert.Contains("Imported context — 1 recap", result); + Assert.Contains("earlier recap", result); + // The user's actual request must sit after the last "[Current request:]" boundary. + Assert.EndsWith("[Current request:]\ncurrent ask", result); + } + + [Fact] + public void MultipleArmedContexts_Pluralize() + { + var result = RequestPreambleComposer.Compose( + "x", new[] { "a", "b" }, NoReactions, None, needsPlanning: false); + + Assert.Contains("2 recaps", result); + } + + [Fact] + public void Reactions_AreFramedAsFeedbackNotText() + { + var result = RequestPreambleComposer.Compose( + "next", None, new[] { ("👍", "the part about caching") }, None, needsPlanning: false); + + Assert.Contains("reacted to earlier responses", result); + Assert.Contains("👍", result); + Assert.Contains("the part about caching", result); + } + + [Fact] + public void WorkspaceNotes_CarryStalenessWarning() + { + var result = RequestPreambleComposer.Compose( + "keep going", None, NoReactions, new[] { "user ran: git checkout main" }, needsPlanning: false); + + Assert.Contains("Workspace changes since your last turn", result); + Assert.Contains("may be stale", result); + Assert.Contains("git checkout main", result); + } + + [Fact] + public void AllRideAlongs_NestWithRequestStillLast() + { + var result = RequestPreambleComposer.Compose( + "the real ask", + new[] { "recap" }, + new[] { ("🎉", "snippet") }, + new[] { "external edit" }, + needsPlanning: true); + + Assert.Contains("Imported context", result); + Assert.Contains("reacted to earlier responses", result); + Assert.Contains("Workspace changes since your last turn", result); + Assert.Contains("propose_plan", result); + // The real ask survives, followed only by the planning nudge. + var askIndex = result.LastIndexOf("the real ask", System.StringComparison.Ordinal); + Assert.True(askIndex >= 0); + Assert.True(result.IndexOf("propose_plan", System.StringComparison.Ordinal) > askIndex); + } +} diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs new file mode 100644 index 0000000..a3ed002 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -0,0 +1,167 @@ +using System.Runtime.CompilerServices; +using MandoCode.Desktop.Services; +using MandoCode.Desktop.ViewModels; +using MandoCode.Models; +using MandoCode.Services; +using Microsoft.SemanticKernel; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The streamed-response loop, driven by a fake — the highest-risk seam in +/// the app, previously testable only by hand with a live model. Asserts each completed turn becomes +/// its own card, the empty/no-response cases warn, a 401 triggers the sign-in callback, and +/// cancellation/errors surface as transcript lines rather than throwing. +/// +public sealed class ResponseStreamerTests +{ + // Tags each fragment type so a test can assert which builder method produced a transcript block. + private sealed class TagHtml : ITranscriptHtml + { + public string AssistantCard(string markdown) => $"CARD:{markdown}"; + public string Warn(string text) => $"WARN:{text}"; + public string Error(string text) => $"ERR:{text}"; + public string Dim(string text) => $"DIM:{text}"; + public string TokenSummary(string text) => $"TOK:{text}"; + } + + private sealed class FakeAiService : IAiService + { + private readonly string[] _segments; + private readonly Exception? _throw; + + public FakeAiService(string[] segments, Exception? throwOnStream = null) + { + _segments = segments; + _throw = throwOnStream; + } + + public async IAsyncEnumerable ChatStreamAsync( + string userMessage, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (_throw != null) + { + await Task.Yield(); + throw _throw; + } + foreach (var s in _segments) + { + await Task.Yield(); + yield return s; + } + } + + // Unused by the streaming loop. + public event Action? OnFunctionInvoked { add { } remove { } } + public event Action? OnFunctionCompleted { add { } remove { } } + public Func>? OnWriteApprovalRequested { get; set; } + public Func>? OnDeleteApprovalRequested { get; set; } + public Func>? OnCommandApprovalRequested { get; set; } + public Task ReinitializeAsync(MandoCodeConfig config) => throw new NotSupportedException(); + public Task RefreshSettingsAsync(MandoCodeConfig config) => throw new NotSupportedException(); + public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => throw new NotSupportedException(); + public string? ExportHistoryJson() => throw new NotSupportedException(); + public int TryRestoreHistoryJson(string json) => throw new NotSupportedException(); + public Task EnterLearnModeAsync() => throw new NotSupportedException(); + public Task ClearHistoryAsync() => throw new NotSupportedException(); + public Task> GetHistoryAsync() => throw new NotSupportedException(); + } + + private static (ResponseStreamer streamer, List blocks) Make(FakeAiService ai) + { + var transcript = new TranscriptWriter(); + var blocks = new List(); + transcript.BlockAdded += b => blocks.Add(b); + var config = new MandoCodeConfig { EnableTokenTracking = false }; + var streamer = new ResponseStreamer( + ai, transcript, new TagHtml(), new BusyStateService(), new TokenTrackingService(), config); + return (streamer, blocks); + } + + [Fact] + public async Task EachTurn_BecomesItsOwnCard_AndReturnsJoinedText() + { + var (s, blocks) = Make(new FakeAiService(new[] { "hello", "world" })); + var logged = new List(); + s.ConversationLogger = (role, text) => logged.Add($"{role}:{text}"); + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("hello\n\nworld", result); + Assert.Contains("CARD:hello", blocks); + Assert.Contains("CARD:world", blocks); + Assert.Equal(new[] { "a:hello", "a:world" }, logged); + } + + [Fact] + public async Task NoChunks_WarnsNoResponse_AndReturnsEmpty() + { + var (s, blocks) = Make(new FakeAiService(Array.Empty())); + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("", result); + Assert.Contains(blocks, b => b.StartsWith("WARN:") && b.Contains("No response")); + Assert.DoesNotContain(blocks, b => b.StartsWith("CARD:")); + } + + [Fact] + public async Task WhitespaceOnlyTurns_WarnEmptyResponse_NoCards() + { + var (s, blocks) = Make(new FakeAiService(new[] { " ", "" })); + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("", result); + Assert.Contains(blocks, b => b.StartsWith("WARN:") && b.Contains("empty response")); + Assert.DoesNotContain(blocks, b => b.StartsWith("CARD:")); + } + + [Fact] + public async Task Response_LookingLike401_InvokesSignInCallback() + { + var (s, _) = Make(new FakeAiService(new[] { "Request failed: 401 Unauthorized — sign in again" })); + var fired = false; + s.On401 = () => { fired = true; return Task.CompletedTask; }; + + await s.StreamAsync("hi", CancellationToken.None); + + Assert.True(fired); + } + + [Fact] + public async Task NormalResponse_DoesNotInvoke401() + { + var (s, _) = Make(new FakeAiService(new[] { "all good here" })); + var fired = false; + s.On401 = () => { fired = true; return Task.CompletedTask; }; + + await s.StreamAsync("hi", CancellationToken.None); + + Assert.False(fired); + } + + [Fact] + public async Task Cancellation_SurfacesAsWarning_NotThrow() + { + var (s, blocks) = Make(new FakeAiService(Array.Empty(), new OperationCanceledException())); + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("", result); + Assert.Contains("WARN:Request cancelled.", blocks); + } + + [Fact] + public async Task StreamError_SurfacesAsErrorCard_NotThrow() + { + var (s, blocks) = Make(new FakeAiService(Array.Empty(), new InvalidOperationException("boom"))); + + var result = await s.StreamAsync("hi", CancellationToken.None); + + Assert.Equal("", result); + Assert.Contains(blocks, b => b.StartsWith("ERR:") && b.Contains("boom")); + } +} diff --git a/src/MandoCode.Desktop/App.xaml.cs b/src/MandoCode.Desktop/App.xaml.cs index a5d9e27..53524e1 100644 --- a/src/MandoCode.Desktop/App.xaml.cs +++ b/src/MandoCode.Desktop/App.xaml.cs @@ -29,19 +29,7 @@ public App() // Record any unhandled exception with its full stack to crash.log, so a UI-thread throw // shows what actually failed instead of only the generated debugger-break in App.g.i.cs. - UnhandledException += (_, e) => - { - try - { - var dir = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "MandoCode.Desktop"); - System.IO.Directory.CreateDirectory(dir); - System.IO.File.AppendAllText(System.IO.Path.Combine(dir, "crash.log"), - $"[{DateTimeOffset.Now:O}] {e.Message}\n{e.Exception}\n\n"); - } - catch { /* logging is best-effort — never mask the original failure */ } - }; + UnhandledException += (_, e) => CrashLog.Write("UnhandledException", e.Exception); Services = BuildServices(); } diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css new file mode 100644 index 0000000..a75e456 --- /dev/null +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css @@ -0,0 +1,452 @@ + * { box-sizing: border-box; } + body { + background: var(--bg); color: var(--fg); + font-family: "Segoe UI", sans-serif; font-size: 14px; + margin: 0; padding: 14px 18px 24px 18px; line-height: 1.5; + } + /* User-chosen chat background: a fixed full-bleed layer painted behind the log. + Only THIS layer fades with the appearance slider — text keeps full contrast, + and panels/code blocks keep their opaque theme backgrounds on top of it. */ + #bg { position: fixed; inset: 0; z-index: -1; pointer-events: none; + background-image: var(--chat-bg-image); background-size: cover; + background-position: center; background-repeat: no-repeat; + opacity: var(--chat-bg-opacity); } + #log > * { margin-bottom: 8px; animation: rise 0.18s ease-out; } + @keyframes rise { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } + } + /* E-ink / flat-motion themes: no fade-in, no hover transitions, no smooth scroll — the + transcript repaints instantly and stays still, the way an e-reader page does. The + attribute is set at build time and toggled live by ThemeManager.BuildTranscriptScript. */ + html[data-flat] #log > * { animation: none; } + html[data-flat] *, html[data-flat] { transition: none !important; scroll-behavior: auto !important; } + /* E-ink background image: treat the (static) chat-background layer like a Kindle image — + grayscale + contrast + 1-bit Bayer ordered dithering into black/white halftone dots. + Applied ONLY to #bg (never the text) and ONLY under the flat/e-ink theme. The layer is + fixed and repaints once, so even this heavy filter costs nothing per frame. */ + html[data-flat] #bg { filter: url(#eink); } + /* Color emoji is the loudest break in the paper illusion, so desaturate every emoji-bearing + surface to grayscale ink: the chrome (react ghost, reaction pills, picker) AND the inline + emoji in message text (.md) and user echoes. Scoped to the flat/e-ink theme only. Safe and + static — assistant turns are appended as COMPLETE blocks (ChatController flushes each turn + via AssistantCard; no token-by-token DOM streaming), so each subtree is filtered once on + append and never re-rasterized by later appends. Under e-ink every other glyph is already + ink-gray, so the only visible effect is draining the color out of emoji. */ + html[data-flat] .react-ghost, + html[data-flat] .rx-pill, + html[data-flat] #rx-pop .rx, + html[data-flat] .md, + html[data-flat] .user-echo { filter: grayscale(1); } + + /* ---- CRT picture-tube overlay (aperture-grille tube) ---------------------------------- + Scoped to html[data-crt]. Drawn on two fixed, pointer-events:none pseudo-layers OVER the + transcript, so the "glass" sits in front of the text. EVERYTHING here is STATIC — no moving + scanline, no flicker (that is the continuous-repaint trap we keep avoiding); the tube look + is fixed gradients only, one paint. The set's native chrome outside the WebView is untouched, + exactly like a real TV where only the picture tube carries scanlines. */ + html[data-crt] body { + /* phosphor bloom on every glyph — a tight bright core + a wider soft halo reads more + like real phosphor than one big blur (and keeps text legible). Static, so no per-frame + cost even though it rides the streaming-text repaint. */ + text-shadow: 0 0 2px rgba(120, 210, 255, 0.55), 0 0 9px rgba(120, 210, 255, 0.42), + 0 0 18px rgba(120, 210, 255, 0.22); + } + html[data-crt] body::before { + content: ""; position: fixed; inset: 0; z-index: 9998; pointer-events: none; + background: + /* horizontal scanlines (4px period: 2px gap + 2px line) */ + repeating-linear-gradient(to bottom, + rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, + rgba(0,0,0,0.22) 2px, rgba(0,0,0,0.22) 4px), + /* aperture grille — faint vertical RGB stripes (the aperture-grille tell, not a dot mask) */ + repeating-linear-gradient(to right, + rgba(255,0,64,0.05) 0, rgba(0,255,128,0.05) 1px, + rgba(64,128,255,0.05) 2px, rgba(0,0,0,0) 3px); + } + html[data-crt] body::after { + content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none; + background: + /* the two signature aperture-grille damper wires */ + linear-gradient(to bottom, + transparent calc(33.3% - 1px), rgba(0,0,0,0.30) 33.3%, transparent calc(33.3% + 1px)), + linear-gradient(to bottom, + transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + 1px)), + /* tube-edge vignette */ + radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%); + } + /* ---- Boxed messages (Appearance toggle, theme-agnostic) --------------------------- + Each prompt/response on its own card surface: hard message boundaries and skimmable + rhythm for long sessions, versus the default flat terminal look. Only theme variables, + so every palette works. Excluded under W98 — its bevelled message windows are bespoke. */ + /* Frosted glass: cards are slightly translucent with a backdrop blur, so a chat + background image glows through without ever fighting the text (the blur is what + preserves contrast over busy wallpapers). Over a plain theme background the effect + degrades to near-solid — no image, no cost to readability. Blur is static compositing, + not per-frame work. */ + html[data-cards]:not([data-win98]) .user-echo { + background: color-mix(in srgb, var(--panel) 82%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--border); border-radius: 10px; + padding: 8px 12px; } + html[data-cards]:not([data-win98]) .assistant { + background: color-mix(in srgb, var(--panel) 82%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--border); border-radius: 10px; + padding: 6px 12px 8px 12px; } + /* Cards sit on the panel color, so code wells inside switch to the bg color to stay + visually recessed (they normally use --panel against a --bg page). */ + html[data-cards]:not([data-win98]) .md pre, + html[data-cards]:not([data-win98]) .md code { background: var(--bg); } + + /* ---- Windows 98 chrome ----------------------------------------------------------- + Scoped to html[data-win98]. The 3D language of 1998: silver surfaces, square corners, + two-tone bevels lit from the top-left (raised = chrome you can press, sunken = wells + that hold content), navy title-bar gradients, Tahoma, and none of the decoration the + era didn't have (radii, soft shadows). Colors come from the theme's CSS variables; + this block only reshapes geometry, bevels, and the title bars. All static — pairs + with the theme's FlatMotion, because nothing animated in 1998. */ + html[data-win98] body { font-family: Tahoma, "MS Sans Serif", "Segoe UI", sans-serif; + /* THE desktop teal. Silver never filled a screen in 1998 — it sat in windows on this. */ + background: #008080; padding: 12px 14px 20px 14px; } + /* Each MESSAGE is its own window on the desktop (not one giant expanding one): user + prompts are small silver windows; assistant responses are windows whose "MandoCode" + label becomes the navy title bar — the hover copy/react chips land on it like window + buttons. Status lines and tool ops sit directly on the teal like desktop icon labels, + with brightened colors (the theme's dark semantic hues are unreadable on teal). + (A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */ + html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px; + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .assistant { background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .assistant-label { + background: linear-gradient(90deg, #000080, #1084D0); color: #FFFFFF; + padding: 3px 10px; margin-bottom: 0; font-weight: 700; } + html[data-win98] .assistant .md { padding: 2px 12px 8px 12px; } + html[data-win98] .line { color: #EAF6F4; } + html[data-win98] .line.info { color: #A8D8FF; } + html[data-win98] .line.success { color: #90EE90; } + html[data-win98] .line.warn { color: #FFE082; } + html[data-win98] .line.error { color: #FF9E8F; } + html[data-win98] .line.dim, html[data-win98] .op-meta, html[data-win98] .token-summary { color: #B8D8D4; } + html[data-win98] .op { color: #EAF6F4; } + html[data-win98] .op-path { color: #EAF6F4; } + html[data-win98] .op-head a.file-link { color: #AAD4FF; border-bottom-color: #AAD4FF; } + /* Op-head semantic colors (WebSearch/WebFetch/Write/Delete glyph classes) are theme-dark + hues built for silver — brighten them on the teal, same mapping as the .line variants. */ + html[data-win98] .op-head.success { color: #90EE90; } + html[data-win98] .op-head.error, html[data-win98] .op-head.red { color: #FF9E8F; } + html[data-win98] .op-head.warn { color: #FFE082; } + html[data-win98] .op-head.info, html[data-win98] .op-head.sky { color: #A8D8FF; } + html[data-win98] .op-head.dim { color: #B8D8D4; } + /* Square EVERYTHING. */ + html[data-win98] .panel, html[data-win98] .chip, html[data-win98] .tool-pill, + html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn, + html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle, + html[data-win98] .md pre, html[data-win98] .md code, html[data-win98] pre.mono-block, + html[data-win98] pre.raw, html[data-win98] .op-detail, html[data-win98] #rx-pop, + html[data-win98] .rx-pill, html[data-win98] #rx-pop .rx { border-radius: 0 !important; } + /* Raised bevel: anything button-like is a silver 3D control. */ + html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn, + html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle, + html[data-win98] .tool-pill, html[data-win98] .chip, html[data-win98] .rx-pill { + background: var(--bg); color: #000; + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; + } + /* ...and presses in like one. */ + html[data-win98] .copy-chip:active, html[data-win98] .expand-btn:active, + html[data-win98] .web-toggle:active, html[data-win98] .dv-btn:active, + html[data-win98] .ue-toggle:active, html[data-win98] .react-ghost:active { + border-color: #404040 #FFFFFF #FFFFFF #404040; + } + /* Panels are little windows: raised silver frame + navy title-bar gradient. */ + html[data-win98] .panel { + background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; + } + html[data-win98] .panel-header { + background: linear-gradient(90deg, #000080, #1084D0); + color: #FFFFFF; border-bottom: none; + } + html[data-win98] .panel-header a.file-link { color: #FFFFFF; border-bottom-color: #9CC2E5; } + /* Content wells are sunken white, like every 98 text box and list view. */ + html[data-win98] .md pre, html[data-win98] pre.cmd, html[data-win98] pre.cmd-out, + html[data-win98] pre.diff, html[data-win98] pre.mono-block, html[data-win98] pre.raw, + html[data-win98] .op-detail { + background: var(--panel); + border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; + } + html[data-win98] .md code { background: var(--panel); border: 1px solid #808080; } + html[data-win98] .md pre code { border: none; } + /* 1998 had no soft shadows. */ + html[data-win98] #rx-pop { box-shadow: none; background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] .chip .dot, html[data-win98] .tool-pill .tp-dot { box-shadow: none; } + /* Plan/help tables become 98 list views: sunken white body, RAISED column headers — + the iconic Explorer detail. Row separators in dialog-face gray. */ + html[data-win98] table.plan { background: var(--panel); + border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; } + html[data-win98] table.plan th { background: var(--bg); color: #000; + border: 1px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] table.plan td { border-top: 1px solid #D4D0C8; } + /* Chunky classic scrollbars. */ + html[data-win98] ::-webkit-scrollbar { width: 16px; height: 16px; } + html[data-win98] ::-webkit-scrollbar-track { background: #DFDFDF; } + html[data-win98] ::-webkit-scrollbar-thumb { background: var(--bg); + border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; } + html[data-win98] ::-webkit-scrollbar-corner { background: #DFDFDF; } + + /* User prompts: gold marks the user's voice, at normal weight so an 8-line clamped + paste reads as text, not a block of emphasis. Only the sigil stays semibold. */ + .user-echo { color: var(--gold); white-space: pre-wrap; margin-top: 14px; } + .ue-sigil { font-weight: 600; } + /* Long prompts clamp to ~8 lines (JS adds the class only when the echo is actually tall). + The fade is a mask on the text itself — not an overlay painted in a background color — + so it works over chat-background images and every theme. */ + .user-echo.clamped { max-height: 11.5em; overflow: hidden; + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent); + mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent); } + .ue-toggle { display: block; background: none; border: none; cursor: pointer; + color: var(--dim); font-size: 11px; font-family: "Segoe UI", sans-serif; padding: 1px 0; } + .ue-toggle:hover { color: var(--fg); } + .assistant { margin-top: 4px; position: relative; } + .assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; } + .md p { margin: 6px 0; } + .md pre { + background: var(--panel); border: 1px solid var(--border); border-radius: 8px; + padding: 10px 12px; overflow-x: auto; position: relative; + font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px; + } + .md code { font-family: "Cascadia Code", Consolas, monospace; background: var(--panel); + border-radius: 4px; padding: 1px 5px; font-size: 13px; } + .md pre code { background: none; padding: 0; } + .md table { border-collapse: collapse; margin: 8px 0; } + .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; } + a { color: var(--sky); } + .md h1, .md h2, .md h3 { color: var(--accent); margin: 12px 0 4px 0; } + .md ul, .md ol { margin: 4px 0; padding-left: 24px; } + .md blockquote { border-left: 3px solid var(--accent); margin: 6px 0; padding-left: 10px; color: var(--dim); } + .line { white-space: pre-wrap; } + .info { color: var(--sky); } + .success { color: var(--green); } + .warn { color: var(--gold); } + .error { color: var(--red); } + .dim { color: var(--dim); } + .sky { color: var(--sky); } + .red { color: var(--red); } + .token-summary { text-align: right; font-size: 12px; } + + /* Status chips — compact pills for session/connection state. A CSS status dot + (crisp, theme-aware) replaces status emoji; state = ok | warn | err | neutral. */ + /* Centered to match the tool pills: all system/status chrome sits centered, conversation stays left. */ + .chip-row { margin: 2px 0; text-align: center; } + .chip { display: inline-flex; align-items: center; gap: 7px; + padding: 3px 12px; border-radius: 999px; font-size: 12.5px; + border: 1px solid var(--border); background: var(--panel); + /* Uniform floor so status pills line up — the "MCP / N connected" pill is the + widest of them, so shorter pills (model / ready) pad up to match. Longer + chips still grow past it. */ + box-sizing: border-box; min-width: 190px; } + .chip .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; + background: var(--dim); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); } + .chip.ok .dot { background: var(--green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 24%, transparent); } + .chip.warn .dot { background: var(--gold); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 24%, transparent); } + .chip.err .dot { background: var(--red); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 24%, transparent); } + .chip-val { color: var(--fg); font-weight: 600; } + .chip-key { color: var(--dim); } + + /* Tool-call pills — STATIC (no animation, so they never cause continuous repaint). A rounded, + theme-colored chip with a monochrome glyph, matching the StatusChip family. */ + /* Centered: tool pills are the assistant's machinery, not dialogue — centering (like Slack/Discord + system messages) keeps the left column a clean read and marks them as ambient activity. + display:flex + fit-content makes the chip block-level and shrink-wrapped so margin auto centers it. */ + .tool-pill { display: flex; width: fit-content; align-items: center; gap: 8px; margin: 2px auto; + padding: 3px 12px; border-radius: 999px; font-size: 12px; + border: 1px solid var(--border); background: var(--panel); } + .tool-pill .tp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; + background: var(--dim); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); } + .tool-pill .tp-label { color: var(--fg); + font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; } + .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + overflow: hidden; } + .panel.red-border { border-color: var(--red); } + .panel-header { padding: 6px 12px; font-weight: 600; border-bottom: 1px solid var(--border); + font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; } + .panel-footer { padding: 4px 12px 8px 12px; color: var(--dim); font-size: 12px; } + pre.cmd, pre.cmd-out, pre.diff, pre.mono-block, pre.raw { + margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; + font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px; + } + pre.mono-block, pre.raw { background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; white-space: pre-wrap; } + .d-add { color: var(--diffadd); display: block; } + .d-rem { color: var(--red); display: block; } + .d-ctx { color: var(--dim); display: block; } + + /* Collapsible long panels: a big write/diff/output otherwise fills the screen and forces + endless scrolling, so panel-hosted blocks taller than ~22% of the window collapse to that + preview height by default. A matching Expand/Collapse button sits in the top-RIGHT and + bottom-RIGHT corners (JS adds them only when a block is actually tall) so it's reachable + whether you're at the top or, after expanding, down at the bottom. The header and footer + pad on the right to clear the buttons. Pure class flip on click — no animation loop. */ + .collapsible-panel { position: relative; } + .collapsible-panel > .panel-header, + .collapsible-panel > .panel-footer { + padding-right: 84px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + /* Reserve a bottom gutter so the bottom corner buttons never overlap the last line of a + footerless panel (e.g. command output). */ + pre.collapsible { position: relative; padding-bottom: 34px; } + pre.collapsible.collapsed { max-height: 22vh; overflow-y: hidden; } + .collapse-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 44px; + pointer-events: none; background: linear-gradient(to bottom, transparent, var(--panel)); } + .expand-btn { position: absolute; top: 6px; z-index: 3; cursor: pointer; + background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 2px 9px; font-size: 11px; + font-family: "Segoe UI", sans-serif; opacity: 0.9; } + .expand-btn.left { left: 6px; } + .expand-btn.right { right: 6px; } + .expand-btn.bottom { top: auto; bottom: 6px; } + .expand-btn:hover { color: var(--fg); border-color: var(--accent); opacity: 1; } + + /* Web fetch/search previews: noisy reference text, hidden by default behind an inline Expand + chip on the op line. Expanding reveals the detail box, which reuses the corner Collapse + button (.expand-btn.right) so it can be closed from the window itself. */ + .web-toggle { margin-left: 8px; cursor: pointer; vertical-align: baseline; + background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 1px 8px; font-size: 11px; font-family: "Segoe UI", sans-serif; } + .web-toggle:hover { color: var(--fg); border-color: var(--accent); } + .web-detail { position: relative; margin-top: 4px; } + .web-detail[hidden] { display: none; } + .web-detail > .op-detail { margin-top: 0; } + /* Action chips on USER-requested diff cards (Changes-tab clicks): Undo posts to the host, + Clear removes the card. Floated right in the header; the collapsible-panel header's + right padding keeps them clear of the corner Expand button. */ + .dv-actions { float: right; display: inline-flex; gap: 6px; } + .dv-btn { background: var(--bg); color: var(--dim); border: 1px solid var(--border); + border-radius: 6px; padding: 1px 8px; font-size: 11px; + font-family: "Segoe UI", sans-serif; cursor: pointer; } + .dv-btn:hover { color: var(--fg); border-color: var(--accent); } + a.file-link { color: var(--sky); text-decoration: none; + border-bottom: 1px dotted color-mix(in srgb, var(--sky) 55%, transparent); cursor: pointer; } + a.file-link:hover { color: var(--accent); border-bottom-color: var(--accent); } + .op { margin: 2px 0; } + .op-head { font-weight: 600; } + .op-path { font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; } + .op-meta { color: var(--dim); font-size: 12px; } + .op-detail { margin-top: 4px; background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; } + /* Prose tool output (web search/fetch): wrap to width, reading font, dimmed — reference material, + not a code block. Declared after pre.cmd-out so these win on shared properties. */ + pre.op-prose { white-space: pre-wrap; word-break: break-word; overflow-x: hidden; + font-family: "Segoe UI", sans-serif; font-size: 12.5px; color: var(--dim); } + table.plan { border-collapse: collapse; width: 100%; } + table.plan th, table.plan td { border-top: 1px solid var(--border); padding: 5px 12px; + text-align: left; vertical-align: top; } + table.plan th { color: var(--dim); font-weight: 600; } + .nowrap { white-space: nowrap; } + + /* Syntax highlighting: highlight.js token classes mapped onto the theme's CSS + variables, so code colors follow every theme (and survive live retheming). */ + .hljs { background: transparent; color: var(--fg); } + .hljs-comment, .hljs-quote { color: var(--dim); font-style: italic; } + .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag { color: var(--accent); } + .hljs-string, .hljs-regexp, .hljs-addition { color: var(--green); } + .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-meta, .hljs-built_in { color: var(--gold); } + .hljs-title, .hljs-section, .hljs-name, .hljs-title.function_, .hljs-title.class_ { color: var(--sky); } + .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-type { color: var(--sky); } + .hljs-deletion { color: var(--red); } + .hljs-emphasis { font-style: italic; } + .hljs-strong { font-weight: bold; } + + /* Copy chips — appear on hover over code blocks and assistant messages. Label is + CSS generated content so it never pollutes the copied innerText. */ + .copy-chip { position: absolute; top: 6px; right: 6px; z-index: 1; opacity: 0; + transition: opacity 0.12s; background: var(--bg); color: var(--dim); + border: 1px solid var(--border); border-radius: 6px; padding: 2px 9px; + font-size: 11px; font-family: "Segoe UI", sans-serif; cursor: pointer; } + .copy-chip::before { content: "Copy"; } + .copy-chip.copied::before { content: "Copied ✓"; } + .copy-chip.copied { color: var(--green); border-color: var(--green); } + .md pre:hover .copy-chip, .assistant:hover > .copy-chip { opacity: 1; } + .copy-chip:hover { color: var(--fg); border-color: var(--accent); } + + /* Reactions, Teams-style. A ghosted add-reaction button fades in on hover next to the + copy chip; clicking it opens a floating picker card (mirrors the input box's emoji + flyout). Chosen reactions sit under the message as pills — no space is reserved + until one exists. Delivery to the model: ChatController.SubmitAsync. */ + .react-ghost { position: absolute; top: 6px; right: 56px; z-index: 1; opacity: 0; + transition: opacity 0.12s; background: var(--bg); color: var(--dim); + border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; + font-size: 12px; cursor: pointer; + font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; } + .assistant:hover > .react-ghost { opacity: 0.55; } + .react-ghost:hover { opacity: 1 !important; border-color: var(--accent); color: var(--fg); } + /* The copy chip widens to "Copied ✓" for ~1.4s after a click; the ghost sits close + enough to collide, so it ducks out for the duration of the flash. */ + .copy-chip.copied ~ .react-ghost { opacity: 0 !important; pointer-events: none; } + #rx-pop { position: absolute; z-index: 50; display: none; width: 316px; + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + padding: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.45); } + #rx-pop .rx { background: none; border: 1px solid transparent; border-radius: 6px; + padding: 2px 5px; font-size: 17px; line-height: 22px; cursor: pointer; + font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; } + #rx-pop .rx:hover { background: var(--bg); border-color: var(--border); } + #rx-pop .rx.on { background: var(--bg); border-color: var(--accent); } + /* flex-wrap is the safety net: if emoji glyphs render wider than budgeted (font + version varies by Windows build), the row wraps inside the card instead of + bleeding past its border. */ + #rx-pop .rx-quick { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; } + #rx-pop .rx-more-btn { margin-left: auto; background: none; border: none; + color: var(--dim); font-size: 12px; cursor: pointer; padding: 2px 6px; } + #rx-pop .rx-more-btn:hover { color: var(--fg); } + #rx-pop .rx-grid { display: none; flex-wrap: wrap; gap: 2px; margin-top: 6px; + padding-top: 6px; border-top: 1px solid var(--border); max-height: 156px; + overflow-y: auto; } + .rx-tray { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } + .rx-pill { background: var(--panel); border: 1px solid var(--accent); border-radius: 999px; + padding: 1px 9px; font-size: 13px; line-height: 19px; cursor: pointer; + font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; } + .rx-pill:hover { border-color: var(--dim); opacity: 0.85; } + + /* Consecutive operation cards group into a collapsible run; it stays open while + the run is active and collapses once a non-operation block lands after it. */ + details.op-group { margin: 2px 0; } + details.op-group summary { color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; } + details.op-group summary:hover { color: var(--fg); } + details.op-group > .op { margin-left: 16px; } + + /* Jump-to-bottom pill — shows when scrolled away from the live end of the chat. */ + #jump-pill { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); + display: none; z-index: 40; background: var(--panel); color: var(--fg); + border: 1px solid var(--accent); border-radius: 999px; padding: 6px 14px; + font-size: 12px; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.4); } + + /* In-chat find bar (Ctrl+F while the transcript has focus). */ + #findbar { position: fixed; top: 10px; right: 16px; z-index: 60; display: none; + align-items: center; gap: 6px; background: var(--panel); + border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); } + #findbar input { background: var(--bg); color: var(--fg); border: 1px solid var(--border); + border-radius: 6px; padding: 3px 8px; font-size: 12px; width: 180px; outline: none; } + #findbar .find-count { color: var(--dim); font-size: 11px; min-width: 44px; text-align: center; } + #findbar button { background: none; border: none; color: var(--dim); cursor: pointer; + font-size: 12px; padding: 2px 6px; } + #findbar button:hover { color: var(--fg); } + mark.find-hit { background: var(--gold); color: #000; border-radius: 2px; } + mark.find-hit.find-current { background: var(--accent); color: #fff; } + + /* Scrollbars — Chromium's stock chrome ignores the theme; restyle every scroll surface + (page, code blocks, reaction picker grid) to match it. */ + ::-webkit-scrollbar { width: 10px; height: 10px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; + border: 2px solid transparent; background-clip: padding-box; } + ::-webkit-scrollbar-thumb:hover { background-color: var(--dim); } + ::-webkit-scrollbar-corner { background: transparent; } + #rx-pop .rx-grid::-webkit-scrollbar { width: 7px; } diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js new file mode 100644 index 0000000..be523bb --- /dev/null +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js @@ -0,0 +1,464 @@ + const log = document.getElementById('log'); + if (window.hljs) hljs.configure({ ignoreUnescapedHTML: true }); + + // --- append pipeline: group consecutive op cards, stamp timestamps --- + function groupSummary(d) { + const n = d.querySelectorAll(':scope > .op').length; + d.querySelector('summary').textContent = '⚙ ' + n + ' operation' + (n === 1 ? '' : 's'); + } + function placeChild(c) { + if (c.nodeType !== 1) { log.appendChild(c); return; } + if (c.classList.contains('op')) { + const prev = log.lastElementChild; + if (prev && prev.tagName === 'DETAILS' && prev.classList.contains('op-group') && prev.hasAttribute('open')) { + prev.appendChild(c); + groupSummary(prev); + return; + } + if (prev && prev.classList.contains('op')) { + const d = document.createElement('details'); + d.className = 'op-group'; + d.setAttribute('open', ''); + d.appendChild(document.createElement('summary')); + log.insertBefore(d, prev); + d.appendChild(prev); + d.appendChild(c); + groupSummary(d); + return; + } + log.appendChild(c); + return; + } + const last = log.lastElementChild; + if (last && last.tagName === 'DETAILS' && last.classList.contains('op-group')) + last.removeAttribute('open'); // run over — collapse the group + if (c.classList.contains('assistant') || c.classList.contains('user-echo')) + c.title = new Date().toLocaleTimeString(); + log.appendChild(c); + } + + // --- syntax highlighting + copy chips, applied to new nodes only --- + function highlightNew() { + if (!window.hljs) return; + log.querySelectorAll('.md pre code:not([data-hl])').forEach(function (c) { + c.setAttribute('data-hl', '1'); + try { hljs.highlightElement(c); } catch (err) { } + }); + } + function doCopy(text, chip) { + // In the app, the host writes the clipboard (copy: message). In an EXPORTED transcript + // there is no webview bridge, so fall back to the browser clipboard API — file:// pages + // are a secure context in Chromium/Firefox, and this runs on a user gesture. + if (window.chrome && window.chrome.webview) + window.chrome.webview.postMessage('copy:' + text); + else if (navigator.clipboard) + navigator.clipboard.writeText(text).catch(function () { }); + chip.classList.add('copied'); + setTimeout(function () { chip.classList.remove('copied'); }, 1400); + } + function addCopyChips() { + log.querySelectorAll('.md pre:not([data-copy])').forEach(function (pre) { + pre.setAttribute('data-copy', '1'); + const chip = document.createElement('button'); + chip.className = 'copy-chip'; + pre.appendChild(chip); // click is handled by the delegated .copy-chip handler + }); + log.querySelectorAll('.assistant:not([data-copy])').forEach(function (card) { + card.setAttribute('data-copy', '1'); + if (!card.querySelector('.md')) return; + const chip = document.createElement('button'); + chip.className = 'copy-chip'; + card.appendChild(chip); + }); + } + // Delegated so copy still works in exported transcripts (see the toggle handlers below). + document.addEventListener('click', function (e) { + const chip = e.target.closest('.copy-chip'); + if (!chip) return; + e.stopPropagation(); + const pre = chip.closest('pre'); + let text = ''; + if (pre) { + const code = pre.querySelector('code'); + text = code ? code.innerText : pre.innerText; + } else { + const card = chip.closest('.assistant'); + const md = card && card.querySelector('.md'); + if (md) text = md.innerText; + } + doCopy(text, chip); + }); + + // --- emoji reactions on assistant responses: hover ghost → picker card → pills --- + // Toggling posts react:/unreact: with a JSON payload; the snippet lets the preamble + // on the user's next turn say WHICH response was reacted to. + const RX_QUICK = ['👍', '👎', '❤️', '🔥', '🎉', '🤔', '😂']; + const RX_MORE = ['😀', '😄', '😊', '😉', '😍', '🥰', '😎', '🤓', '🙃', '😅', '😬', '😭', + '🥳', '🤯', '😴', '🙄', '😤', '😱', '🫠', '🤗', '🫡', '👌', '🙏', '👏', '💪', '🤝', + '✌️', '🤞', '👀', '🧠', '💯', '✨', '🚀', '🎯', '💡', '⚡', '⭐', '💔', '✅', '❌', + '⚠️', '❓', '❗', '💬', '🐛', '🔧', '🔒', '🔑', '📝', '📌', '📁', '🖥️', '☕', '🍕', + '🎮', '🤖']; + let rxSeq = 0; + let rxCard = null; // the card the open picker targets + + const rxPop = document.createElement('div'); + rxPop.id = 'rx-pop'; + document.body.appendChild(rxPop); + + function rxSnippet(card) { + const md = card.querySelector('.md'); + return (md ? md.innerText : '').trim().replace(/\s+/g, ' ').slice(0, 80); + } + function rxPillFor(card, emoji) { + const tray = card.querySelector('.rx-tray'); + if (!tray) return null; + return Array.prototype.find.call(tray.children, function (p) { return p.textContent === emoji; }); + } + // Toggle a reaction on a card: pill tray + picker highlight + postMessage, all in one place. + function rxToggle(card, emoji) { + const existing = rxPillFor(card, emoji); + if (existing) { + existing.remove(); + const tray = card.querySelector('.rx-tray'); + if (tray && !tray.children.length) tray.remove(); + window.chrome.webview.postMessage('unreact:' + + JSON.stringify({ id: card.dataset.rxId, emoji: emoji, snippet: '' })); + } else { + let tray = card.querySelector('.rx-tray'); + if (!tray) { + tray = document.createElement('div'); + tray.className = 'rx-tray'; + card.appendChild(tray); + } + const pill = document.createElement('button'); + pill.className = 'rx-pill'; + pill.textContent = emoji; + pill.title = 'Click to remove reaction'; + pill.addEventListener('click', function (ev) { + ev.stopPropagation(); + rxToggle(card, emoji); + }); + tray.appendChild(pill); + window.chrome.webview.postMessage('react:' + + JSON.stringify({ id: card.dataset.rxId, emoji: emoji, snippet: rxSnippet(card) })); + } + // Picking (or un-picking) from the open picker dismisses it — one-shot action, + // like Teams/Slack. Multiple reactions = reopen; chosen ones show highlighted. + if (rxPop.style.display === 'block' && rxCard === card) closeRxPop(); + } + function rxChip(parent, emoji) { + const b = document.createElement('button'); + b.className = 'rx' + (rxCard && rxPillFor(rxCard, emoji) ? ' on' : ''); + b.textContent = emoji; + b.addEventListener('click', function (ev) { + ev.stopPropagation(); + rxToggle(rxCard, emoji); + }); + parent.appendChild(b); + } + function openRxPop(card, anchor) { + rxCard = card; + rxPop.innerHTML = ''; + const quick = document.createElement('div'); + quick.className = 'rx-quick'; + RX_QUICK.forEach(function (e) { rxChip(quick, e); }); + const moreBtn = document.createElement('button'); + moreBtn.className = 'rx-more-btn'; + moreBtn.textContent = 'More ▾'; + quick.appendChild(moreBtn); + rxPop.appendChild(quick); + const grid = document.createElement('div'); + grid.className = 'rx-grid'; + RX_MORE.forEach(function (e) { rxChip(grid, e); }); + rxPop.appendChild(grid); + moreBtn.addEventListener('click', function (ev) { + ev.stopPropagation(); + const opening = grid.style.display !== 'flex'; + grid.style.display = opening ? 'flex' : 'none'; + moreBtn.textContent = opening ? 'Less ▴' : 'More ▾'; + }); + // Anchor under the ghost button, right-aligned; flip above when near the viewport bottom. + rxPop.style.display = 'block'; + const r = anchor.getBoundingClientRect(); + const pw = rxPop.offsetWidth, ph = rxPop.offsetHeight; + const left = Math.max(8, Math.min(r.right - pw, window.innerWidth - pw - 8)) + window.scrollX; + let top = r.bottom + 6 + window.scrollY; + if (r.bottom + ph + 12 > window.innerHeight) top = Math.max(window.scrollY + 8, r.top - ph - 6 + window.scrollY); + rxPop.style.left = left + 'px'; + rxPop.style.top = top + 'px'; + } + function closeRxPop() { rxPop.style.display = 'none'; rxCard = null; } + document.addEventListener('click', function (e) { + if (rxPop.style.display === 'block' && !rxPop.contains(e.target)) closeRxPop(); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeRxPop(); + }); + + function addReactionGhosts() { + log.querySelectorAll('.assistant:not([data-rx])').forEach(function (card) { + card.setAttribute('data-rx', '1'); + card.dataset.rxId = String(++rxSeq); + const ghost = document.createElement('button'); + ghost.className = 'react-ghost'; + ghost.textContent = '🙂+'; + ghost.title = 'React to this response'; + ghost.addEventListener('click', function (ev) { + ev.stopPropagation(); + if (rxPop.style.display === 'block' && rxCard === card) { closeRxPop(); return; } + openRxPop(card, ghost); + }); + card.appendChild(ghost); + }); + } + + // --- collapse long diff/output panels to a preview; corner buttons maximize/minimize --- + // Only panel-hosted
 blocks (diffs, command output, folder-delete listings) taller than
+  // ~22% of the window get collapsed. A matching Expand/Collapse button is placed in the top-RIGHT
+  // and bottom-RIGHT corners so it's reachable from the top or — after expanding down — the bottom.
+  function setCollapsed(pre, collapsed) {
+    pre.classList.toggle('collapsed', collapsed);
+    const panel = pre.closest('.panel');
+    if (!panel) return;
+    const fade = panel.querySelector('.collapse-fade');
+    if (fade) fade.style.display = collapsed ? 'block' : 'none';
+    panel.querySelectorAll('.expand-btn').forEach(function (b) {
+      b.textContent = collapsed ? '⤢ Expand' : '⤡ Collapse';
+    });
+  }
+  function addCollapsers() {
+    log.querySelectorAll('pre.diff:not([data-collapse]), pre.cmd-out:not([data-collapse])').forEach(function (pre) {
+      pre.setAttribute('data-collapse', '1');
+      const panel = pre.closest('.panel');
+      if (!panel) return;                                               // only panel-hosted blocks
+      if (pre.scrollHeight <= window.innerHeight * 0.22 + 40) return;   // short enough already
+      pre.classList.add('collapsible');
+      panel.classList.add('collapsible-panel');
+      const fade = document.createElement('div');
+      fade.className = 'collapse-fade';
+      pre.appendChild(fade);
+      ['right', 'right bottom'].forEach(function (side) {
+        const b = document.createElement('button');
+        b.className = 'expand-btn ' + side;
+        b.title = 'Maximize / minimize this block';
+        panel.appendChild(b);   // click is handled by the delegated .expand-btn handler
+      });
+      setCollapsed(pre, true);                                          // start minimized
+    });
+  }
+
+  // --- clamp long user prompts: a big pasted prompt (log, file contents) otherwise
+  // dominates the scrollback, so echoes taller than ~9 lines clamp to ~8 with a toggle ---
+  function addEchoClamps() {
+    log.querySelectorAll('.user-echo:not([data-clamp])').forEach(function (echo) {
+      echo.setAttribute('data-clamp', '1');
+      const lh = parseFloat(getComputedStyle(echo).lineHeight) || 20;
+      if (echo.scrollHeight <= lh * 9 + 6) return;   // short enough — no chrome
+      // Count hidden lines while the echo is still unclamped; ~8 lines stay visible.
+      const hidden = Math.max(1, Math.round(echo.scrollHeight / lh) - 8);
+      echo.classList.add('clamped');
+      const btn = document.createElement('button');
+      btn.className = 'ue-toggle';
+      // The expanded label lives in a data attribute (not a closure) so it survives
+      // outerHTML serialization when the transcript is exported.
+      btn.dataset.more = 'Show more (' + hidden + ' more line' + (hidden === 1 ? '' : 's') + ')';
+      btn.textContent = btn.dataset.more;
+      echo.after(btn);
+    });
+  }
+  // Toggles are DELEGATED document handlers, not per-button listeners: exporting the
+  // transcript serializes outerHTML, which keeps the buttons but drops bound listeners.
+  // These handlers re-register when the exported page runs this script on load, so
+  // clamped prompts and collapsed panels stay expandable in the saved file.
+  document.addEventListener('click', function (e) {
+    const btn = e.target.closest('.ue-toggle');
+    if (!btn) return;
+    const echo = btn.previousElementSibling;
+    if (!echo || !echo.classList.contains('user-echo')) return;
+    const clamped = echo.classList.toggle('clamped');
+    btn.textContent = clamped ? btn.dataset.more : 'Show less';
+  });
+  document.addEventListener('click', function (e) {
+    const b = e.target.closest('.expand-btn:not(.web-collapse)');
+    if (!b) return;
+    const panel = b.closest('.panel');
+    const pre = panel && panel.querySelector('pre.collapsible');
+    if (pre) setCollapsed(pre, !pre.classList.contains('collapsed'));
+  });
+
+  window.__append = function (html) {
+    const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 60);
+    const wrap = document.createElement('div');
+    wrap.innerHTML = html;
+    while (wrap.firstChild) placeChild(wrap.firstChild);
+    highlightNew();
+    addCopyChips();
+    addReactionGhosts();
+    addCollapsers();
+    addEchoClamps();
+    if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
+    updatePill();
+  };
+  window.__clear = function () { log.innerHTML = ''; updatePill(); };
+
+  document.addEventListener('click', function (e) {
+    const link = e.target.closest('a[data-file]');
+    if (!link) return;
+    e.preventDefault();
+    window.chrome.webview.postMessage('open-file:' + link.getAttribute('data-file'));
+  });
+
+  // Interactive diff-card chips (delegated — survives transcript export, like the toggles).
+  // Clear just deletes the card from the DOM; Undo asks the host, which confirms before
+  // discarding anything. In an exported page Undo is a harmless no-op (no webview bridge).
+  document.addEventListener('click', function (e) {
+    const clear = e.target.closest('.dv-clear');
+    if (clear) {
+      const panel = clear.closest('.panel');
+      if (panel) panel.remove();
+      return;
+    }
+    const undo = e.target.closest('.dv-undo');
+    if (undo && window.chrome && window.chrome.webview)
+      window.chrome.webview.postMessage('undo-file:' + undo.getAttribute('data-file'));
+  });
+
+  // --- drag hand-off: Chromium owns drags over the transcript surface, so XAML never sees
+  // them. On dragenter we alert the host, which mounts its drop overlay over this WebView;
+  // the OS then retargets the drag (and the drop, with real file paths) to that overlay.
+  // preventDefault on dragover/drop is the safety net for a drop that lands in the instant
+  // before the overlay mounts — without it the browser would navigate to the dropped file.
+  window.addEventListener('dragenter', function (e) {
+    e.preventDefault();
+    if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('drag-enter');
+  });
+  window.addEventListener('dragover', function (e) { e.preventDefault(); });
+  window.addEventListener('drop', function (e) { e.preventDefault(); });
+
+  // Web fetch/search preview toggle: the inline chip opens the hidden detail box; the box's own
+  // Collapse button (and the chip again) closes it. Chip label and box visibility stay in sync.
+  document.addEventListener('click', function (e) {
+    const t = e.target.closest('.web-toggle, .web-collapse');
+    if (!t) return;
+    e.stopPropagation();
+    const op = t.closest('.op');
+    if (!op) return;
+    const detail = op.querySelector('.web-detail');
+    const toggle = op.querySelector('.web-toggle');
+    if (!detail || !toggle) return;
+    const open = t.classList.contains('web-collapse') ? false : detail.hasAttribute('hidden');
+    detail.toggleAttribute('hidden', !open);
+    toggle.textContent = open ? '⤡ Collapse' : '⤢ Expand';
+  });
+
+  // --- jump-to-bottom pill ---
+  const pill = document.createElement('div');
+  pill.id = 'jump-pill';
+  pill.textContent = '↓ Latest';
+  document.body.appendChild(pill);
+  pill.addEventListener('click', function () {
+    window.scrollTo({ top: document.body.scrollHeight,
+      behavior: document.documentElement.hasAttribute('data-flat') ? 'auto' : 'smooth' });
+  });
+  function updatePill() {
+    const nb = (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 80);
+    pill.style.display = nb ? 'none' : 'block';
+  }
+  window.addEventListener('scroll', updatePill);
+
+  // --- in-chat find (Ctrl+F) ---
+  let findBar = null, findHits = [], findIdx = -1;
+  function ensureFindBar() {
+    if (findBar) return;
+    findBar = document.createElement('div');
+    findBar.id = 'findbar';
+    findBar.innerHTML = '' +
+      '';
+    document.body.appendChild(findBar);
+    const input = findBar.querySelector('input');
+    let deb = null;
+    input.addEventListener('input', function () {
+      clearTimeout(deb);
+      deb = setTimeout(function () { runFind(input.value); }, 150);
+    });
+    input.addEventListener('keydown', function (e) {
+      if (e.key === 'Enter') { e.preventDefault(); stepFind(e.shiftKey ? -1 : 1); }
+    });
+    findBar.addEventListener('click', function (e) {
+      const b = e.target.closest('button');
+      if (!b) return;
+      if (b.dataset.act === 'prev') stepFind(-1);
+      else if (b.dataset.act === 'next') stepFind(1);
+      else closeFind();
+    });
+  }
+  function setCount() {
+    if (!findBar) return;
+    findBar.querySelector('.find-count').textContent = findHits.length ? (findIdx + 1) + '/' + findHits.length : '';
+  }
+  function clearFind() {
+    findHits.forEach(function (m) {
+      const p = m.parentNode;
+      if (!p) return;
+      p.replaceChild(document.createTextNode(m.textContent), m);
+      p.normalize();
+    });
+    findHits = [];
+    findIdx = -1;
+    setCount();
+  }
+  function runFind(q) {
+    clearFind();
+    if (!q || q.length < 2) return;
+    const needle = q.toLowerCase();
+    const walker = document.createTreeWalker(log, NodeFilter.SHOW_TEXT, null);
+    const nodes = [];
+    let n;
+    while ((n = walker.nextNode())) {
+      if (n.textContent.toLowerCase().includes(needle)) nodes.push(n);
+    }
+    nodes.forEach(function (node) {
+      let text = node, idx;
+      while ((idx = text.textContent.toLowerCase().indexOf(needle)) >= 0) {
+        const hit = text.splitText(idx);
+        const rest = hit.splitText(q.length);
+        const m = document.createElement('mark');
+        m.className = 'find-hit';
+        hit.parentNode.replaceChild(m, hit);
+        m.appendChild(hit);
+        findHits.push(m);
+        text = rest;
+      }
+    });
+    if (findHits.length) { findIdx = 0; focusHit(); }
+    setCount();
+  }
+  function stepFind(dir) {
+    if (!findHits.length) return;
+    findHits[findIdx].classList.remove('find-current');
+    findIdx = (findIdx + dir + findHits.length) % findHits.length;
+    focusHit();
+    setCount();
+  }
+  function focusHit() {
+    const m = findHits[findIdx];
+    m.classList.add('find-current');
+    m.scrollIntoView({ block: 'center' });
+  }
+  function closeFind() {
+    clearFind();
+    if (findBar) findBar.style.display = 'none';
+  }
+  document.addEventListener('keydown', function (e) {
+    if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
+      e.preventDefault();
+      ensureFindBar();
+      findBar.style.display = 'flex';
+      const input = findBar.querySelector('input');
+      input.focus();
+      input.select();
+    }
+    else if (e.key === 'Escape' && findBar && findBar.style.display !== 'none') {
+      closeFind();
+    }
+  });
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs
new file mode 100644
index 0000000..461ddd6
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs
@@ -0,0 +1,342 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // IApprovalUi — this tab's approval overlay (completes the harness's awaited TCS)
+    // ============================================================
+
+    public Task ShowApprovalAsync(ApprovalRequest request, CancellationToken ct = default)
+    {
+        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        _approvalTcs = tcs;
+
+        var reg = ct.CanBeCanceled
+            ? ct.Register(() =>
+            {
+                tcs.TrySetCanceled(ct);
+                OnUi(() => { HideApprovalOverlay(); HidePlanApprovalBar(); });
+            })
+            : default(CancellationTokenRegistration);
+
+        OnUi(() =>
+        {
+            // What the cross-tab toast will say — a specific "what's waiting" line, not the modal's
+            // question. Set for both the bottom-bar and modal paths.
+            _approvalSummary = string.IsNullOrEmpty(request.ToastSummary) ? request.Title : request.ToastSummary;
+
+            // Plan approvals render as a non-covering bottom bar so the plan card stays readable.
+            if (request.BottomBar)
+            {
+                ShowPlanApprovalBar(request, choice =>
+                {
+                    HidePlanApprovalBar();
+                    reg.Dispose();
+                    tcs.TrySetResult(choice);
+                    if (ReferenceEquals(_approvalTcs, tcs)) _approvalTcs = null;
+                });
+                return;
+            }
+
+            ApprovalTitle.Text = request.Title;
+
+            ApprovalSubtitle.Text = request.Subtitle ?? "";
+            ApprovalSubtitle.Visibility = string.IsNullOrEmpty(request.Subtitle) ? Visibility.Collapsed : Visibility.Visible;
+
+            ApprovalDetail.Text = request.Detail ?? "";
+            ApprovalDetail.Visibility = string.IsNullOrEmpty(request.Detail) ? Visibility.Collapsed : Visibility.Visible;
+
+            // Pull the shared, theme-mutated brushes from app resources so the approval diff
+            // follows the active theme (these used to be hardcoded LightSkyBlue/red/gray, which
+            // stayed blue under every theme — jarring under E-Ink). Mirrors the transcript's
+            // diff coloring: command/added -> sky, removed -> red, context -> dim.
+            var skyBrush = (SolidColorBrush)Application.Current.Resources["MandoSkyBrush"];
+            var redBrush = (SolidColorBrush)Application.Current.Resources["MandoRedBrush"];
+            var dimBrush = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+
+            var rows = new List();
+            if (request.CommandText != null)
+            {
+                rows.Add(new DiffLineVm
+                {
+                    Text = $"$ {request.CommandText}",
+                    Brush = skyBrush
+                });
+            }
+            if (request.DiffLines != null)
+            {
+                foreach (var line in request.DiffLines)
+                {
+                    var (prefix, brush) = line.LineType switch
+                    {
+                        DiffLineType.Added => ("+ ", skyBrush),
+                        DiffLineType.Removed => ("- ", redBrush),
+                        _ => ("  ", dimBrush)
+                    };
+                    var num = (line.LineType == DiffLineType.Added ? line.NewLineNumber : line.OldLineNumber);
+                    rows.Add(new DiffLineVm
+                    {
+                        Text = $"{(num.HasValue ? num.Value.ToString().PadLeft(4) : "    ")} {prefix}{line.Content}",
+                        Brush = brush
+                    });
+                }
+                if (request.DiffSummary != null)
+                    rows.Add(new DiffLineVm { Text = "", Brush = dimBrush });
+            }
+            ApprovalDiffList.ItemsSource = rows;
+            ApprovalBodyScroll.Visibility = rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
+
+            if (!string.IsNullOrEmpty(request.DiffSummary))
+            {
+                ApprovalDetail.Text = request.DiffSummary;
+                ApprovalDetail.Visibility = Visibility.Visible;
+            }
+
+            ApprovalButtons.Children.Clear();
+            foreach (var option in request.Options)
+            {
+                var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
+                if (!string.IsNullOrEmpty(option.Glyph))
+                    content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
+                content.Children.Add(new TextBlock { Text = option.Label });
+                var button = new Button
+                {
+                    Content = content,
+                    HorizontalAlignment = HorizontalAlignment.Stretch,
+                    HorizontalContentAlignment = HorizontalAlignment.Left,
+                    Tag = option.Label
+                };
+                button.Foreground = option.Kind switch
+                {
+                    ApprovalOptionKind.Proceed => (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"],
+                    ApprovalOptionKind.Destructive => (SolidColorBrush)Application.Current.Resources["MandoRedBrush"],
+                    _ => (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
+                };
+                if (!string.IsNullOrEmpty(option.Description))
+                    ToolTipService.SetToolTip(button, option.Description);
+                button.Click += (_, _) =>
+                {
+                    var choice = (string)button.Tag;
+                    HideApprovalOverlay();
+                    reg.Dispose();
+                    _approvalTcs?.TrySetResult(choice);
+                    _approvalTcs = null;
+                };
+                ApprovalButtons.Children.Add(button);
+            }
+
+            InstructionPanel.Visibility = Visibility.Collapsed;
+            ApprovalButtons.Visibility = Visibility.Visible;
+            SetApprovalCardSize(instructionMode: false);
+            ShowApprovalOverlay();
+        });
+
+        return tcs.Task;
+    }
+
+    /// Approval mode: compact centered card. Instruction mode: full width and
+    /// half the window height, centered — room to write real instructions.
+    private void SetApprovalCardSize(bool instructionMode)
+    {
+        if (instructionMode)
+        {
+            ApprovalCard.HorizontalAlignment = HorizontalAlignment.Stretch;
+            ApprovalCard.MaxWidth = double.PositiveInfinity;
+            ApprovalCard.MaxHeight = double.PositiveInfinity;
+            ApprovalCard.Height = Math.Max(320, ChatRoot.ActualHeight * 0.5);
+        }
+        else
+        {
+            ApprovalCard.HorizontalAlignment = HorizontalAlignment.Center;
+            ApprovalCard.MaxWidth = 860;
+            ApprovalCard.MaxHeight = 640;
+            ApprovalCard.Height = double.NaN;
+        }
+    }
+
+    public Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default)
+    {
+        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        _instructionTcs = tcs;
+
+        OnUi(() =>
+        {
+            // First line is the question; any extra lines (e.g. a validation error on
+            // re-prompt) render below it in the smaller prompt text.
+            var newline = prompt.IndexOf('\n');
+            ApprovalTitle.Text = newline < 0 ? prompt : prompt[..newline];
+            InstructionPrompt.Text = newline < 0 ? "" : prompt[(newline + 1)..].Trim();
+            InstructionPrompt.Visibility = InstructionPrompt.Text.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
+
+            ApprovalSubtitle.Visibility = Visibility.Collapsed;
+            ApprovalDetail.Visibility = Visibility.Collapsed;
+            ApprovalBodyScroll.Visibility = Visibility.Collapsed;
+            ApprovalButtons.Visibility = Visibility.Collapsed;
+
+            InstructionBox.Text = "";
+            InstructionBox.PlaceholderText = string.IsNullOrEmpty(placeholder)
+                ? "Type your answer and press Enter"
+                : placeholder;
+            InstructionCancelButton.Visibility = allowCancel ? Visibility.Visible : Visibility.Collapsed;
+            InstructionPanel.Visibility = Visibility.Visible;
+            SetApprovalCardSize(instructionMode: true);
+            ShowApprovalOverlay();
+            InstructionBox.Focus(FocusState.Programmatic);
+        });
+
+        return tcs.Task;
+    }
+
+    private void InstructionBox_KeyDown(object sender, KeyRoutedEventArgs e)
+    {
+        if (e.Key == VirtualKey.Enter)
+        {
+            // Shift+Enter inserts a newline (the box is multi-line); plain Enter submits.
+            // PreviewKeyDown is required here — with AcceptsReturn, the class handler
+            // would insert the newline before a plain KeyDown handler ever ran.
+            var shift = Microsoft.UI.Input.InputKeyboardSource
+                .GetKeyStateForCurrentThread(VirtualKey.Shift)
+                .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+            if (shift) return;
+            e.Handled = true;
+            SubmitInstruction();
+        }
+        else if (e.Key == VirtualKey.Escape && InstructionCancelButton.Visibility == Visibility.Visible)
+        {
+            e.Handled = true;
+            CancelInstruction();
+        }
+    }
+
+    private void InstructionSubmit_Click(object sender, RoutedEventArgs e) => SubmitInstruction();
+
+    private void InstructionCancel_Click(object sender, RoutedEventArgs e) => CancelInstruction();
+
+    private void SubmitInstruction()
+    {
+        var text = InstructionBox.Text;
+        HideApprovalOverlay();
+        _instructionTcs?.TrySetResult(text);
+        _instructionTcs = null;
+    }
+
+    private void CancelInstruction()
+    {
+        HideApprovalOverlay();
+        _instructionTcs?.TrySetResult(ApprovalSignals.Cancelled);
+        _instructionTcs = null;
+    }
+
+    /// An approval raised in a background tab can't steal focus, so MainWindow badges
+    /// that tab and raises the toast instead.
+    private void ShowApprovalOverlay()
+    {
+        ApprovalOverlay.Visibility = Visibility.Visible;
+        ApprovalStateChanged?.Invoke(this);
+    }
+
+    private void HideApprovalOverlay()
+    {
+        ApprovalOverlay.Visibility = Visibility.Collapsed;
+        ApprovalDiffList.ItemsSource = null;
+        ApprovalStateChanged?.Invoke(this);
+        InputBox.Focus(FocusState.Programmatic);
+    }
+
+    /// Slides the plan-approval bar up above the input. Unlike the modal it doesn't cover the
+    /// transcript (the plan stays readable), but it DOES gate input — the turn is awaiting the choice.
+    private void ShowPlanApprovalBar(ApprovalRequest request, Action onChosen)
+    {
+        // Windows 98 theme: the bar drops its rounded card look and reads as a silver
+        // dialog strip — square corners, dialog-face background. Rebuilt on every show,
+        // so live theme switches take effect on the next approval.
+        var win98 = ThemeManager.Current.Win98;
+        PlanApprovalBar.CornerRadius = new CornerRadius(win98 ? 0 : 12);
+        PlanApprovalBar.Background = (Brush)Application.Current.Resources[
+            win98 ? "MandoBackgroundBrush" : "MandoPanelBrush"];
+
+        PlanApprovalTitle.Text = request.Title;
+
+        // Command approvals ride this bar too: show the command in monospace. The buttons
+        // live in a WrapPanel — one horizontal row whenever it fits, wrapping only when the
+        // window is too narrow for the long "don't ask again" labels.
+        PlanApprovalCommand.Text = string.IsNullOrEmpty(request.CommandText) ? "" : "$ " + request.CommandText;
+        PlanApprovalCommand.Visibility = string.IsNullOrEmpty(request.CommandText)
+            ? Visibility.Collapsed : Visibility.Visible;
+
+        PlanApprovalButtons.Children.Clear();
+        foreach (var option in request.Options)
+        {
+            var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+            if (!string.IsNullOrEmpty(option.Glyph))
+                content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
+            content.Children.Add(new TextBlock { Text = option.Label });
+
+            var button = new Button { Content = content, Tag = option.Label, Padding = new Thickness(14, 6, 14, 6) };
+            if (win98) button.CornerRadius = new CornerRadius(0);   // square, like every 98 control
+            if (option.Kind == ApprovalOptionKind.Proceed)
+                button.Style = (Style)Application.Current.Resources["AccentButtonStyle"];   // primary
+            else
+                button.Foreground = option.Kind == ApprovalOptionKind.Destructive
+                    ? (SolidColorBrush)Application.Current.Resources["MandoRedBrush"]
+                    : (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+            if (!string.IsNullOrEmpty(option.Description))
+                ToolTipService.SetToolTip(button, option.Description);
+            button.Click += (_, _) => onChosen((string)button.Tag);
+            PlanApprovalButtons.Children.Add(button);
+        }
+
+        // Gate input while the plan is awaiting a decision.
+        InputBox.IsEnabled = false;
+        SendButton.IsEnabled = false;
+        EmojiButton.IsEnabled = false;
+
+        PlanApprovalBar.Visibility = Visibility.Visible;
+        ApprovalStateChanged?.Invoke(this);
+
+        var slide = new DoubleAnimation
+        {
+            From = 24, To = 0,
+            Duration = new Duration(TimeSpan.FromMilliseconds(220)),
+            EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+        };
+        Storyboard.SetTarget(slide, PlanApprovalTransform);
+        Storyboard.SetTargetProperty(slide, "Y");
+        var fade = new DoubleAnimation
+        {
+            From = 0, To = 1,
+            Duration = new Duration(TimeSpan.FromMilliseconds(180)),
+        };
+        Storyboard.SetTarget(fade, PlanApprovalBar);
+        Storyboard.SetTargetProperty(fade, "Opacity");
+        var sb = new Storyboard();
+        sb.Children.Add(slide);
+        sb.Children.Add(fade);
+        sb.Begin();
+    }
+
+    private void HidePlanApprovalBar()
+    {
+        if (PlanApprovalBar.Visibility != Visibility.Visible) return;
+        PlanApprovalBar.Visibility = Visibility.Collapsed;
+        InputBox.IsEnabled = true;
+        SendButton.IsEnabled = true;
+        EmojiButton.IsEnabled = true;
+        ApprovalStateChanged?.Invoke(this);
+        InputBox.Focus(FocusState.Programmatic);
+    }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
new file mode 100644
index 0000000..fa21b85
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs
@@ -0,0 +1,920 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // Git status strip
+    // ============================================================
+
+    private int _branchRefreshSeq;
+    private DateTime _lastBranchRefresh = DateTime.MinValue;
+    private string? _lastGitRoot;
+    private readonly ObservableCollection _changes = new();
+
+    /// Fire-and-forget refresh of the bottom status strip AND the explorer's Changes
+    /// tab (one git call feeds both). Throttled (UpdateHeader runs on every controller state
+    /// change) except when the root changed; sequence-guarded so an older, slower git call
+    /// can never overwrite a newer result; any failure just hides the strip.
+    private async void RefreshBranchChip(bool force = false)
+    {
+        var root = _controller.ProjectRootPath;
+        if (root != _lastGitRoot) force = true;   // never show the previous folder's state
+        if (!force && (DateTime.UtcNow - _lastBranchRefresh).TotalSeconds < 2) return;
+        _lastBranchRefresh = DateTime.UtcNow;
+        _lastGitRoot = root;
+
+        var seq = ++_branchRefreshSeq;
+        var info = await Task.Run(() => GitQuickStatus.TryGet(root));
+
+        if (_shutDown || seq != _branchRefreshSeq) return;
+        _lastGitInfo = info;
+        UpdateChangesList(info, root);
+        _wsTracker.CaptureBaselineIfPending(info);
+        if (info == null)
+        {
+            StatusStrip.Visibility = Visibility.Collapsed;
+            return;
+        }
+
+        BranchText.Text = info.Branch
+            + (info.Ahead > 0 ? $" ↑{info.Ahead}" : "")
+            + (info.Behind > 0 ? $" ↓{info.Behind}" : "");
+
+        // One status light: conflicts trump dirty trumps clean.
+        var (dotBrush, state) =
+            info.Conflicted ? ("MandoRedBrush", "merge conflicts")
+            : info.Dirty ? ("MandoGoldBrush", "uncommitted changes")
+            : ("MandoGreenBrush", "clean");
+        BranchDot.Fill = Application.Current.Resources[dotBrush] as Brush;
+
+        var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
+            Path.TrimEndingDirectorySeparator(info.RepoRoot),
+            Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
+        ToolTipService.SetToolTip(StatusStrip,
+            (info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
+            + " — " + state
+            + (info.Ahead > 0 || info.Behind > 0
+                ? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
+            // Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
+            + (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
+        StatusStrip.Visibility = Visibility.Visible;
+    }
+
+    /// Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).
+    private void UpdateChangesList(GitBranchInfo? info, string root)
+    {
+        if (ChangesList.ItemsSource == null) ChangesList.ItemsSource = _changes;
+
+        // Rebuilding the collection re-realizes every ListView row — a visible flash — so
+        // bail when this snapshot is identical to what's already shown (the common case:
+        // most refreshes confirm state rather than change it). Badges derive from the same
+        // data, so they can't have changed either.
+        var incoming = info?.Changes ?? (IReadOnlyList)Array.Empty();
+        if (incoming.Count == _changes.Count)
+        {
+            var identical = true;
+            for (var i = 0; i < incoming.Count; i++)
+            {
+                if (incoming[i].RelPath != _changes[i].RelPath || incoming[i].Kind != _changes[i].Kind)
+                {
+                    identical = false;
+                    break;
+                }
+            }
+            if (identical) return;
+        }
+
+        _changes.Clear();
+        if (info != null)
+        {
+            foreach (var c in info.Changes)
+            {
+                var relNative = c.RelPath.Replace('/', Path.DirectorySeparatorChar);
+                _changes.Add(new GitChangeItem
+                {
+                    Kind = c.Kind,
+                    KindBrush = BrushForKind(c.Kind),
+                    KindLabel = c.Kind switch
+                    {
+                        "!" => "Merge conflict",
+                        "U" => "Untracked (new, not yet added)",
+                        "A" => "Added",
+                        "D" => "Deleted",
+                        "R" => "Renamed",
+                        _ => "Modified",
+                    },
+                    Name = Path.GetFileName(c.RelPath.TrimEnd('/')),
+                    Dir = Path.GetDirectoryName(relNative)?.Replace(Path.DirectorySeparatorChar, '/') ?? "",
+                    FullPath = Path.Combine(root, relNative),
+                    RelPath = c.RelPath,
+                    TagTooltip = $"Tag in prompt — inserts @{c.RelPath}",
+                });
+            }
+        }
+
+        ChangesTabButton.Content = _changes.Count > 0 ? $"Changes ({_changes.Count})" : "Changes";
+        ChangesEmptyText.Visibility = _changesTabActive && _changes.Count == 0
+            ? Visibility.Visible : Visibility.Collapsed;
+        CommitButton.IsEnabled = _changes.Count > 0;
+
+        RebuildDirtySets(info);
+        RefreshExplorerDirtyFlags();
+    }
+
+    // --- dirty badges on the file tree ---
+    // A changed file gets a gold dot; every ancestor folder gets one too, so a collapsed
+    // folder still signals "something inside changed" (VS Code's badge behavior).
+
+    private readonly HashSet _gitDirtyFiles = new(StringComparer.OrdinalIgnoreCase);
+    private readonly HashSet _gitDirtyDirs = new(StringComparer.OrdinalIgnoreCase);
+
+    private void RebuildDirtySets(GitBranchInfo? info)
+    {
+        _gitDirtyFiles.Clear();
+        _gitDirtyDirs.Clear();
+        if (info == null) return;
+        foreach (var c in info.Changes)
+        {
+            var rel = c.RelPath.TrimEnd('/');
+            // Untracked directories arrive as one "dir/" entry — that's a dir badge, not a file.
+            if (c.RelPath.EndsWith('/')) _gitDirtyDirs.Add(rel);
+            else _gitDirtyFiles.Add(rel);
+            for (var slash = rel.LastIndexOf('/'); slash > 0; slash = rel.LastIndexOf('/'))
+            {
+                rel = rel[..slash];
+                _gitDirtyDirs.Add(rel);
+            }
+        }
+    }
+
+    /// Re-flags every REALIZED tree node in place (expansion state survives).
+    /// Nodes created later pick their flag up at creation in LoadChildNodes.
+    private void RefreshExplorerDirtyFlags()
+    {
+        Walk(ExplorerTree.RootNodes);
+
+        void Walk(IList nodes)
+        {
+            foreach (var node in nodes)
+            {
+                if (node.Content is ExplorerItem item) item.Dirty = IsItemDirty(item);
+                if (node.Children.Count > 0) Walk(node.Children);
+            }
+        }
+    }
+
+    private bool IsItemDirty(ExplorerItem item) =>
+        item.IsDirectory ? _gitDirtyDirs.Contains(item.RelPath) : _gitDirtyFiles.Contains(item.RelPath);
+
+    private static Brush? BrushForKind(string kind) =>
+        Application.Current.Resources[kind switch
+        {
+            "!" or "D" => "MandoRedBrush",
+            "A" or "U" => "MandoGreenBrush",
+            "R" => "MandoSkyBrush",
+            _ => "MandoGoldBrush",
+        }] as Brush;
+
+    private void UpdatePlanProgress(int done, int total, bool active)
+    {
+        PlanProgressPanel.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
+        if (total > 0)
+        {
+            PlanProgressBar.Value = done * 100.0 / total;
+            PlanProgressText.Text = $"Plan: step {Math.Min(done + 1, total)} of {total}";
+        }
+    }
+
+    /// 
+    /// Populates the model dropdown each time it opens. The flyout appears immediately showing a
+    /// loading spinner; this awaits the model list off the UI thread and swaps in the rows (or an
+    /// inline error) when it returns. Tab-local — picking a model repins THIS agent only.
+    /// 
+    private async void ModelFlyout_Opening(object? sender, object e)
+    {
+        ModelLoadingPanel.Visibility = Visibility.Visible;
+        ModelErrorText.Visibility = Visibility.Collapsed;
+        ModelList.Visibility = Visibility.Collapsed;
+
+        var result = await _controller.LoadAvailableModelsAsync();
+
+        if (!result.Ok)
+        {
+            ModelErrorText.Text = result.Error;
+            ModelLoadingPanel.Visibility = Visibility.Collapsed;
+            ModelErrorText.Visibility = Visibility.Visible;
+            return;
+        }
+
+        var sky = (Brush)Application.Current.Resources["MandoSkyBrush"];
+        var dim = (Brush)Application.Current.Resources["MandoDimBrush"];
+        var badgeBg = new SolidColorBrush(Windows.UI.Color.FromArgb(0x22, 0x80, 0x80, 0x80));
+        var current = _controller.ModelName;
+
+        var items = result.Models.Select(m =>
+        {
+            var cloud = MandoCodeConfig.IsCloudModel(m);
+            return new ModelItem(m, cloud ? "cloud" : "local", cloud ? sky : dim, badgeBg);
+        }).ToList();
+
+        ModelList.ItemsSource = items;
+        ModelList.SelectedItem = items.FirstOrDefault(
+            i => string.Equals(i.Name, current, StringComparison.OrdinalIgnoreCase));
+
+        ModelLoadingPanel.Visibility = Visibility.Collapsed;
+        ModelList.Visibility = Visibility.Visible;
+    }
+
+    private async void ModelList_ItemClick(object sender, ItemClickEventArgs e)
+    {
+        ModelFlyout.Hide();
+        if (e.ClickedItem is not ModelItem item) return;
+        if (string.Equals(item.Name, _controller.ModelName, StringComparison.OrdinalIgnoreCase)) return;
+
+        await Task.Run(() => _controller.SelectModelAsync(item.Name));
+        UpdateHeader();
+    }
+
+    private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
+    {
+        var picker = new Windows.Storage.Pickers.FolderPicker();
+        picker.FileTypeFilter.Add("*");
+
+        // Unpackaged apps must initialize pickers with the window handle.
+        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_owner));
+
+        var folder = await picker.PickSingleFolderAsync();
+        if (folder == null) return;
+
+        _transcript.Append(_html.Info($"Project root changed to: {folder.Path}"));
+        _transcript.Append(_html.Dim("Rebuilding the AI session for the new project…"));
+
+        // Retargets THIS tab only — its own ProjectRootAccessor, file cache, and kernel.
+        // Other agents keep working in their own folders.
+        var session = Session;
+        await Task.Run(async () =>
+        {
+            await session.ChangeProjectRootAsync(folder.Path);
+            _transcript.Append(_html.Success("✓ Ready."));
+        });
+        UpdateHeader();
+        if (_explorerOpen) BuildExplorerRoot();   // the open tree must follow the new root
+    }
+
+    // ============================================================
+    // File explorer panel
+    // ============================================================
+
+    private bool _explorerOpen;
+    private string? _explorerRoot;   // root the tree was last built for
+
+    private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen);
+    private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false);
+
+    private void ExplorerRefresh_Click(object sender, RoutedEventArgs e)
+    {
+        BuildExplorerRoot();
+        RefreshBranchChip(force: true);   // the Changes tab re-reads too
+    }
+
+    // --- Files / Changes tabs ---
+
+    private bool _changesTabActive;
+
+    private void FilesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: false);
+    private void ChangesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: true);
+
+    private void SetExplorerTab(bool changes)
+    {
+        _changesTabActive = changes;
+        ExplorerTree.Visibility = changes ? Visibility.Collapsed : Visibility.Visible;
+        ChangesList.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+        ChangesEmptyText.Visibility = changes && _changes.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+        ChangesFooter.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
+        CommitButton.IsEnabled = _changes.Count > 0;
+        FilesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.Normal : Microsoft.UI.Text.FontWeights.SemiBold;
+        ChangesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
+        FilesTabButton.Opacity = changes ? 0.55 : 1;
+        ChangesTabButton.Opacity = changes ? 1 : 0.55;
+    }
+
+    private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e)
+    {
+        if (_explorerOpen) SizeExplorer();
+    }
+
+    private void SizeExplorer()
+    {
+        // Default ~20% of the window, clamped so the tree stays usable on small windows and
+        // doesn't waste half a 4K monitor on the other end. Once the user has dragged the
+        // splitter, their width wins (re-clamped so a shrunken window can't strand the panel).
+        var w = ChatRoot.ActualWidth;
+        if (w <= 0) return;
+        var target = _explorerUserWidth ?? Math.Clamp(w * 0.20, 220, 460);
+        ExplorerPanel.Width = Math.Clamp(target, MinExplorerWidth, MaxExplorerWidth());
+    }
+
+    private const double MinExplorerWidth = 180;
+    private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, ChatRoot.ActualWidth * 0.6);
+
+    // --- splitter drag (same pointer-capture pattern as MainWindow's terminal splitter) ---
+
+    private double? _explorerUserWidth;   // set on first drag; SizeExplorer defers to it
+    private bool _draggingExplorer;
+    private double _explorerDragStartWidth;
+    private double _explorerDragStartX;
+
+    private void ExplorerSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+    {
+        _draggingExplorer = true;
+        _explorerDragStartWidth = ExplorerPanel.ActualWidth;
+        _explorerDragStartX = e.GetCurrentPoint(ChatRoot).Position.X;   // stable frame while the grip moves
+        ((UIElement)sender).CapturePointer(e.Pointer);
+    }
+
+    private void ExplorerSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingExplorer) return;
+        // Dragging left grows the panel; right shrinks it.
+        var delta = e.GetCurrentPoint(ChatRoot).Position.X - _explorerDragStartX;
+        var next = Math.Clamp(_explorerDragStartWidth - delta, MinExplorerWidth, MaxExplorerWidth());
+        ExplorerPanel.Width = next;
+        _explorerUserWidth = next;
+    }
+
+    private void ExplorerSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingExplorer) return;
+        _draggingExplorer = false;
+        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+    }
+
+    private void ToggleExplorer(bool open)
+    {
+        if (open == _explorerOpen) return;
+        _explorerOpen = open;
+
+        // Docked, not overlaid: the panel sits in the transcript row's second column, so
+        // showing it RESIZES the transcript (text stays fully readable) and collapsing it
+        // gives the width back. No slide animation — animating a WebView2's width forces
+        // continuous relayout of the browser surface, and instant dock/undock is how
+        // solution-explorer-style panels behave anyway.
+        if (open)
+        {
+            SizeExplorer();
+            // (Re)build on open when the tab's root changed since the tree was built — the
+            // panel keeps its expansion state across close/open within the same root.
+            if (_explorerRoot != _controller.ProjectRootPath) BuildExplorerRoot();
+            ExplorerPanel.Visibility = Visibility.Visible;
+            ExplorerSplitter.Visibility = Visibility.Visible;
+        }
+        else
+        {
+            ExplorerPanel.Visibility = Visibility.Collapsed;
+            ExplorerSplitter.Visibility = Visibility.Collapsed;
+        }
+    }
+
+    private void BuildExplorerRoot()
+    {
+        _explorerRoot = _controller.ProjectRootPath;
+        ExplorerRootText.Text = Path.GetFileName(Path.TrimEndingDirectorySeparator(_explorerRoot));
+        ToolTipService.SetToolTip(ExplorerRootText, _explorerRoot);
+        ExplorerTree.RootNodes.Clear();
+        foreach (var node in LoadChildNodes(_explorerRoot)) ExplorerTree.RootNodes.Add(node);
+        StartExplorerWatcher(_explorerRoot);
+    }
+
+    // --- filesystem watcher: the tree follows external creates/deletes/renames on its own ---
+    // Efficiency comes from three choices: (1) only NAME notifications — content writes don't
+    // change tree shape; (2) events debounce into one flush, so a build touching 500 files
+    // costs one pass; (3) a flush re-syncs only REALIZED directory nodes — churn under a
+    // never-expanded folder (node_modules, bin/obj) is a hash lookup and a skip, because
+    // lazy loading will read the truth from disk whenever it's finally expanded.
+
+    private FileSystemWatcher? _fsWatcher;
+    private readonly object _fsLock = new();
+    private readonly HashSet _pendingFsDirs = new(StringComparer.OrdinalIgnoreCase);
+    private bool _fsFlushQueued;
+    private bool _fsSyncAll;   // watcher buffer overflowed — re-sync every realized dir
+
+    private void StartExplorerWatcher(string root)
+    {
+        StopExplorerWatcher();
+        try
+        {
+            _fsWatcher = new FileSystemWatcher(root)
+            {
+                IncludeSubdirectories = true,
+                // LastWrite so EDITS refresh git state (M rows, badges, dirty dot) — name
+                // events alone only cover tree shape. Content writes are routed git-only
+                // below: they can't change the tree, so they never trigger tree syncs.
+                NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
+                InternalBufferSize = 64 * 1024,   // max — fewer overflows during big builds
+            };
+            _fsWatcher.Created += (_, e) => QueueFsEvent(e.FullPath);
+            _fsWatcher.Deleted += (_, e) => QueueFsEvent(e.FullPath);
+            _fsWatcher.Renamed += (_, e) => { QueueFsEvent(e.OldFullPath); QueueFsEvent(e.FullPath); };
+            _fsWatcher.Changed += (_, e) => QueueFsEvent(e.FullPath, treeRelevant: false);
+            _fsWatcher.Error += (_, _) => { lock (_fsLock) { _fsSyncAll = true; } QueueFsEvent(root); };
+            _fsWatcher.EnableRaisingEvents = true;
+        }
+        catch
+        {
+            _fsWatcher = null;   // best-effort — the refresh button still exists
+        }
+    }
+
+    private void StopExplorerWatcher()
+    {
+        try { _fsWatcher?.Dispose(); } catch { }
+        _fsWatcher = null;
+    }
+
+    /// Threadpool-side: coalesce this event's parent directory into the pending set
+    /// and arm one debounced flush. .git churn and content-only writes skip the tree but
+    /// still refresh git state — that's how external edits, branch switches, and commits
+    /// show up without a manual refresh.
+    private void QueueFsEvent(string fullPath, bool treeRelevant = true)
+    {
+        bool arm;
+        lock (_fsLock)
+        {
+            var rel = ToRelOrNull(fullPath)?.Replace('\\', '/');
+            if (rel == null) return;
+            var isGit = rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase);
+
+            // Our OWN git calls write .git/index (+ transient *.lock files) — reacting to
+            // those would refresh forever: refresh → git status → index event → refresh…
+            // Ignore them; real external actions (checkout, commit) also touch HEAD/refs,
+            // which still get through and trigger the refresh we want.
+            if (isGit && (rel.EndsWith("/index", StringComparison.OrdinalIgnoreCase)
+                       || rel.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)))
+                return;
+
+            if (!isGit && treeRelevant)
+                _pendingFsDirs.Add(Path.GetDirectoryName(fullPath) ?? "");
+
+            // Workspace notes: remember WHICH files were touched while the agent was idle.
+            // Status-snapshot diffing alone misses content edits to files that were ALREADY
+            // dirty/untracked (their status entry doesn't change) — this set fills that gap.
+            // Idle-gated so the agent's own writes never count as external.
+            if (!isGit && !_controller.IsProcessing)
+                _wsTracker.RecordTouch(rel);
+
+            arm = !_fsFlushQueued;
+            _fsFlushQueued = true;
+        }
+        if (arm) _ = FlushFsEventsAsync();
+
+        string? ToRelOrNull(string p)
+        {
+            var root = _explorerRoot;
+            if (root == null) return null;
+            var prefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
+            return p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? p[prefix.Length..] : null;
+        }
+    }
+
+    private async Task FlushFsEventsAsync()
+    {
+        await Task.Delay(800);   // coalesce the burst
+        List dirs;
+        bool syncAll;
+        lock (_fsLock)
+        {
+            syncAll = _fsSyncAll;
+            _fsSyncAll = false;
+            dirs = _pendingFsDirs.ToList();
+            _pendingFsDirs.Clear();
+            _fsFlushQueued = false;
+        }
+        OnUi(() =>
+        {
+            if (_shutDown) return;
+            if (syncAll) SyncAllRealizedDirs();
+            else foreach (var dir in dirs) SyncRealizedDir(dir);
+            RefreshBranchChip(force: true);   // badges, Changes tab, and status strip follow
+        });
+    }
+
+    /// Re-syncs one directory's children IF that directory is realized in the tree;
+    /// unexpanded directories are skipped (lazy load reads fresh from disk anyway).
+    private void SyncRealizedDir(string dir)
+    {
+        var list = FindRealizedChildList(dir);
+        if (list != null) SyncDirectoryNode(list, dir);
+    }
+
+    private void SyncAllRealizedDirs()
+    {
+        var root = _explorerRoot;
+        if (root == null) return;
+        SyncDirectoryNode(ExplorerTree.RootNodes, root);
+        Walk(ExplorerTree.RootNodes);
+
+        void Walk(IList nodes)
+        {
+            foreach (var n in nodes)
+            {
+                if (n is { HasUnrealizedChildren: false, Content: ExplorerItem { IsDirectory: true } item })
+                {
+                    SyncDirectoryNode(n.Children, item.FullPath);
+                    Walk(n.Children);
+                }
+            }
+        }
+    }
+
+    private IList? FindRealizedChildList(string dir)
+    {
+        var root = _explorerRoot;
+        if (root == null) return null;
+        if (PathsEqual(dir, root)) return ExplorerTree.RootNodes;
+        return Find(ExplorerTree.RootNodes);
+
+        IList? Find(IList nodes)
+        {
+            foreach (var n in nodes)
+            {
+                if (n.Content is ExplorerItem { IsDirectory: true } item && PathsEqual(item.FullPath, dir))
+                    return n.HasUnrealizedChildren ? null : n.Children;
+                if (n.Children.Count > 0)
+                {
+                    var found = Find(n.Children);
+                    if (found != null) return found;
+                }
+            }
+            return null;
+        }
+
+        static bool PathsEqual(string a, string b) => string.Equals(
+            Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b),
+            StringComparison.OrdinalIgnoreCase);
+    }
+
+    /// Minimal diff of a realized directory node against disk: remove rows whose
+    /// path vanished, insert new rows at their sorted position. Never rebuilds surviving
+    /// nodes, so expansion state below them is preserved.
+    private void SyncDirectoryNode(IList children, string dir)
+    {
+        var root = _explorerRoot ?? _controller.ProjectRootPath;
+        string[] dirs, files;
+        try
+        {
+            dirs = Directory.GetDirectories(dir);
+            files = Directory.GetFiles(dir);
+        }
+        catch (Exception) { return; }
+        Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
+        Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+
+        var desired = new List<(string Path, bool IsDir)>(dirs.Length + files.Length);
+        foreach (var d in dirs) desired.Add((d, true));
+        foreach (var f in files) desired.Add((f, false));
+        var desiredSet = new HashSet(desired.Select(x => x.Path), StringComparer.OrdinalIgnoreCase);
+
+        for (var i = children.Count - 1; i >= 0; i--)
+            if (children[i].Content is ExplorerItem it && !desiredSet.Contains(it.FullPath))
+                children.RemoveAt(i);
+
+        var existing = new HashSet(
+            children.Select(n => (n.Content as ExplorerItem)?.FullPath ?? ""),
+            StringComparer.OrdinalIgnoreCase);
+
+        for (var idx = 0; idx < desired.Count; idx++)
+        {
+            var (path, isDir) = desired[idx];
+            if (existing.Contains(path)) continue;
+            var item = isDir ? ExplorerItem.ForFolder(path, root) : ExplorerItem.ForFile(path, root);
+            item.Dirty = IsItemDirty(item);
+            var node = new TreeViewNode { Content = item };
+            if (isDir) node.HasUnrealizedChildren = true;
+            children.Insert(Math.Min(idx, children.Count), node);
+        }
+    }
+
+    /// One directory level, folders first then files, both alphabetical. Unreadable
+    /// or vanished directories render as empty rather than throwing.
+    private List LoadChildNodes(string dir)
+    {
+        var root = _explorerRoot ?? _controller.ProjectRootPath;
+        var nodes = new List();
+        string[] dirs, files;
+        try
+        {
+            dirs = Directory.GetDirectories(dir);
+            files = Directory.GetFiles(dir);
+        }
+        catch (Exception) { return nodes; }
+        Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
+        Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+        foreach (var d in dirs)
+        {
+            var item = ExplorerItem.ForFolder(d, root);
+            item.Dirty = IsItemDirty(item);
+            nodes.Add(new TreeViewNode { Content = item, HasUnrealizedChildren = true });
+        }
+        foreach (var f in files)
+        {
+            var item = ExplorerItem.ForFile(f, root);
+            item.Dirty = IsItemDirty(item);
+            nodes.Add(new TreeViewNode { Content = item });
+        }
+        return nodes;
+    }
+
+    /// The row's @ button — shared by the file tree (TreeViewNode rows) and the
+    /// Changes list (GitChangeItem rows): tags the file/folder in the prompt, identical
+    /// result to dragging the row onto the input box.
+    private void ExplorerTag_Click(object sender, RoutedEventArgs e)
+    {
+        var ctx = (sender as FrameworkElement)?.DataContext;
+        var path = ctx switch
+        {
+            TreeViewNode { Content: ExplorerItem item } => item.FullPath,
+            GitChangeItem change => change.FullPath,
+            _ => null,
+        };
+        if (path != null) InsertFileTokens(new[] { path });
+    }
+
+    private void ChangesList_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
+    {
+        var paths = e.Items.OfType().Select(c => c.FullPath).ToList();
+        if (paths.Count == 0) { e.Cancel = true; return; }
+        e.Data.SetText(string.Join("\n", paths));
+        e.Data.RequestedOperation = DataPackageOperation.Copy;
+    }
+
+    /// The row's ± button: show this file's diff as a transcript DiffCard. An
+    /// explicit button (not row click) so selecting or starting a drag never spawns a card,
+    /// and no click-vs-double-click disambiguation delay is needed.
+    private async void ChangesDiff_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.DataContext is not GitChangeItem item || _shutDown) return;
+
+        var root = _controller.ProjectRootPath;
+        var diff = await Task.Run(() => GitQuickStatus.TryGetDiff(root, item.RelPath, untracked: item.Kind == "U"));
+        if (_shutDown) return;
+
+        if (diff == null)
+            _transcript.Append(_html.Warn($"Couldn't get a diff for {item.RelPath}"));
+        else if (diff.Lines.Count == 0)
+            _transcript.Append(_html.Dim($"{item.RelPath}: {diff.Summary}"));
+        else
+            _transcript.Append(_html.DiffCard(item.RelPath, diff.Lines, diff.Summary, interactive: true));
+    }
+
+    /// Pre-fills the prompt with a commit request — never sends, never commits.
+    /// Caret-aware insert, so tagging files first then clicking Commit… composes naturally
+    /// ("@a.cs @b.cs Commit the current changes…"). The user can edit, then sends; the
+    /// bottom-bar approval gates the actual git command.
+    private void Commit_Click(object sender, RoutedEventArgs e) =>
+        InsertAtCaret("Commit the current changes with an appropriate message");
+
+    private void ChangeUndo_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.DataContext is GitChangeItem item)
+            UndoFileFromCard(item.RelPath);
+    }
+
+    /// Fire-and-forget bridge for non-async call sites (web message handler, row
+    /// button). async void is safe here: ConfirmAndUndoAsync catches nothing fatal — git
+    /// failure is reported to the transcript, not thrown.
+    private async void UndoFileFromCard(string relPath) => await ConfirmAndUndoAsync(relPath);
+
+    /// The one destructive action in the app, so it always confirms first —
+    /// whether it came from a Changes row or a diff card's Undo chip.
+    private async Task ConfirmAndUndoAsync(string relPath)
+    {
+        var dialog = new ContentDialog
+        {
+            Title = "Discard changes?",
+            Content = $"{relPath} will be restored to its state at the last commit. This can't be undone.",
+            PrimaryButtonText = "Discard changes",
+            CloseButtonText = "Cancel",
+            DefaultButton = ContentDialogButton.Close,
+            XamlRoot = XamlRoot,
+        };
+        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+        var root = _controller.ProjectRootPath;
+        var ok = await Task.Run(() => GitQuickStatus.TryUndoChanges(root, relPath));
+        if (_shutDown) return;
+        _transcript.Append(ok
+            ? _html.Success($"Restored {relPath} to its state at the last commit.")
+            : _html.Warn($"Couldn't restore {relPath} — is it still tracked by git?"));
+        if (ok)
+        {
+            // Tell the model explicitly — discarding its work is feedback, not just a file
+            // event — and re-baseline so the generic delta doesn't report it a second time.
+            _controller.NoteWorkspaceEvent(
+                $"The user DISCARDED all uncommitted changes to {relPath} (restored to the last commit). " +
+                "If you changed that file earlier, those changes are gone by the user's choice — don't re-apply them unless asked.");
+            _wsTracker.MarkCapturePending();
+        }
+        RefreshBranchChip(force: true);
+    }
+
+    private void ChangesList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+    {
+        if ((e.OriginalSource as FrameworkElement)?.DataContext is not GitChangeItem item) return;
+        if (!File.Exists(item.FullPath)) return;   // deleted entries have nothing to open
+        if (ShellOpen.Try(item.FullPath) is { } ex)
+            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+    }
+
+    private void ExplorerTag_PointerEntered(object sender, PointerRoutedEventArgs e)
+        => ((UIElement)sender).Opacity = 1;
+
+    private void ExplorerTag_PointerExited(object sender, PointerRoutedEventArgs e)
+        => ((UIElement)sender).Opacity = 0.45;
+
+    private void ExplorerTree_Expanding(TreeView sender, TreeViewExpandingEventArgs args)
+    {
+        if (!args.Node.HasUnrealizedChildren) return;
+        args.Node.HasUnrealizedChildren = false;
+        if (args.Node.Content is not ExplorerItem item || !item.IsDirectory) return;
+        foreach (var child in LoadChildNodes(item.FullPath)) args.Node.Children.Add(child);
+    }
+
+    private void ExplorerTree_ItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
+    {
+        // Single click: folders toggle, files only select. Opening is double-click territory
+        // (ExplorerTree_DoubleTapped) — a stray single click must never launch an app.
+        if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: true } } node)
+            node.IsExpanded = !node.IsExpanded;
+    }
+
+    private void ExplorerTree_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+    {
+        // The template's elements inherit the row's TreeViewNode as DataContext.
+        if ((e.OriginalSource as FrameworkElement)?.DataContext is not TreeViewNode node ||
+            node.Content is not ExplorerItem { IsDirectory: false } item)
+            return;
+        if (ShellOpen.Try(item.FullPath) is { } ex)
+            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+    }
+
+    // ============================================================
+    // Drag & drop @-references
+    // ============================================================
+
+    /// Dragging explorer rows carries their full paths as text — the input box's
+    /// Drop handler recognizes existing paths and converts them to @tokens.
+    private void ExplorerTree_DragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
+    {
+        var paths = args.Items.OfType()
+            .Select(n => n.Content).OfType()
+            .Select(i => i.FullPath).ToList();
+        if (paths.Count == 0) { args.Cancel = true; return; }
+        args.Data.SetText(string.Join("\n", paths));
+        args.Data.RequestedOperation = DataPackageOperation.Copy;
+    }
+
+    private void InputBox_DragOver(object sender, DragEventArgs e)
+    {
+        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+            e.DataView.Contains(StandardDataFormats.Text))
+        {
+            e.AcceptedOperation = DataPackageOperation.Copy;
+            e.Handled = true;
+        }
+    }
+
+    // --- drop-to-tag overlay choreography ---
+    // Show when a drag enters the tab: over XAML chrome that's ChatRoot's DragEnter; over the
+    // WebView it's the transcript script's 'drag-enter' message (Chromium owns drags there).
+    // Hide when the drag leaves the overlay/tab or when any drop completes. Moving between
+    // those regions can flicker the overlay off/on for a frame — harmless.
+
+    private void ShowDropOverlay() => DropOverlay.Visibility = Visibility.Visible;
+    private void HideDropOverlay() => DropOverlay.Visibility = Visibility.Collapsed;
+
+    private void ChatRoot_DragEnter(object sender, DragEventArgs e)
+    {
+        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+            e.DataView.Contains(StandardDataFormats.Text))
+            ShowDropOverlay();
+    }
+
+    private void ChatRoot_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
+    private void DropOverlay_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
+
+    private void DropOverlay_DragOver(object sender, DragEventArgs e)
+    {
+        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
+            e.DataView.Contains(StandardDataFormats.Text))
+        {
+            e.AcceptedOperation = DataPackageOperation.Copy;
+            e.Handled = true;
+        }
+    }
+
+    private async void DropOverlay_Drop(object sender, DragEventArgs e)
+    {
+        HideDropOverlay();
+        await HandleDropAsync(e);
+    }
+
+    private async void InputBox_Drop(object sender, DragEventArgs e)
+    {
+        HideDropOverlay();
+        await HandleDropAsync(e);
+    }
+
+    /// Shared drop handling for the input box and the drop-to-tag overlay: paths
+    /// become @tokens, ordinary text inserts as text.
+    private async Task HandleDropAsync(DragEventArgs e)
+    {
+        e.Handled = true;
+        var deferral = e.GetDeferral();
+        try
+        {
+            if (e.DataView.Contains(StandardDataFormats.StorageItems))
+            {
+                // Shell drop (Windows Explorer): real files/folders with paths.
+                var items = await e.DataView.GetStorageItemsAsync();
+                InsertFileTokens(items.Select(i => i.Path).Where(p => !string.IsNullOrEmpty(p)));
+            }
+            else if (e.DataView.Contains(StandardDataFormats.Text))
+            {
+                // Text drop: explorer-tree rows arrive as newline-joined full paths. If every
+                // line is an existing path, tokenize; otherwise it's ordinary dragged text.
+                var text = await e.DataView.GetTextAsync();
+                var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+                if (lines.Length > 0 && lines.All(l => File.Exists(l) || Directory.Exists(l)))
+                    InsertFileTokens(lines);
+                else
+                    InsertAtCaret(text);
+            }
+        }
+        catch (Exception ex)
+        {
+            _transcript.Append(_html.Warn($"Couldn't read the dropped item: {ex.Message}"));
+        }
+        finally
+        {
+            deferral.Complete();
+        }
+    }
+
+    /// Converts full paths into the same @tokens the autocomplete inserts: project-root
+    /// relative, forward slashes, trailing '/' for folders. Items outside this tab's project
+    /// root can't be resolved by the @ pipeline, so they're skipped with a warning.
+    private void InsertFileTokens(IEnumerable fullPaths)
+    {
+        var root = _controller.ProjectRootPath;
+        var rootPrefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
+        var tokens = new List();
+        var outside = new List();
+
+        foreach (var raw in fullPaths)
+        {
+            string full;
+            try { full = Path.GetFullPath(raw); }
+            catch { continue; }
+            if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
+            {
+                outside.Add(full);
+                continue;
+            }
+            var rel = Path.GetRelativePath(root, full).Replace('\\', '/');
+            tokens.Add("@" + rel + (Directory.Exists(full) ? "/" : ""));
+        }
+
+        if (tokens.Count > 0)
+            InsertAtCaret(string.Join(" ", tokens) + " ");
+        if (outside.Count > 0)
+            _transcript.Append(_html.Warn(
+                $"Skipped {outside.Count} dropped item{(outside.Count == 1 ? "" : "s")} outside this tab's project folder — @ references only work under {root}"));
+    }
+
+    /// Inserts at the caret with token-safe spacing: a separating space is added when
+    /// the caret touches non-whitespace, so a dropped @token never glues onto existing text.
+    private void InsertAtCaret(string insert)
+    {
+        var text = InputBox.Text;
+        var caret = Math.Clamp(InputBox.SelectionStart, 0, text.Length);
+        if (caret > 0 && !char.IsWhiteSpace(text[caret - 1])) insert = " " + insert;
+        InputBox.Text = text[..caret] + insert + text[caret..];
+        InputBox.SelectionStart = caret + insert.Length;
+        InputBox.Focus(FocusState.Programmatic);
+    }
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs
new file mode 100644
index 0000000..c5d70d2
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Header.cs
@@ -0,0 +1,88 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // Header / busy / plan progress
+    // ============================================================
+
+    public void UpdateHeader()
+    {
+        ModelText.Text = _controller.ModelName;
+        ProjectRootText.Text = _controller.ProjectRootPath;
+        ConnectionDot.Fill = new SolidColorBrush(
+            _controller.ModelError ? Colors.Orange
+            : _controller.IsConnected ? Colors.LimeGreen
+            : Colors.Gray);
+
+        var tracker = Session.Tokens;
+        TokenText.Text = tracker.TotalSessionTokens > 0
+            ? $"{MandoCode.Services.TokenTrackingService.FormatTokenCount(tracker.TotalSessionTokens)} tokens"
+            : "";
+
+        var processing = _controller.IsProcessing;
+        SendIcon.Glyph = processing ? "" : "";   // stop vs send
+        SendLabel.Text = processing ? "Stop" : "Send";
+        ModelButton.IsEnabled = !processing;   // no model switch mid-turn
+
+        RefreshBranchChip();
+
+        HeaderChanged?.Invoke(this);
+    }
+
+    private void UpdateBusy(bool busy, string? activity)
+    {
+        BusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+        BusyRing.IsActive = busy;
+        if (busy) BusyText.Text = string.IsNullOrWhiteSpace(activity) ? "Working..." : activity;
+        else
+        {
+            // Turn just ended: refresh git state and snapshot it as the baseline for
+            // detecting OUTSIDE-the-conversation changes before the next send.
+            _wsTracker.MarkCapturePending();
+            RefreshBranchChip(force: true);
+
+            // Persist the model's full memory as of this turn (tier-3 full fidelity).
+            // Off-thread: serialization of a long history shouldn't touch UI latency.
+            var key = Session.PersistKey;
+            var ai = Session.Ai;
+            _ = Task.Run(() => SessionHistoryStore.Save(key, ai.ExportHistoryJson()));
+        }
+    }
+
+    // ============================================================
+    // Workspace-change notes for the model
+    // ============================================================
+    // The model only knows what happened inside the conversation. Anything else — the undo
+    // button discarding its edits, files changed in another editor, external branch switches
+    // — is invisible to it and leaves its picture of the working tree stale. The decision
+    // logic lives in WorkspaceDeltaTracker (pure, unit-testable); this class only feeds it:
+    // turn end → MarkCapturePending, git refresh → CaptureBaselineIfPending, watcher touch →
+    // RecordTouch, send → EmitDelta. Notes queue on the controller (same pattern as reactions).
+
+    private GitBranchInfo? _lastGitInfo;
+    private readonly WorkspaceDeltaTracker _wsTracker = new();
+
+    /// Called at send time: queues notes for whatever changed outside the
+    /// conversation since the last turn ended, then re-baselines.
+    private void EmitWorkspaceDelta()
+    {
+        foreach (var note in _wsTracker.EmitDelta(_lastGitInfo))
+            _controller.NoteWorkspaceEvent(note);
+    }
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs
new file mode 100644
index 0000000..1da5943
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Input.cs
@@ -0,0 +1,304 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // Input handling
+    // ============================================================
+
+    private void SendButton_Click(object sender, RoutedEventArgs e)
+    {
+        if (_controller.IsProcessing)
+        {
+            _controller.CancelActiveRequest();
+            return;
+        }
+        SubmitCurrentInput();
+    }
+
+    private void SubmitCurrentInput()
+    {
+        var text = InputBox.Text;
+        if (string.IsNullOrWhiteSpace(text) || _controller.IsProcessing) return;
+
+        EmitWorkspaceDelta();   // queue outside-the-conversation changes before this send
+        InputBox.Text = "";
+        HideSuggestions();
+        UpdateHeader();
+
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                await _controller.SubmitAsync(text);
+            }
+            catch (Exception ex)
+            {
+                _transcript.Append(_html.Error($"Unexpected error: {ex.Message}"));
+            }
+        });
+    }
+
+    // PreviewKeyDown, NOT KeyDown: the TextBox's own class handler runs before instance
+    // KeyDown handlers, so with AcceptsReturn=true an Enter had already inserted a newline
+    // — which made TextChanged hide the suggestions popup, and the handler then fell
+    // through to submit. Preview (tunneling) fires first, so Handled=true genuinely
+    // suppresses the newline and Enter-to-accept behaves exactly like a mouse click.
+    private void InputBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
+    {
+        if (e.Key == VirtualKey.Enter)
+        {
+            var shift = Microsoft.UI.Input.InputKeyboardSource
+                .GetKeyStateForCurrentThread(VirtualKey.Shift)
+                .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+            if (!shift)
+            {
+                e.Handled = true;
+
+                // If suggestions are open, Enter accepts (falling back to the first row —
+                // never submit the half-typed token as a message).
+                if (SuggestionsPanel.Visibility == Visibility.Visible)
+                {
+                    var pick = SuggestionsList.SelectedItem as CommandSuggestion ?? _suggestions.FirstOrDefault();
+                    if (pick != null)
+                    {
+                        AcceptSuggestion(pick);
+                        return;
+                    }
+                }
+                SubmitCurrentInput();
+            }
+        }
+        else if (e.Key == VirtualKey.Tab && SuggestionsPanel.Visibility == Visibility.Visible)
+        {
+            var pick = (SuggestionsList.SelectedItem ?? _suggestions.FirstOrDefault()) as CommandSuggestion;
+            if (pick != null)
+            {
+                e.Handled = true;
+                AcceptSuggestion(pick);
+            }
+        }
+        else if (e.Key == VirtualKey.Down && SuggestionsPanel.Visibility == Visibility.Visible)
+        {
+            e.Handled = true;
+            SuggestionsList.SelectedIndex = Math.Min(SuggestionsList.SelectedIndex + 1, _suggestions.Count - 1);
+            SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+        }
+        else if (e.Key == VirtualKey.Up && SuggestionsPanel.Visibility == Visibility.Visible)
+        {
+            e.Handled = true;
+            SuggestionsList.SelectedIndex = Math.Max(SuggestionsList.SelectedIndex - 1, 0);
+            SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+        }
+        else if (e.Key == VirtualKey.Escape)
+        {
+            if (SuggestionsPanel.Visibility == Visibility.Visible) HideSuggestions();
+            else _controller.CancelActiveRequest();
+        }
+    }
+
+    private void InputBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSuggestions();
+
+    private void UpdateSuggestions()
+    {
+        var text = InputBox.Text;
+        var caret = InputBox.SelectionStart;
+
+        // Slash commands: input starts with '/' and is still a single token.
+        if (text.StartsWith('/') && !text.Contains(' '))
+        {
+            var matches = _controller.GetCommandSuggestions(text);
+            if (ShowSuggestions(SuggestMode.Command, 0, caret,
+                    matches.Select(m => new CommandSuggestion { Command = m.Command, Description = m.Description })))
+                return;
+        }
+
+        // @file references: find the token containing the caret; if it starts with '@',
+        // filter project files/directories through the same provider the CLI uses
+        // (directories come back with a trailing '/' — selecting one drills into it).
+        var tokenStart = caret;
+        while (tokenStart > 0 && !char.IsWhiteSpace(text[tokenStart - 1]))
+            tokenStart--;
+
+        if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == '@')
+        {
+            var fragment = text[(tokenStart + 1)..caret];
+            List matches;
+            try { matches = _fileProvider.FilterFiles(fragment); }
+            catch { matches = new List(); }
+
+            if (ShowSuggestions(SuggestMode.File, tokenStart, caret,
+                    matches.Select(m => new CommandSuggestion
+                    {
+                        Command = m,
+                        Description = m.EndsWith('/') ? "folder — select to drill in" : "file"
+                    })))
+                return;
+        }
+
+        // :emoji: shortcodes (Slack-style). Two behaviors on the token containing the caret:
+        //  - ":name:" fully typed with an exact match → replace it with the emoji right here.
+        //  - ":fra" partially typed (2+ chars, no closing ':') → suggest matching shortcodes.
+        // The 2-char minimum keeps ordinary colons (":)", "note:") from popping the list.
+        if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == ':')
+        {
+            var body = text[(tokenStart + 1)..caret];
+            if (body.Length > 1 && body.EndsWith(':'))
+            {
+                var name = body[..^1].ToLowerInvariant();
+                var exact = EmojiShortcodes.FirstOrDefault(s => s.Name == name).Emoji;
+                if (exact != null)
+                {
+                    InputBox.Text = text[..tokenStart] + exact + text[caret..];
+                    InputBox.SelectionStart = tokenStart + exact.Length;
+                    HideSuggestions();
+                    return;
+                }
+            }
+            else if (body.Length >= 2 && !body.Contains(':'))
+            {
+                var frag = body.ToLowerInvariant();
+                var matches = EmojiShortcodes.Where(s => s.Name.StartsWith(frag))
+                    .Concat(EmojiShortcodes.Where(s => !s.Name.StartsWith(frag) && s.Name.Contains(frag)));
+
+                if (ShowSuggestions(SuggestMode.Emoji, tokenStart, caret,
+                        matches.Select(m => new CommandSuggestion
+                        {
+                            Command = ":" + m.Name + ":",
+                            Description = m.Emoji,
+                            InsertText = m.Emoji,
+                        })))
+                    return;
+            }
+        }
+
+        HideSuggestions();
+    }
+
+    private bool ShowSuggestions(SuggestMode mode, int tokenStart, int tokenEnd, IEnumerable items)
+    {
+        _suggestions.Clear();
+        foreach (var item in items) _suggestions.Add(item);
+        if (_suggestions.Count == 0) return false;
+
+        _suggestMode = mode;
+        _tokenStart = tokenStart;
+        _tokenEnd = tokenEnd;
+        SuggestionsPanel.Visibility = Visibility.Visible;
+        SuggestionsList.SelectedIndex = 0;
+        SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
+        return true;
+    }
+
+    private void SuggestionsList_ItemClick(object sender, ItemClickEventArgs e)
+    {
+        if (e.ClickedItem is CommandSuggestion s) AcceptSuggestion(s);
+    }
+
+    private void AcceptSuggestion(CommandSuggestion s)
+    {
+        if (_suggestMode == SuggestMode.File)
+        {
+            var text = InputBox.Text;
+            var start = Math.Min(_tokenStart, text.Length);
+            var end = Math.Min(_tokenEnd, text.Length);
+
+            // Replace the @token with the picked path. Directories keep the caret hot
+            // (no trailing space) so the reopened popup shows their contents; files
+            // close the token with a space.
+            var isFolder = s.Command.EndsWith('/');
+            var replacement = "@" + s.Command + (isFolder ? "" : " ");
+            InputBox.Text = text[..start] + replacement + text[end..];
+            InputBox.SelectionStart = start + replacement.Length;
+
+            // Setting .Text resets the caret to 0 BEFORE the line above restores it, and
+            // TextChanged runs in that window — it sees no token at caret 0 and hides the
+            // popup. Recompute now that the caret is where the user expects it:
+            // folder → drilled listing reopens; file → token ended with a space, stays hidden.
+            UpdateSuggestions();
+        }
+        else if (_suggestMode == SuggestMode.Emoji)
+        {
+            var text = InputBox.Text;
+            var start = Math.Min(_tokenStart, text.Length);
+            var end = Math.Min(_tokenEnd, text.Length);
+            var emoji = s.InsertText ?? s.Command;
+            InputBox.Text = text[..start] + emoji + text[end..];
+            InputBox.SelectionStart = start + emoji.Length;
+            HideSuggestions();
+        }
+        else
+        {
+            InputBox.Text = s.Command + " ";
+            InputBox.SelectionStart = InputBox.Text.Length;
+            HideSuggestions();
+        }
+        InputBox.Focus(FocusState.Programmatic);
+    }
+
+    /// Curated quick-pick set for the emoji flyout; Win + . remains the full picker.
+    private static readonly string[] QuickEmojis =
+    {
+        "😀", "😄", "😂", "🤣", "😊", "😉", "😍", "🥰", "😎", "🤓", "🤔", "🙃",
+        "😅", "😬", "😭", "🥳", "🤯", "😴", "🙄", "😤", "😱", "🫠", "🤗", "🫡",
+        "👍", "👎", "👌", "🙏", "👏", "💪", "🤝", "✌️", "🤞", "👀", "🧠", "💯",
+        "🔥", "✨", "🚀", "🎉", "🎯", "💡", "⚡", "⭐", "❤️", "💔", "✅", "❌",
+        "⚠️", "❓", "❗", "💬", "🐛", "🔧", "🔒", "🔑", "📝", "📌", "📁", "🖥️",
+        "☕", "🍕", "🎮", "🤖",
+    };
+
+    /// Slack-style shortcode → emoji. Aliases are separate rows pointing at the same
+    /// emoji. Names must be lowercase; lookup lowercases the typed fragment.
+    private static readonly (string Name, string Emoji)[] EmojiShortcodes =
+    {
+        ("grinning", "😀"), ("smile", "😄"), ("joy", "😂"), ("rofl", "🤣"),
+        ("blush", "😊"), ("wink", "😉"), ("heart_eyes", "😍"), ("smiling_hearts", "🥰"),
+        ("sunglasses", "😎"), ("coolglasses", "😎"), ("nerd", "🤓"), ("thinking", "🤔"),
+        ("upside_down", "🙃"), ("sweat_smile", "😅"), ("grimacing", "😬"), ("sob", "😭"),
+        ("partying", "🥳"), ("mind_blown", "🤯"), ("sleeping", "😴"), ("eye_roll", "🙄"),
+        ("triumph", "😤"), ("scream", "😱"), ("melting", "🫠"), ("hugs", "🤗"),
+        ("salute", "🫡"), ("thumbsup", "👍"), ("+1", "👍"), ("thumbsdown", "👎"),
+        ("-1", "👎"), ("ok_hand", "👌"), ("pray", "🙏"), ("clap", "👏"),
+        ("muscle", "💪"), ("handshake", "🤝"), ("victory", "✌️"), ("crossed_fingers", "🤞"),
+        ("eyes", "👀"), ("brain", "🧠"), ("100", "💯"), ("fire", "🔥"),
+        ("sparkles", "✨"), ("rocket", "🚀"), ("tada", "🎉"), ("party_popper", "🎉"),
+        ("dart", "🎯"), ("bulb", "💡"), ("idea", "💡"), ("zap", "⚡"),
+        ("star", "⭐"), ("heart", "❤️"), ("broken_heart", "💔"), ("check", "✅"),
+        ("white_check_mark", "✅"), ("x", "❌"), ("cross", "❌"), ("warning", "⚠️"),
+        ("question", "❓"), ("exclamation", "❗"), ("speech_balloon", "💬"), ("bug", "🐛"),
+        ("wrench", "🔧"), ("lock", "🔒"), ("key", "🔑"), ("memo", "📝"),
+        ("note", "📝"), ("pushpin", "📌"), ("pin", "📌"), ("folder", "📁"),
+        ("desktop", "🖥️"), ("coffee", "☕"), ("pizza", "🍕"), ("video_game", "🎮"),
+        ("robot", "🤖"),
+    };
+
+    private void EmojiGrid_ItemClick(object sender, ItemClickEventArgs e)
+    {
+        if (e.ClickedItem is not string emoji || !InputBox.IsEnabled) return;
+        var caret = Math.Min(InputBox.SelectionStart, InputBox.Text.Length);
+        InputBox.Text = InputBox.Text.Insert(caret, emoji);
+        InputBox.SelectionStart = caret + emoji.Length;
+        InputBox.Focus(FocusState.Programmatic);
+    }
+
+    private void HideSuggestions()
+    {
+        _suggestMode = SuggestMode.None;
+        SuggestionsPanel.Visibility = Visibility.Collapsed;
+        _suggestions.Clear();
+    }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
new file mode 100644
index 0000000..cbc6fc5
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
@@ -0,0 +1,251 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // 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 top offer to match the controller's pending buffer. Stage 1 is the
+    /// thin notification bar; the full picker is built only when the user clicks Create on it.
+    private void RefreshSnapshotOffer()
+    {
+        var offer = _controller.PendingOffer;
+        if (offer == null)
+        {
+            SnapshotOfferRoot.Visibility = Visibility.Collapsed;
+            return;
+        }
+
+        // A manual "Take snapshot" is an explicit decision — skip the notification bar and open the
+        // name+model picker straight away. Model switches keep stage 1, since there the user may
+        // just want to keep working (or "keep memory") rather than snapshot at all.
+        if (offer.IsManual)
+        {
+            ShowSnapshotPickerCard(offer);
+            return;
+        }
+
+        // Stage 1: notification bar. Non-blocking — the user can ignore it and keep prompting.
+        // "Keep memory" only appears when a switch actually cleared a conversation.
+        SnapshotKeepMemoryButton.Visibility = _controller.CanCarryMemory
+            ? Visibility.Visible : Visibility.Collapsed;
+        SnapshotNotifyText.Text = _controller.CanCarryMemory
+            ? $"Keep the {offer.OriginModel} conversation going, or save it as a snapshot?"
+            : $"Snapshot available — save the {offer.OriginModel} conversation.";
+        SnapshotNotifyBar.Visibility = Visibility.Visible;
+        SnapshotOfferCard.Visibility = Visibility.Collapsed;
+        SnapshotOfferRoot.Visibility = Visibility.Visible;
+        SlideSnapshotOfferIn();
+    }
+
+    /// Stage 2: expand into the full name + model picker. Reached either from the
+    /// notification bar's Create (model switches) or directly for a manual "Take snapshot". Hangs at
+    /// the top until the user creates or dismisses.
+    private void ShowSnapshotPickerCard(ChatController.PendingSnapshot offer)
+    {
+        SnapshotOfferSubtitle.Text =
+            $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — "
+            + "name it (or leave blank and the summarizer will), pick a model, and create.";
+        SnapshotCreateButton.Content = "Create";
+        SnapshotNameBox.Text = "";   // a fresh offer starts unnamed
+        // Reset any leftover busy state from a prior, interrupted attempt.
+        SnapshotBusyPanel.Visibility = Visibility.Collapsed;
+        SnapshotBusyRing.IsActive = false;
+        SnapshotOfferContent.Opacity = 1;
+        SnapshotOfferContent.IsHitTestVisible = true;
+        SnapshotNotifyBar.Visibility = Visibility.Collapsed;
+        SnapshotOfferCard.Visibility = Visibility.Visible;
+        SnapshotOfferRoot.Visibility = Visibility.Visible;
+        SlideSnapshotOfferIn();
+        _ = LoadSnapshotModelsAsync(offer.OriginModel);
+    }
+
+    private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e)
+    {
+        var offer = _controller.PendingOffer;
+        if (offer != null) ShowSnapshotPickerCard(offer);
+    }
+
+    /// Drops the offer down from the top of the transcript with a short fade.
+    private void SlideSnapshotOfferIn()
+    {
+        var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+        var slide = new DoubleAnimation
+        {
+            From = -18, To = 0,
+            Duration = new Duration(TimeSpan.FromMilliseconds(220)),
+            EasingFunction = ease,
+        };
+        Storyboard.SetTarget(slide, SnapshotOfferTransform);
+        Storyboard.SetTargetProperty(slide, "Y");
+        var fade = new DoubleAnimation
+        {
+            From = 0, To = 1,
+            Duration = new Duration(TimeSpan.FromMilliseconds(180)),
+            EasingFunction = ease,
+        };
+        Storyboard.SetTarget(fade, SnapshotOfferRoot);
+        Storyboard.SetTargetProperty(fade, "Opacity");
+        var sb = new Storyboard();
+        sb.Children.Add(slide);
+        sb.Children.Add(fade);
+        sb.Begin();
+    }
+
+    /// 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;
+        }
+
+        // Summarizing can take a while (especially a cloud model), so show a clear busy state:
+        // fade the controls out and spin, with the snapshot's name in the message when it has one.
+        var name = SnapshotNameBox.Text?.Trim() ?? "";
+        SnapshotBusyText.Text = string.IsNullOrEmpty(name)
+            ? "Creating snapshot…"
+            : $"Creating “{name}” snapshot…";
+        SetSnapshotBusy(true);
+
+        var error = await _controller.CreateSnapshotAsync(choice.Name, name);
+        if (error != null)
+        {
+            SetSnapshotBusy(false);
+            _transcript.Append(_html.Warn(error));
+        }
+        // On success the controller clears the offer → SnapshotOfferChanged → RefreshSnapshotOffer
+        // hides the whole thing, and a "Snapshot saved" chip lands in the transcript.
+    }
+
+    /// Toggles the create card's busy state: fades the inputs out (and blocks them) while a
+    /// centered spinner + "Creating…" text shows.
+    private void SetSnapshotBusy(bool busy)
+    {
+        SnapshotBusyRing.IsActive = busy;
+        SnapshotBusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+        SnapshotOfferContent.IsHitTestVisible = !busy;
+
+        var fade = new DoubleAnimation
+        {
+            To = busy ? 0.25 : 1.0,
+            Duration = new Duration(TimeSpan.FromMilliseconds(160)),
+            EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+        };
+        Storyboard.SetTarget(fade, SnapshotOfferContent);
+        Storyboard.SetTargetProperty(fade, "Opacity");
+        var sb = new Storyboard();
+        sb.Children.Add(fade);
+        sb.Begin();
+    }
+
+    private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
+        => _controller.DismissSnapshotOffer();
+
+    /// "Keep memory": verbatim continuation on the new model. On success the offer
+    /// clears itself (SnapshotOfferChanged → RefreshSnapshotOffer); on failure the bar stays
+    /// so Snapshot remains available as the salvage path.
+    private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
+        => _controller.TryCarryMemoryAcrossSwitch();
+
+    /// Saves this tab's transcript as a standalone HTML page. Shared by the header save
+    /// button and the tab's options menu.
+    public async Task ExportTranscriptAsync()
+    {
+        if (!CanScript) return;
+        try
+        {
+            var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML");
+            var html = JsonSerializer.Deserialize(json) ?? "";
+
+            var picker = new Windows.Storage.Pickers.FileSavePicker();
+            WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_owner));
+            picker.FileTypeChoices.Add("HTML page", new List { ".html" });
+            picker.SuggestedFileName = $"mandocode-transcript-{DateTime.Now:yyyy-MM-dd-HHmm}";
+            var file = await picker.PickSaveFileAsync();
+            if (file == null) return;
+
+            await Windows.Storage.FileIO.WriteTextAsync(file, "\n" + html);
+            _transcript.Append(_html.Success($"Transcript saved to {file.Path}"));
+        }
+        catch (Exception ex)
+        {
+            _transcript.Append(_html.Warn($"Couldn't save transcript: {ex.Message}"));
+        }
+    }
+
+    /// Opens a clicked transcript path with its default app (folders open in Explorer).
+    /// Relative paths — how operation cards display them — resolve against THIS tab's root.
+    private void OpenTranscriptPath(string raw)
+    {
+        try
+        {
+            var path = raw.Trim();
+            if (!Path.IsPathRooted(path)) path = Path.Combine(Session.ProjectRoot.ProjectRoot, path);
+            path = Path.GetFullPath(path);
+            if (File.Exists(path) || Directory.Exists(path))
+            {
+                if (ShellOpen.Try(path) is { } ex1)
+                    _transcript.Append(_html.Warn($"Couldn't open file: {ex1.Message}"));
+            }
+            else
+                _transcript.Append(_html.Warn($"Can't open — no longer exists: {path}"));
+        }
+        catch (Exception ex)
+        {
+            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
+        }
+    }
+
+    private static void OpenInBrowser(string url)
+        => ShellOpen.Try(url);   // a dead link must not crash the app — the launch failure is swallowed
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
new file mode 100644
index 0000000..8af92a7
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
@@ -0,0 +1,168 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class ChatTabView
+{
+    // ============================================================
+    // Transcript
+    // ============================================================
+
+    private bool _journalRestored;
+
+    /// Replays this session's journaled transcript into the fresh WebView — via
+    /// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every
+    /// block). Chunked so a long history is a few script calls, not a thousand.
+    private async Task RestoreJournaledTranscriptAsync()
+    {
+        if (_journalRestored) return;
+        _journalRestored = true;
+        try
+        {
+            var blocks = TranscriptJournal.Load(Session.PersistKey)
+                .Where(b => !TranscriptHtmlBuilder.IsEphemeralStatus(b))
+                .ToList();
+            if (blocks.Count == 0) return;
+
+            var chunk = new System.Text.StringBuilder();
+            foreach (var block in blocks)
+            {
+                chunk.Append(block);
+                if (chunk.Length > 400_000)
+                {
+                    await AppendRawAsync(chunk.ToString());
+                    chunk.Clear();
+                }
+            }
+            if (chunk.Length > 0) await AppendRawAsync(chunk.ToString());
+
+            // Divider goes through AppendRawAsync too — journaling it would stack one
+            // divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync,
+            // called by MainWindow after any saved model is re-selected — a model switch clears
+            // history, so restoring memory here would risk it being wiped moments later).
+            _replayedBlockCount = blocks.Count;
+            await AppendRawAsync(_html.Dim("— restored from your previous session —"));
+        }
+        catch { /* a failed replay must never block a fresh conversation */ }
+    }
+
+    private int _replayedBlockCount;
+
+    /// 
+    /// Gives the restored session its memory back, best fidelity first. Called by MainWindow
+    /// AFTER the tab's harness is initialized and any saved model re-selected. Cascade:
+    /// 1) full-fidelity harness history (the agent genuinely remembers, tool calls included);
+    /// 2) plain-text tail armed as imported background (briefed, not remembering);
+    /// 3) an honest amnesia note, so the model never has to guess about the replayed pixels.
+    /// Fresh tabs have none of the files and fall straight through as a no-op.
+    /// 
+    public async Task RestoreConversationMemoryAsync()
+    {
+        try
+        {
+            // 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
+            var historyJson = SessionHistoryStore.Load(Session.PersistKey);
+            if (historyJson != null)
+            {
+                var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson));
+                if (restored > 0)
+                {
+                    await AppendRawAsync(_html.Dim(
+                        $"Conversation memory restored — the agent remembers this session ({restored} messages)."));
+                    return;
+                }
+            }
+
+            // 2) Tail-brief: bounded verbatim excerpt rides the next send as imported background.
+            var turns = ConversationLog.Load(Session.PersistKey);
+            if (turns.Count > 0)
+            {
+                const int budget = 12_000;
+                var picked = new List();
+                var used = 0;
+                for (var i = turns.Count - 1; i >= 0; i--)
+                {
+                    if (picked.Count > 0 && used + turns[i].T.Length > budget) break;
+                    picked.Add(turns[i]);
+                    used += turns[i].T.Length;
+                }
+                picked.Reverse();
+
+                var sb = new System.Text.StringBuilder();
+                if (picked.Count < turns.Count)
+                    sb.Append($"(Older turns omitted — this is the most recent {picked.Count} of {turns.Count}.)\n\n");
+                foreach (var turn in picked)
+                    sb.Append(turn.R == "u" ? "User: " : "Assistant: ").Append(turn.T).Append("\n\n");
+
+                _controller.ArmRestoredConversation(
+                    "From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" +
+                    sb.ToString().TrimEnd());
+                await AppendRawAsync(_html.Dim(
+                    "Context re-armed — the agent will be briefed on this conversation with your next message."));
+                return;
+            }
+
+            // 3) Transcript was replayed but no memory of any kind exists — say so to the model.
+            if (_replayedBlockCount > 0)
+                _controller.NoteWorkspaceEvent(
+                    "This tab was restored from a previous session. The transcript the user sees above is a replay " +
+                    "for their benefit; it is NOT in your context and you have no memory of it. If the user refers " +
+                    "to earlier work, say so honestly and re-read files instead of guessing.");
+        }
+        catch { /* memory restore is best-effort; a fresh conversation always works */ }
+    }
+
+    private async Task AppendRawAsync(string html)
+    {
+        try
+        {
+            await TranscriptView.CoreWebView2.ExecuteScriptAsync(
+                $"window.__append({JsonSerializer.Serialize(html)})");
+        }
+        catch { /* transient during navigation/teardown — a fragment failing to render is not fatal */ }
+    }
+
+    private async void AppendHtml(string html)
+    {
+        if (_shutDown) return;
+        if (!CanScript)
+        {
+            _pendingHtml.Enqueue(html);
+            return;
+        }
+
+        try
+        {
+            await TranscriptView.CoreWebView2.ExecuteScriptAsync(
+                $"window.__append({JsonSerializer.Serialize(html)})");
+        }
+        catch
+        {
+            // A fragment failing to render must never take the app down.
+        }
+    }
+
+    private async void ClearTranscript()
+    {
+        if (!CanScript) return;
+        try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); }
+        catch { /* transient during navigation/teardown — clearing a gone WebView is a no-op */ }
+    }
+
+    /// 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();
+
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs b/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs
new file mode 100644
index 0000000..10363d1
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.ViewModels.cs
@@ -0,0 +1,118 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+/// One row in the header's model dropdown: the model tag plus a cloud/local badge.
+/// Built on the UI thread when the flyout opens, so it can carry ready-made brushes.
+public sealed class ModelItem
+{
+    public ModelItem(string name, string badge, Brush badgeForeground, Brush badgeBackground)
+    {
+        Name = name;
+        Badge = badge;
+        BadgeForeground = badgeForeground;
+        BadgeBackground = badgeBackground;
+    }
+
+    public string Name { get; }
+    public string Badge { get; }
+    public Brush BadgeForeground { get; }
+    public Brush BadgeBackground { get; }
+}
+
+/// One row in the file-explorer tree. Folder nodes are created with unrealized
+/// children and lazy-load their contents on first expand (ChatTabView.ExplorerTree_Expanding).
+public sealed class ExplorerItem : System.ComponentModel.INotifyPropertyChanged
+{
+    public string Name { get; private init; } = "";
+    public string FullPath { get; private init; } = "";
+    public bool IsDirectory { get; private init; }
+
+    /// Root-relative path with forward slashes \u2014 the key used to match this row
+    /// against git change entries.
+    public string RelPath { get; private init; } = "";
+
+    /// The exact @token the row produces (root-relative, forward slashes, trailing
+    /// '/' on folders) \u2014 shown in the tag button's tooltip so hovering teaches the @ syntax.
+    public string Token { get; private init; } = "";
+
+    public string TagTooltip => $"Tag in prompt \u2014 inserts {Token}";
+
+    /// Files: this file has uncommitted changes. Folders: something inside does.
+    /// Mutable + observable so rows already realized in the tree light up in place when a
+    /// git refresh lands (rebuilding the tree would lose expansion state).
+    public bool Dirty
+    {
+        get => _dirty;
+        set
+        {
+            if (_dirty == value) return;
+            _dirty = value;
+            PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DirtyVisibility)));
+        }
+    }
+    private bool _dirty;
+
+    public Visibility DirtyVisibility => _dirty ? Visibility.Visible : Visibility.Collapsed;
+
+    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
+
+    public string Glyph => IsDirectory ? "\uE8B7" : "\uE8A5";   // folder / document
+
+    /// Resolved per-realization from app resources, so icons pick up live theme
+    /// switches the next time rows are created (matching how transcript colors retheme).
+    public Brush? IconBrush =>
+        Application.Current.Resources[IsDirectory ? "MandoGoldBrush" : "MandoDimBrush"] as Brush;
+
+    public static ExplorerItem ForFolder(string path, string root)
+    {
+        var rel = Rel(path, root);
+        return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, RelPath = rel, Token = "@" + rel + "/" };
+    }
+
+    public static ExplorerItem ForFile(string path, string root)
+    {
+        var rel = Rel(path, root);
+        return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, RelPath = rel, Token = "@" + rel };
+    }
+
+    private static string Rel(string path, string root) =>
+        Path.GetRelativePath(root, path).Replace('\\', '/');
+}
+
+/// One row in the explorer's Changes tab: a working-tree change with its display
+/// letter/color, split name + directory, and the @token its tag button inserts. Built on
+/// the UI thread from a GitQuickStatus snapshot, so it carries ready-made brushes
+/// (same pattern as ModelItem).
+public sealed class GitChangeItem
+{
+    public string Kind { get; init; } = "";
+    public string KindLabel { get; init; } = "";
+    public Brush? KindBrush { get; init; }
+    public string Name { get; init; } = "";
+    public string Dir { get; init; } = "";
+    public string FullPath { get; init; } = "";
+    public string RelPath { get; init; } = "";
+    public string TagTooltip { get; init; } = "";
+
+    /// Undo restores from HEAD, so it needs a HEAD side: hidden for untracked rows
+    /// ("undoing" a new file would DELETE it — different action, different UI) and renamed
+    /// rows (a clean rename-undo needs both paths).
+    public Visibility UndoVisibility => Kind is "M" or "D" or "!" ? Visibility.Visible : Visibility.Collapsed;
+
+    public string UndoTooltip => Kind == "D"
+        ? "Restore this deleted file"
+        : "Undo changes — restore this file to the last commit (asks first)";
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
index a30dc6f..95970ec 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
@@ -549,7 +549,7 @@
             
 
             
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index ff8730c..ad2e63d 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -348,2090 +348,4 @@ public void HandleEscape()
         _controller.CancelActiveRequest();
     }
 
-    // ============================================================
-    // Transcript
-    // ============================================================
-
-    private bool _journalRestored;
-
-    /// Replays this session's journaled transcript into the fresh WebView — via
-    /// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every
-    /// block). Chunked so a long history is a few script calls, not a thousand.
-    private async Task RestoreJournaledTranscriptAsync()
-    {
-        if (_journalRestored) return;
-        _journalRestored = true;
-        try
-        {
-            var blocks = TranscriptJournal.Load(Session.PersistKey)
-                .Where(b => !TranscriptHtmlBuilder.IsEphemeralStatus(b))
-                .ToList();
-            if (blocks.Count == 0) return;
-
-            var chunk = new System.Text.StringBuilder();
-            foreach (var block in blocks)
-            {
-                chunk.Append(block);
-                if (chunk.Length > 400_000)
-                {
-                    await AppendRawAsync(chunk.ToString());
-                    chunk.Clear();
-                }
-            }
-            if (chunk.Length > 0) await AppendRawAsync(chunk.ToString());
-
-            // Divider goes through AppendRawAsync too — journaling it would stack one
-            // divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync,
-            // called by MainWindow after any saved model is re-selected — a model switch clears
-            // history, so restoring memory here would risk it being wiped moments later).
-            _replayedBlockCount = blocks.Count;
-            await AppendRawAsync(_html.Dim("— restored from your previous session —"));
-        }
-        catch { /* a failed replay must never block a fresh conversation */ }
-    }
-
-    private int _replayedBlockCount;
-
-    /// 
-    /// Gives the restored session its memory back, best fidelity first. Called by MainWindow
-    /// AFTER the tab's harness is initialized and any saved model re-selected. Cascade:
-    /// 1) full-fidelity harness history (the agent genuinely remembers, tool calls included);
-    /// 2) plain-text tail armed as imported background (briefed, not remembering);
-    /// 3) an honest amnesia note, so the model never has to guess about the replayed pixels.
-    /// Fresh tabs have none of the files and fall straight through as a no-op.
-    /// 
-    public async Task RestoreConversationMemoryAsync()
-    {
-        try
-        {
-            // 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
-            var historyJson = SessionHistoryStore.Load(Session.PersistKey);
-            if (historyJson != null)
-            {
-                var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson));
-                if (restored > 0)
-                {
-                    await AppendRawAsync(_html.Dim(
-                        $"Conversation memory restored — the agent remembers this session ({restored} messages)."));
-                    return;
-                }
-            }
-
-            // 2) Tail-brief: bounded verbatim excerpt rides the next send as imported background.
-            var turns = ConversationLog.Load(Session.PersistKey);
-            if (turns.Count > 0)
-            {
-                const int budget = 12_000;
-                var picked = new List();
-                var used = 0;
-                for (var i = turns.Count - 1; i >= 0; i--)
-                {
-                    if (picked.Count > 0 && used + turns[i].T.Length > budget) break;
-                    picked.Add(turns[i]);
-                    used += turns[i].T.Length;
-                }
-                picked.Reverse();
-
-                var sb = new System.Text.StringBuilder();
-                if (picked.Count < turns.Count)
-                    sb.Append($"(Older turns omitted — this is the most recent {picked.Count} of {turns.Count}.)\n\n");
-                foreach (var turn in picked)
-                    sb.Append(turn.R == "u" ? "User: " : "Assistant: ").Append(turn.T).Append("\n\n");
-
-                _controller.ArmRestoredConversation(
-                    "From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" +
-                    sb.ToString().TrimEnd());
-                await AppendRawAsync(_html.Dim(
-                    "Context re-armed — the agent will be briefed on this conversation with your next message."));
-                return;
-            }
-
-            // 3) Transcript was replayed but no memory of any kind exists — say so to the model.
-            if (_replayedBlockCount > 0)
-                _controller.NoteWorkspaceEvent(
-                    "This tab was restored from a previous session. The transcript the user sees above is a replay " +
-                    "for their benefit; it is NOT in your context and you have no memory of it. If the user refers " +
-                    "to earlier work, say so honestly and re-read files instead of guessing.");
-        }
-        catch { /* memory restore is best-effort; a fresh conversation always works */ }
-    }
-
-    private async Task AppendRawAsync(string html)
-    {
-        try
-        {
-            await TranscriptView.CoreWebView2.ExecuteScriptAsync(
-                $"window.__append({JsonSerializer.Serialize(html)})");
-        }
-        catch { }
-    }
-
-    private async void AppendHtml(string html)
-    {
-        if (_shutDown) return;
-        if (!CanScript)
-        {
-            _pendingHtml.Enqueue(html);
-            return;
-        }
-
-        try
-        {
-            await TranscriptView.CoreWebView2.ExecuteScriptAsync(
-                $"window.__append({JsonSerializer.Serialize(html)})");
-        }
-        catch
-        {
-            // A fragment failing to render must never take the app down.
-        }
-    }
-
-    private async void ClearTranscript()
-    {
-        if (!CanScript) return;
-        try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); }
-        catch { }
-    }
-
-    /// 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();
-
-    // ============================================================
-    // 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 top offer to match the controller's pending buffer. Stage 1 is the
-    /// thin notification bar; the full picker is built only when the user clicks Create on it.
-    private void RefreshSnapshotOffer()
-    {
-        var offer = _controller.PendingOffer;
-        if (offer == null)
-        {
-            SnapshotOfferRoot.Visibility = Visibility.Collapsed;
-            return;
-        }
-
-        // A manual "Take snapshot" is an explicit decision — skip the notification bar and open the
-        // name+model picker straight away. Model switches keep stage 1, since there the user may
-        // just want to keep working (or "keep memory") rather than snapshot at all.
-        if (offer.IsManual)
-        {
-            ShowSnapshotPickerCard(offer);
-            return;
-        }
-
-        // Stage 1: notification bar. Non-blocking — the user can ignore it and keep prompting.
-        // "Keep memory" only appears when a switch actually cleared a conversation.
-        SnapshotKeepMemoryButton.Visibility = _controller.CanCarryMemory
-            ? Visibility.Visible : Visibility.Collapsed;
-        SnapshotNotifyText.Text = _controller.CanCarryMemory
-            ? $"Keep the {offer.OriginModel} conversation going, or save it as a snapshot?"
-            : $"Snapshot available — save the {offer.OriginModel} conversation.";
-        SnapshotNotifyBar.Visibility = Visibility.Visible;
-        SnapshotOfferCard.Visibility = Visibility.Collapsed;
-        SnapshotOfferRoot.Visibility = Visibility.Visible;
-        SlideSnapshotOfferIn();
-    }
-
-    /// Stage 2: expand into the full name + model picker. Reached either from the
-    /// notification bar's Create (model switches) or directly for a manual "Take snapshot". Hangs at
-    /// the top until the user creates or dismisses.
-    private void ShowSnapshotPickerCard(ChatController.PendingSnapshot offer)
-    {
-        SnapshotOfferSubtitle.Text =
-            $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — "
-            + "name it (or leave blank and the summarizer will), pick a model, and create.";
-        SnapshotCreateButton.Content = "Create";
-        SnapshotNameBox.Text = "";   // a fresh offer starts unnamed
-        // Reset any leftover busy state from a prior, interrupted attempt.
-        SnapshotBusyPanel.Visibility = Visibility.Collapsed;
-        SnapshotBusyRing.IsActive = false;
-        SnapshotOfferContent.Opacity = 1;
-        SnapshotOfferContent.IsHitTestVisible = true;
-        SnapshotNotifyBar.Visibility = Visibility.Collapsed;
-        SnapshotOfferCard.Visibility = Visibility.Visible;
-        SnapshotOfferRoot.Visibility = Visibility.Visible;
-        SlideSnapshotOfferIn();
-        _ = LoadSnapshotModelsAsync(offer.OriginModel);
-    }
-
-    private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e)
-    {
-        var offer = _controller.PendingOffer;
-        if (offer != null) ShowSnapshotPickerCard(offer);
-    }
-
-    /// Drops the offer down from the top of the transcript with a short fade.
-    private void SlideSnapshotOfferIn()
-    {
-        var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
-        var slide = new DoubleAnimation
-        {
-            From = -18, To = 0,
-            Duration = new Duration(TimeSpan.FromMilliseconds(220)),
-            EasingFunction = ease,
-        };
-        Storyboard.SetTarget(slide, SnapshotOfferTransform);
-        Storyboard.SetTargetProperty(slide, "Y");
-        var fade = new DoubleAnimation
-        {
-            From = 0, To = 1,
-            Duration = new Duration(TimeSpan.FromMilliseconds(180)),
-            EasingFunction = ease,
-        };
-        Storyboard.SetTarget(fade, SnapshotOfferRoot);
-        Storyboard.SetTargetProperty(fade, "Opacity");
-        var sb = new Storyboard();
-        sb.Children.Add(slide);
-        sb.Children.Add(fade);
-        sb.Begin();
-    }
-
-    /// 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;
-        }
-
-        // Summarizing can take a while (especially a cloud model), so show a clear busy state:
-        // fade the controls out and spin, with the snapshot's name in the message when it has one.
-        var name = SnapshotNameBox.Text?.Trim() ?? "";
-        SnapshotBusyText.Text = string.IsNullOrEmpty(name)
-            ? "Creating snapshot…"
-            : $"Creating “{name}” snapshot…";
-        SetSnapshotBusy(true);
-
-        var error = await _controller.CreateSnapshotAsync(choice.Name, name);
-        if (error != null)
-        {
-            SetSnapshotBusy(false);
-            _transcript.Append(_html.Warn(error));
-        }
-        // On success the controller clears the offer → SnapshotOfferChanged → RefreshSnapshotOffer
-        // hides the whole thing, and a "Snapshot saved" chip lands in the transcript.
-    }
-
-    /// Toggles the create card's busy state: fades the inputs out (and blocks them) while a
-    /// centered spinner + "Creating…" text shows.
-    private void SetSnapshotBusy(bool busy)
-    {
-        SnapshotBusyRing.IsActive = busy;
-        SnapshotBusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
-        SnapshotOfferContent.IsHitTestVisible = !busy;
-
-        var fade = new DoubleAnimation
-        {
-            To = busy ? 0.25 : 1.0,
-            Duration = new Duration(TimeSpan.FromMilliseconds(160)),
-            EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
-        };
-        Storyboard.SetTarget(fade, SnapshotOfferContent);
-        Storyboard.SetTargetProperty(fade, "Opacity");
-        var sb = new Storyboard();
-        sb.Children.Add(fade);
-        sb.Begin();
-    }
-
-    private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
-        => _controller.DismissSnapshotOffer();
-
-    /// "Keep memory": verbatim continuation on the new model. On success the offer
-    /// clears itself (SnapshotOfferChanged → RefreshSnapshotOffer); on failure the bar stays
-    /// so Snapshot remains available as the salvage path.
-    private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
-        => _controller.TryCarryMemoryAcrossSwitch();
-
-    /// Saves this tab's transcript as a standalone HTML page. Shared by the header save
-    /// button and the tab's options menu.
-    public async Task ExportTranscriptAsync()
-    {
-        if (!CanScript) return;
-        try
-        {
-            var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML");
-            var html = JsonSerializer.Deserialize(json) ?? "";
-
-            var picker = new Windows.Storage.Pickers.FileSavePicker();
-            WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_owner));
-            picker.FileTypeChoices.Add("HTML page", new List { ".html" });
-            picker.SuggestedFileName = $"mandocode-transcript-{DateTime.Now:yyyy-MM-dd-HHmm}";
-            var file = await picker.PickSaveFileAsync();
-            if (file == null) return;
-
-            await Windows.Storage.FileIO.WriteTextAsync(file, "\n" + html);
-            _transcript.Append(_html.Success($"Transcript saved to {file.Path}"));
-        }
-        catch (Exception ex)
-        {
-            _transcript.Append(_html.Warn($"Couldn't save transcript: {ex.Message}"));
-        }
-    }
-
-    /// Opens a clicked transcript path with its default app (folders open in Explorer).
-    /// Relative paths — how operation cards display them — resolve against THIS tab's root.
-    private void OpenTranscriptPath(string raw)
-    {
-        try
-        {
-            var path = raw.Trim();
-            if (!Path.IsPathRooted(path)) path = Path.Combine(Session.ProjectRoot.ProjectRoot, path);
-            path = Path.GetFullPath(path);
-            if (File.Exists(path) || Directory.Exists(path))
-                System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
-            else
-                _transcript.Append(_html.Warn($"Can't open — no longer exists: {path}"));
-        }
-        catch (Exception ex)
-        {
-            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
-        }
-    }
-
-    private static void OpenInBrowser(string url)
-    {
-        try
-        {
-            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
-        }
-        catch { /* a dead link must not crash the app */ }
-    }
-
-    // ============================================================
-    // Header / busy / plan progress
-    // ============================================================
-
-    public void UpdateHeader()
-    {
-        ModelText.Text = _controller.ModelName;
-        ProjectRootText.Text = _controller.ProjectRootPath;
-        ConnectionDot.Fill = new SolidColorBrush(
-            _controller.ModelError ? Colors.Orange
-            : _controller.IsConnected ? Colors.LimeGreen
-            : Colors.Gray);
-
-        var tracker = Session.Tokens;
-        TokenText.Text = tracker.TotalSessionTokens > 0
-            ? $"{MandoCode.Services.TokenTrackingService.FormatTokenCount(tracker.TotalSessionTokens)} tokens"
-            : "";
-
-        var processing = _controller.IsProcessing;
-        SendIcon.Glyph = processing ? "" : "";   // stop vs send
-        SendLabel.Text = processing ? "Stop" : "Send";
-        ModelButton.IsEnabled = !processing;   // no model switch mid-turn
-
-        RefreshBranchChip();
-
-        HeaderChanged?.Invoke(this);
-    }
-
-    private void UpdateBusy(bool busy, string? activity)
-    {
-        BusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
-        BusyRing.IsActive = busy;
-        if (busy) BusyText.Text = string.IsNullOrWhiteSpace(activity) ? "Working..." : activity;
-        else
-        {
-            // Turn just ended: refresh git state and snapshot it as the baseline for
-            // detecting OUTSIDE-the-conversation changes before the next send.
-            _wsTracker.MarkCapturePending();
-            RefreshBranchChip(force: true);
-
-            // Persist the model's full memory as of this turn (tier-3 full fidelity).
-            // Off-thread: serialization of a long history shouldn't touch UI latency.
-            var key = Session.PersistKey;
-            var ai = Session.Ai;
-            _ = Task.Run(() => SessionHistoryStore.Save(key, ai.ExportHistoryJson()));
-        }
-    }
-
-    // ============================================================
-    // Workspace-change notes for the model
-    // ============================================================
-    // The model only knows what happened inside the conversation. Anything else — the undo
-    // button discarding its edits, files changed in another editor, external branch switches
-    // — is invisible to it and leaves its picture of the working tree stale. The decision
-    // logic lives in WorkspaceDeltaTracker (pure, unit-testable); this class only feeds it:
-    // turn end → MarkCapturePending, git refresh → CaptureBaselineIfPending, watcher touch →
-    // RecordTouch, send → EmitDelta. Notes queue on the controller (same pattern as reactions).
-
-    private GitBranchInfo? _lastGitInfo;
-    private readonly WorkspaceDeltaTracker _wsTracker = new();
-
-    /// Called at send time: queues notes for whatever changed outside the
-    /// conversation since the last turn ended, then re-baselines.
-    private void EmitWorkspaceDelta()
-    {
-        foreach (var note in _wsTracker.EmitDelta(_lastGitInfo))
-            _controller.NoteWorkspaceEvent(note);
-    }
-
-    // ============================================================
-    // Git status strip
-    // ============================================================
-
-    private int _branchRefreshSeq;
-    private DateTime _lastBranchRefresh = DateTime.MinValue;
-    private string? _lastGitRoot;
-    private readonly ObservableCollection _changes = new();
-
-    /// Fire-and-forget refresh of the bottom status strip AND the explorer's Changes
-    /// tab (one git call feeds both). Throttled (UpdateHeader runs on every controller state
-    /// change) except when the root changed; sequence-guarded so an older, slower git call
-    /// can never overwrite a newer result; any failure just hides the strip.
-    private async void RefreshBranchChip(bool force = false)
-    {
-        var root = _controller.ProjectRootPath;
-        if (root != _lastGitRoot) force = true;   // never show the previous folder's state
-        if (!force && (DateTime.UtcNow - _lastBranchRefresh).TotalSeconds < 2) return;
-        _lastBranchRefresh = DateTime.UtcNow;
-        _lastGitRoot = root;
-
-        var seq = ++_branchRefreshSeq;
-        var info = await Task.Run(() => GitQuickStatus.TryGet(root));
-
-        if (_shutDown || seq != _branchRefreshSeq) return;
-        _lastGitInfo = info;
-        UpdateChangesList(info, root);
-        _wsTracker.CaptureBaselineIfPending(info);
-        if (info == null)
-        {
-            StatusStrip.Visibility = Visibility.Collapsed;
-            return;
-        }
-
-        BranchText.Text = info.Branch
-            + (info.Ahead > 0 ? $" ↑{info.Ahead}" : "")
-            + (info.Behind > 0 ? $" ↓{info.Behind}" : "");
-
-        // One status light: conflicts trump dirty trumps clean.
-        var (dotBrush, state) =
-            info.Conflicted ? ("MandoRedBrush", "merge conflicts")
-            : info.Dirty ? ("MandoGoldBrush", "uncommitted changes")
-            : ("MandoGreenBrush", "clean");
-        BranchDot.Fill = Application.Current.Resources[dotBrush] as Brush;
-
-        var foreignRoot = info.RepoRoot.Length > 0 && !string.Equals(
-            Path.TrimEndingDirectorySeparator(info.RepoRoot),
-            Path.TrimEndingDirectorySeparator(root), StringComparison.OrdinalIgnoreCase);
-        ToolTipService.SetToolTip(StatusStrip,
-            (info.Detached ? "Detached HEAD at commit " + info.Branch : "Git branch: " + info.Branch)
-            + " — " + state
-            + (info.Ahead > 0 || info.Behind > 0
-                ? $" ({info.Ahead} ahead, {info.Behind} behind upstream)" : "")
-            // Git found the repo in an ANCESTOR folder — say so, or this reads as a ghost.
-            + (foreignRoot ? $"\nRepository root: {info.RepoRoot} (this folder is inside that repository)" : ""));
-        StatusStrip.Visibility = Visibility.Visible;
-    }
-
-    /// Rebuilds the Changes tab's rows from a fresh git snapshot (UI thread).
-    private void UpdateChangesList(GitBranchInfo? info, string root)
-    {
-        if (ChangesList.ItemsSource == null) ChangesList.ItemsSource = _changes;
-
-        // Rebuilding the collection re-realizes every ListView row — a visible flash — so
-        // bail when this snapshot is identical to what's already shown (the common case:
-        // most refreshes confirm state rather than change it). Badges derive from the same
-        // data, so they can't have changed either.
-        var incoming = info?.Changes ?? (IReadOnlyList)Array.Empty();
-        if (incoming.Count == _changes.Count)
-        {
-            var identical = true;
-            for (var i = 0; i < incoming.Count; i++)
-            {
-                if (incoming[i].RelPath != _changes[i].RelPath || incoming[i].Kind != _changes[i].Kind)
-                {
-                    identical = false;
-                    break;
-                }
-            }
-            if (identical) return;
-        }
-
-        _changes.Clear();
-        if (info != null)
-        {
-            foreach (var c in info.Changes)
-            {
-                var relNative = c.RelPath.Replace('/', Path.DirectorySeparatorChar);
-                _changes.Add(new GitChangeItem
-                {
-                    Kind = c.Kind,
-                    KindBrush = BrushForKind(c.Kind),
-                    KindLabel = c.Kind switch
-                    {
-                        "!" => "Merge conflict",
-                        "U" => "Untracked (new, not yet added)",
-                        "A" => "Added",
-                        "D" => "Deleted",
-                        "R" => "Renamed",
-                        _ => "Modified",
-                    },
-                    Name = Path.GetFileName(c.RelPath.TrimEnd('/')),
-                    Dir = Path.GetDirectoryName(relNative)?.Replace(Path.DirectorySeparatorChar, '/') ?? "",
-                    FullPath = Path.Combine(root, relNative),
-                    RelPath = c.RelPath,
-                    TagTooltip = $"Tag in prompt — inserts @{c.RelPath}",
-                });
-            }
-        }
-
-        ChangesTabButton.Content = _changes.Count > 0 ? $"Changes ({_changes.Count})" : "Changes";
-        ChangesEmptyText.Visibility = _changesTabActive && _changes.Count == 0
-            ? Visibility.Visible : Visibility.Collapsed;
-        CommitButton.IsEnabled = _changes.Count > 0;
-
-        RebuildDirtySets(info);
-        RefreshExplorerDirtyFlags();
-    }
-
-    // --- dirty badges on the file tree ---
-    // A changed file gets a gold dot; every ancestor folder gets one too, so a collapsed
-    // folder still signals "something inside changed" (VS Code's badge behavior).
-
-    private readonly HashSet _gitDirtyFiles = new(StringComparer.OrdinalIgnoreCase);
-    private readonly HashSet _gitDirtyDirs = new(StringComparer.OrdinalIgnoreCase);
-
-    private void RebuildDirtySets(GitBranchInfo? info)
-    {
-        _gitDirtyFiles.Clear();
-        _gitDirtyDirs.Clear();
-        if (info == null) return;
-        foreach (var c in info.Changes)
-        {
-            var rel = c.RelPath.TrimEnd('/');
-            // Untracked directories arrive as one "dir/" entry — that's a dir badge, not a file.
-            if (c.RelPath.EndsWith('/')) _gitDirtyDirs.Add(rel);
-            else _gitDirtyFiles.Add(rel);
-            for (var slash = rel.LastIndexOf('/'); slash > 0; slash = rel.LastIndexOf('/'))
-            {
-                rel = rel[..slash];
-                _gitDirtyDirs.Add(rel);
-            }
-        }
-    }
-
-    /// Re-flags every REALIZED tree node in place (expansion state survives).
-    /// Nodes created later pick their flag up at creation in LoadChildNodes.
-    private void RefreshExplorerDirtyFlags()
-    {
-        Walk(ExplorerTree.RootNodes);
-
-        void Walk(IList nodes)
-        {
-            foreach (var node in nodes)
-            {
-                if (node.Content is ExplorerItem item) item.Dirty = IsItemDirty(item);
-                if (node.Children.Count > 0) Walk(node.Children);
-            }
-        }
-    }
-
-    private bool IsItemDirty(ExplorerItem item) =>
-        item.IsDirectory ? _gitDirtyDirs.Contains(item.RelPath) : _gitDirtyFiles.Contains(item.RelPath);
-
-    private static Brush? BrushForKind(string kind) =>
-        Application.Current.Resources[kind switch
-        {
-            "!" or "D" => "MandoRedBrush",
-            "A" or "U" => "MandoGreenBrush",
-            "R" => "MandoSkyBrush",
-            _ => "MandoGoldBrush",
-        }] as Brush;
-
-    private void UpdatePlanProgress(int done, int total, bool active)
-    {
-        PlanProgressPanel.Visibility = active ? Visibility.Visible : Visibility.Collapsed;
-        if (total > 0)
-        {
-            PlanProgressBar.Value = done * 100.0 / total;
-            PlanProgressText.Text = $"Plan: step {Math.Min(done + 1, total)} of {total}";
-        }
-    }
-
-    /// 
-    /// Populates the model dropdown each time it opens. The flyout appears immediately showing a
-    /// loading spinner; this awaits the model list off the UI thread and swaps in the rows (or an
-    /// inline error) when it returns. Tab-local — picking a model repins THIS agent only.
-    /// 
-    private async void ModelFlyout_Opening(object? sender, object e)
-    {
-        ModelLoadingPanel.Visibility = Visibility.Visible;
-        ModelErrorText.Visibility = Visibility.Collapsed;
-        ModelList.Visibility = Visibility.Collapsed;
-
-        var result = await _controller.LoadAvailableModelsAsync();
-
-        if (!result.Ok)
-        {
-            ModelErrorText.Text = result.Error;
-            ModelLoadingPanel.Visibility = Visibility.Collapsed;
-            ModelErrorText.Visibility = Visibility.Visible;
-            return;
-        }
-
-        var sky = (Brush)Application.Current.Resources["MandoSkyBrush"];
-        var dim = (Brush)Application.Current.Resources["MandoDimBrush"];
-        var badgeBg = new SolidColorBrush(Windows.UI.Color.FromArgb(0x22, 0x80, 0x80, 0x80));
-        var current = _controller.ModelName;
-
-        var items = result.Models.Select(m =>
-        {
-            var cloud = MandoCodeConfig.IsCloudModel(m);
-            return new ModelItem(m, cloud ? "cloud" : "local", cloud ? sky : dim, badgeBg);
-        }).ToList();
-
-        ModelList.ItemsSource = items;
-        ModelList.SelectedItem = items.FirstOrDefault(
-            i => string.Equals(i.Name, current, StringComparison.OrdinalIgnoreCase));
-
-        ModelLoadingPanel.Visibility = Visibility.Collapsed;
-        ModelList.Visibility = Visibility.Visible;
-    }
-
-    private async void ModelList_ItemClick(object sender, ItemClickEventArgs e)
-    {
-        ModelFlyout.Hide();
-        if (e.ClickedItem is not ModelItem item) return;
-        if (string.Equals(item.Name, _controller.ModelName, StringComparison.OrdinalIgnoreCase)) return;
-
-        await Task.Run(() => _controller.SelectModelAsync(item.Name));
-        UpdateHeader();
-    }
-
-    private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
-    {
-        var picker = new Windows.Storage.Pickers.FolderPicker();
-        picker.FileTypeFilter.Add("*");
-
-        // Unpackaged apps must initialize pickers with the window handle.
-        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_owner));
-
-        var folder = await picker.PickSingleFolderAsync();
-        if (folder == null) return;
-
-        _transcript.Append(_html.Info($"Project root changed to: {folder.Path}"));
-        _transcript.Append(_html.Dim("Rebuilding the AI session for the new project…"));
-
-        // Retargets THIS tab only — its own ProjectRootAccessor, file cache, and kernel.
-        // Other agents keep working in their own folders.
-        var session = Session;
-        await Task.Run(async () =>
-        {
-            await session.ChangeProjectRootAsync(folder.Path);
-            _transcript.Append(_html.Success("✓ Ready."));
-        });
-        UpdateHeader();
-        if (_explorerOpen) BuildExplorerRoot();   // the open tree must follow the new root
-    }
-
-    // ============================================================
-    // File explorer panel
-    // ============================================================
-
-    private bool _explorerOpen;
-    private string? _explorerRoot;   // root the tree was last built for
-
-    private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen);
-    private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false);
-
-    private void ExplorerRefresh_Click(object sender, RoutedEventArgs e)
-    {
-        BuildExplorerRoot();
-        RefreshBranchChip(force: true);   // the Changes tab re-reads too
-    }
-
-    // --- Files / Changes tabs ---
-
-    private bool _changesTabActive;
-
-    private void FilesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: false);
-    private void ChangesTab_Click(object sender, RoutedEventArgs e) => SetExplorerTab(changes: true);
-
-    private void SetExplorerTab(bool changes)
-    {
-        _changesTabActive = changes;
-        ExplorerTree.Visibility = changes ? Visibility.Collapsed : Visibility.Visible;
-        ChangesList.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
-        ChangesEmptyText.Visibility = changes && _changes.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
-        ChangesFooter.Visibility = changes ? Visibility.Visible : Visibility.Collapsed;
-        CommitButton.IsEnabled = _changes.Count > 0;
-        FilesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.Normal : Microsoft.UI.Text.FontWeights.SemiBold;
-        ChangesTabButton.FontWeight = changes ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal;
-        FilesTabButton.Opacity = changes ? 0.55 : 1;
-        ChangesTabButton.Opacity = changes ? 1 : 0.55;
-    }
-
-    private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e)
-    {
-        if (_explorerOpen) SizeExplorer();
-    }
-
-    private void SizeExplorer()
-    {
-        // Default ~20% of the window, clamped so the tree stays usable on small windows and
-        // doesn't waste half a 4K monitor on the other end. Once the user has dragged the
-        // splitter, their width wins (re-clamped so a shrunken window can't strand the panel).
-        var w = ChatRoot.ActualWidth;
-        if (w <= 0) return;
-        var target = _explorerUserWidth ?? Math.Clamp(w * 0.20, 220, 460);
-        ExplorerPanel.Width = Math.Clamp(target, MinExplorerWidth, MaxExplorerWidth());
-    }
-
-    private const double MinExplorerWidth = 180;
-    private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, ChatRoot.ActualWidth * 0.6);
-
-    // --- splitter drag (same pointer-capture pattern as MainWindow's terminal splitter) ---
-
-    private double? _explorerUserWidth;   // set on first drag; SizeExplorer defers to it
-    private bool _draggingExplorer;
-    private double _explorerDragStartWidth;
-    private double _explorerDragStartX;
-
-    private void ExplorerSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
-    {
-        _draggingExplorer = true;
-        _explorerDragStartWidth = ExplorerPanel.ActualWidth;
-        _explorerDragStartX = e.GetCurrentPoint(ChatRoot).Position.X;   // stable frame while the grip moves
-        ((UIElement)sender).CapturePointer(e.Pointer);
-    }
-
-    private void ExplorerSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingExplorer) return;
-        // Dragging left grows the panel; right shrinks it.
-        var delta = e.GetCurrentPoint(ChatRoot).Position.X - _explorerDragStartX;
-        var next = Math.Clamp(_explorerDragStartWidth - delta, MinExplorerWidth, MaxExplorerWidth());
-        ExplorerPanel.Width = next;
-        _explorerUserWidth = next;
-    }
-
-    private void ExplorerSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingExplorer) return;
-        _draggingExplorer = false;
-        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
-    }
-
-    private void ToggleExplorer(bool open)
-    {
-        if (open == _explorerOpen) return;
-        _explorerOpen = open;
-
-        // Docked, not overlaid: the panel sits in the transcript row's second column, so
-        // showing it RESIZES the transcript (text stays fully readable) and collapsing it
-        // gives the width back. No slide animation — animating a WebView2's width forces
-        // continuous relayout of the browser surface, and instant dock/undock is how
-        // solution-explorer-style panels behave anyway.
-        if (open)
-        {
-            SizeExplorer();
-            // (Re)build on open when the tab's root changed since the tree was built — the
-            // panel keeps its expansion state across close/open within the same root.
-            if (_explorerRoot != _controller.ProjectRootPath) BuildExplorerRoot();
-            ExplorerPanel.Visibility = Visibility.Visible;
-            ExplorerSplitter.Visibility = Visibility.Visible;
-        }
-        else
-        {
-            ExplorerPanel.Visibility = Visibility.Collapsed;
-            ExplorerSplitter.Visibility = Visibility.Collapsed;
-        }
-    }
-
-    private void BuildExplorerRoot()
-    {
-        _explorerRoot = _controller.ProjectRootPath;
-        ExplorerRootText.Text = Path.GetFileName(Path.TrimEndingDirectorySeparator(_explorerRoot));
-        ToolTipService.SetToolTip(ExplorerRootText, _explorerRoot);
-        ExplorerTree.RootNodes.Clear();
-        foreach (var node in LoadChildNodes(_explorerRoot)) ExplorerTree.RootNodes.Add(node);
-        StartExplorerWatcher(_explorerRoot);
-    }
-
-    // --- filesystem watcher: the tree follows external creates/deletes/renames on its own ---
-    // Efficiency comes from three choices: (1) only NAME notifications — content writes don't
-    // change tree shape; (2) events debounce into one flush, so a build touching 500 files
-    // costs one pass; (3) a flush re-syncs only REALIZED directory nodes — churn under a
-    // never-expanded folder (node_modules, bin/obj) is a hash lookup and a skip, because
-    // lazy loading will read the truth from disk whenever it's finally expanded.
-
-    private FileSystemWatcher? _fsWatcher;
-    private readonly object _fsLock = new();
-    private readonly HashSet _pendingFsDirs = new(StringComparer.OrdinalIgnoreCase);
-    private bool _fsFlushQueued;
-    private bool _fsSyncAll;   // watcher buffer overflowed — re-sync every realized dir
-
-    private void StartExplorerWatcher(string root)
-    {
-        StopExplorerWatcher();
-        try
-        {
-            _fsWatcher = new FileSystemWatcher(root)
-            {
-                IncludeSubdirectories = true,
-                // LastWrite so EDITS refresh git state (M rows, badges, dirty dot) — name
-                // events alone only cover tree shape. Content writes are routed git-only
-                // below: they can't change the tree, so they never trigger tree syncs.
-                NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite,
-                InternalBufferSize = 64 * 1024,   // max — fewer overflows during big builds
-            };
-            _fsWatcher.Created += (_, e) => QueueFsEvent(e.FullPath);
-            _fsWatcher.Deleted += (_, e) => QueueFsEvent(e.FullPath);
-            _fsWatcher.Renamed += (_, e) => { QueueFsEvent(e.OldFullPath); QueueFsEvent(e.FullPath); };
-            _fsWatcher.Changed += (_, e) => QueueFsEvent(e.FullPath, treeRelevant: false);
-            _fsWatcher.Error += (_, _) => { lock (_fsLock) { _fsSyncAll = true; } QueueFsEvent(root); };
-            _fsWatcher.EnableRaisingEvents = true;
-        }
-        catch
-        {
-            _fsWatcher = null;   // best-effort — the refresh button still exists
-        }
-    }
-
-    private void StopExplorerWatcher()
-    {
-        try { _fsWatcher?.Dispose(); } catch { }
-        _fsWatcher = null;
-    }
-
-    /// Threadpool-side: coalesce this event's parent directory into the pending set
-    /// and arm one debounced flush. .git churn and content-only writes skip the tree but
-    /// still refresh git state — that's how external edits, branch switches, and commits
-    /// show up without a manual refresh.
-    private void QueueFsEvent(string fullPath, bool treeRelevant = true)
-    {
-        bool arm;
-        lock (_fsLock)
-        {
-            var rel = ToRelOrNull(fullPath)?.Replace('\\', '/');
-            if (rel == null) return;
-            var isGit = rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase);
-
-            // Our OWN git calls write .git/index (+ transient *.lock files) — reacting to
-            // those would refresh forever: refresh → git status → index event → refresh…
-            // Ignore them; real external actions (checkout, commit) also touch HEAD/refs,
-            // which still get through and trigger the refresh we want.
-            if (isGit && (rel.EndsWith("/index", StringComparison.OrdinalIgnoreCase)
-                       || rel.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)))
-                return;
-
-            if (!isGit && treeRelevant)
-                _pendingFsDirs.Add(Path.GetDirectoryName(fullPath) ?? "");
-
-            // Workspace notes: remember WHICH files were touched while the agent was idle.
-            // Status-snapshot diffing alone misses content edits to files that were ALREADY
-            // dirty/untracked (their status entry doesn't change) — this set fills that gap.
-            // Idle-gated so the agent's own writes never count as external.
-            if (!isGit && !_controller.IsProcessing)
-                _wsTracker.RecordTouch(rel);
-
-            arm = !_fsFlushQueued;
-            _fsFlushQueued = true;
-        }
-        if (arm) _ = FlushFsEventsAsync();
-
-        string? ToRelOrNull(string p)
-        {
-            var root = _explorerRoot;
-            if (root == null) return null;
-            var prefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
-            return p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? p[prefix.Length..] : null;
-        }
-    }
-
-    private async Task FlushFsEventsAsync()
-    {
-        await Task.Delay(800);   // coalesce the burst
-        List dirs;
-        bool syncAll;
-        lock (_fsLock)
-        {
-            syncAll = _fsSyncAll;
-            _fsSyncAll = false;
-            dirs = _pendingFsDirs.ToList();
-            _pendingFsDirs.Clear();
-            _fsFlushQueued = false;
-        }
-        OnUi(() =>
-        {
-            if (_shutDown) return;
-            if (syncAll) SyncAllRealizedDirs();
-            else foreach (var dir in dirs) SyncRealizedDir(dir);
-            RefreshBranchChip(force: true);   // badges, Changes tab, and status strip follow
-        });
-    }
-
-    /// Re-syncs one directory's children IF that directory is realized in the tree;
-    /// unexpanded directories are skipped (lazy load reads fresh from disk anyway).
-    private void SyncRealizedDir(string dir)
-    {
-        var list = FindRealizedChildList(dir);
-        if (list != null) SyncDirectoryNode(list, dir);
-    }
-
-    private void SyncAllRealizedDirs()
-    {
-        var root = _explorerRoot;
-        if (root == null) return;
-        SyncDirectoryNode(ExplorerTree.RootNodes, root);
-        Walk(ExplorerTree.RootNodes);
-
-        void Walk(IList nodes)
-        {
-            foreach (var n in nodes)
-            {
-                if (n is { HasUnrealizedChildren: false, Content: ExplorerItem { IsDirectory: true } item })
-                {
-                    SyncDirectoryNode(n.Children, item.FullPath);
-                    Walk(n.Children);
-                }
-            }
-        }
-    }
-
-    private IList? FindRealizedChildList(string dir)
-    {
-        var root = _explorerRoot;
-        if (root == null) return null;
-        if (PathsEqual(dir, root)) return ExplorerTree.RootNodes;
-        return Find(ExplorerTree.RootNodes);
-
-        IList? Find(IList nodes)
-        {
-            foreach (var n in nodes)
-            {
-                if (n.Content is ExplorerItem { IsDirectory: true } item && PathsEqual(item.FullPath, dir))
-                    return n.HasUnrealizedChildren ? null : n.Children;
-                if (n.Children.Count > 0)
-                {
-                    var found = Find(n.Children);
-                    if (found != null) return found;
-                }
-            }
-            return null;
-        }
-
-        static bool PathsEqual(string a, string b) => string.Equals(
-            Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b),
-            StringComparison.OrdinalIgnoreCase);
-    }
-
-    /// Minimal diff of a realized directory node against disk: remove rows whose
-    /// path vanished, insert new rows at their sorted position. Never rebuilds surviving
-    /// nodes, so expansion state below them is preserved.
-    private void SyncDirectoryNode(IList children, string dir)
-    {
-        var root = _explorerRoot ?? _controller.ProjectRootPath;
-        string[] dirs, files;
-        try
-        {
-            dirs = Directory.GetDirectories(dir);
-            files = Directory.GetFiles(dir);
-        }
-        catch (Exception) { return; }
-        Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
-        Array.Sort(files, StringComparer.OrdinalIgnoreCase);
-
-        var desired = new List<(string Path, bool IsDir)>(dirs.Length + files.Length);
-        foreach (var d in dirs) desired.Add((d, true));
-        foreach (var f in files) desired.Add((f, false));
-        var desiredSet = new HashSet(desired.Select(x => x.Path), StringComparer.OrdinalIgnoreCase);
-
-        for (var i = children.Count - 1; i >= 0; i--)
-            if (children[i].Content is ExplorerItem it && !desiredSet.Contains(it.FullPath))
-                children.RemoveAt(i);
-
-        var existing = new HashSet(
-            children.Select(n => (n.Content as ExplorerItem)?.FullPath ?? ""),
-            StringComparer.OrdinalIgnoreCase);
-
-        for (var idx = 0; idx < desired.Count; idx++)
-        {
-            var (path, isDir) = desired[idx];
-            if (existing.Contains(path)) continue;
-            var item = isDir ? ExplorerItem.ForFolder(path, root) : ExplorerItem.ForFile(path, root);
-            item.Dirty = IsItemDirty(item);
-            var node = new TreeViewNode { Content = item };
-            if (isDir) node.HasUnrealizedChildren = true;
-            children.Insert(Math.Min(idx, children.Count), node);
-        }
-    }
-
-    /// One directory level, folders first then files, both alphabetical. Unreadable
-    /// or vanished directories render as empty rather than throwing.
-    private List LoadChildNodes(string dir)
-    {
-        var root = _explorerRoot ?? _controller.ProjectRootPath;
-        var nodes = new List();
-        string[] dirs, files;
-        try
-        {
-            dirs = Directory.GetDirectories(dir);
-            files = Directory.GetFiles(dir);
-        }
-        catch (Exception) { return nodes; }
-        Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
-        Array.Sort(files, StringComparer.OrdinalIgnoreCase);
-        foreach (var d in dirs)
-        {
-            var item = ExplorerItem.ForFolder(d, root);
-            item.Dirty = IsItemDirty(item);
-            nodes.Add(new TreeViewNode { Content = item, HasUnrealizedChildren = true });
-        }
-        foreach (var f in files)
-        {
-            var item = ExplorerItem.ForFile(f, root);
-            item.Dirty = IsItemDirty(item);
-            nodes.Add(new TreeViewNode { Content = item });
-        }
-        return nodes;
-    }
-
-    /// The row's @ button — shared by the file tree (TreeViewNode rows) and the
-    /// Changes list (GitChangeItem rows): tags the file/folder in the prompt, identical
-    /// result to dragging the row onto the input box.
-    private void ExplorerTag_Click(object sender, RoutedEventArgs e)
-    {
-        var ctx = (sender as FrameworkElement)?.DataContext;
-        var path = ctx switch
-        {
-            TreeViewNode { Content: ExplorerItem item } => item.FullPath,
-            GitChangeItem change => change.FullPath,
-            _ => null,
-        };
-        if (path != null) InsertFileTokens(new[] { path });
-    }
-
-    private void ChangesList_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
-    {
-        var paths = e.Items.OfType().Select(c => c.FullPath).ToList();
-        if (paths.Count == 0) { e.Cancel = true; return; }
-        e.Data.SetText(string.Join("\n", paths));
-        e.Data.RequestedOperation = DataPackageOperation.Copy;
-    }
-
-    /// The row's ± button: show this file's diff as a transcript DiffCard. An
-    /// explicit button (not row click) so selecting or starting a drag never spawns a card,
-    /// and no click-vs-double-click disambiguation delay is needed.
-    private async void ChangesDiff_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.DataContext is not GitChangeItem item || _shutDown) return;
-
-        var root = _controller.ProjectRootPath;
-        var diff = await Task.Run(() => GitQuickStatus.TryGetDiff(root, item.RelPath, untracked: item.Kind == "U"));
-        if (_shutDown) return;
-
-        if (diff == null)
-            _transcript.Append(_html.Warn($"Couldn't get a diff for {item.RelPath}"));
-        else if (diff.Lines.Count == 0)
-            _transcript.Append(_html.Dim($"{item.RelPath}: {diff.Summary}"));
-        else
-            _transcript.Append(_html.DiffCard(item.RelPath, diff.Lines, diff.Summary, interactive: true));
-    }
-
-    /// Pre-fills the prompt with a commit request — never sends, never commits.
-    /// Caret-aware insert, so tagging files first then clicking Commit… composes naturally
-    /// ("@a.cs @b.cs Commit the current changes…"). The user can edit, then sends; the
-    /// bottom-bar approval gates the actual git command.
-    private void Commit_Click(object sender, RoutedEventArgs e) =>
-        InsertAtCaret("Commit the current changes with an appropriate message");
-
-    private void ChangeUndo_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.DataContext is GitChangeItem item)
-            UndoFileFromCard(item.RelPath);
-    }
-
-    /// Fire-and-forget bridge for non-async call sites (web message handler, row
-    /// button). async void is safe here: ConfirmAndUndoAsync catches nothing fatal — git
-    /// failure is reported to the transcript, not thrown.
-    private async void UndoFileFromCard(string relPath) => await ConfirmAndUndoAsync(relPath);
-
-    /// The one destructive action in the app, so it always confirms first —
-    /// whether it came from a Changes row or a diff card's Undo chip.
-    private async Task ConfirmAndUndoAsync(string relPath)
-    {
-        var dialog = new ContentDialog
-        {
-            Title = "Discard changes?",
-            Content = $"{relPath} will be restored to its state at the last commit. This can't be undone.",
-            PrimaryButtonText = "Discard changes",
-            CloseButtonText = "Cancel",
-            DefaultButton = ContentDialogButton.Close,
-            XamlRoot = XamlRoot,
-        };
-        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
-        var root = _controller.ProjectRootPath;
-        var ok = await Task.Run(() => GitQuickStatus.TryUndoChanges(root, relPath));
-        if (_shutDown) return;
-        _transcript.Append(ok
-            ? _html.Success($"Restored {relPath} to its state at the last commit.")
-            : _html.Warn($"Couldn't restore {relPath} — is it still tracked by git?"));
-        if (ok)
-        {
-            // Tell the model explicitly — discarding its work is feedback, not just a file
-            // event — and re-baseline so the generic delta doesn't report it a second time.
-            _controller.NoteWorkspaceEvent(
-                $"The user DISCARDED all uncommitted changes to {relPath} (restored to the last commit). " +
-                "If you changed that file earlier, those changes are gone by the user's choice — don't re-apply them unless asked.");
-            _wsTracker.MarkCapturePending();
-        }
-        RefreshBranchChip(force: true);
-    }
-
-    private void ChangesList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
-    {
-        if ((e.OriginalSource as FrameworkElement)?.DataContext is not GitChangeItem item) return;
-        if (!File.Exists(item.FullPath)) return;   // deleted entries have nothing to open
-        try
-        {
-            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
-            {
-                FileName = item.FullPath,
-                UseShellExecute = true,
-            });
-        }
-        catch (Exception ex)
-        {
-            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
-        }
-    }
-
-    private void ExplorerTag_PointerEntered(object sender, PointerRoutedEventArgs e)
-        => ((UIElement)sender).Opacity = 1;
-
-    private void ExplorerTag_PointerExited(object sender, PointerRoutedEventArgs e)
-        => ((UIElement)sender).Opacity = 0.45;
-
-    private void ExplorerTree_Expanding(TreeView sender, TreeViewExpandingEventArgs args)
-    {
-        if (!args.Node.HasUnrealizedChildren) return;
-        args.Node.HasUnrealizedChildren = false;
-        if (args.Node.Content is not ExplorerItem item || !item.IsDirectory) return;
-        foreach (var child in LoadChildNodes(item.FullPath)) args.Node.Children.Add(child);
-    }
-
-    private void ExplorerTree_ItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
-    {
-        // Single click: folders toggle, files only select. Opening is double-click territory
-        // (ExplorerTree_DoubleTapped) — a stray single click must never launch an app.
-        if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: true } } node)
-            node.IsExpanded = !node.IsExpanded;
-    }
-
-    private void ExplorerTree_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
-    {
-        // The template's elements inherit the row's TreeViewNode as DataContext.
-        if ((e.OriginalSource as FrameworkElement)?.DataContext is not TreeViewNode node ||
-            node.Content is not ExplorerItem { IsDirectory: false } item)
-            return;
-        try
-        {
-            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
-            {
-                FileName = item.FullPath,
-                UseShellExecute = true,
-            });
-        }
-        catch (Exception ex)
-        {
-            _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}"));
-        }
-    }
-
-    // ============================================================
-    // Drag & drop @-references
-    // ============================================================
-
-    /// Dragging explorer rows carries their full paths as text — the input box's
-    /// Drop handler recognizes existing paths and converts them to @tokens.
-    private void ExplorerTree_DragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
-    {
-        var paths = args.Items.OfType()
-            .Select(n => n.Content).OfType()
-            .Select(i => i.FullPath).ToList();
-        if (paths.Count == 0) { args.Cancel = true; return; }
-        args.Data.SetText(string.Join("\n", paths));
-        args.Data.RequestedOperation = DataPackageOperation.Copy;
-    }
-
-    private void InputBox_DragOver(object sender, DragEventArgs e)
-    {
-        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
-            e.DataView.Contains(StandardDataFormats.Text))
-        {
-            e.AcceptedOperation = DataPackageOperation.Copy;
-            e.Handled = true;
-        }
-    }
-
-    // --- drop-to-tag overlay choreography ---
-    // Show when a drag enters the tab: over XAML chrome that's ChatRoot's DragEnter; over the
-    // WebView it's the transcript script's 'drag-enter' message (Chromium owns drags there).
-    // Hide when the drag leaves the overlay/tab or when any drop completes. Moving between
-    // those regions can flicker the overlay off/on for a frame — harmless.
-
-    private void ShowDropOverlay() => DropOverlay.Visibility = Visibility.Visible;
-    private void HideDropOverlay() => DropOverlay.Visibility = Visibility.Collapsed;
-
-    private void ChatRoot_DragEnter(object sender, DragEventArgs e)
-    {
-        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
-            e.DataView.Contains(StandardDataFormats.Text))
-            ShowDropOverlay();
-    }
-
-    private void ChatRoot_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
-    private void DropOverlay_DragLeave(object sender, DragEventArgs e) => HideDropOverlay();
-
-    private void DropOverlay_DragOver(object sender, DragEventArgs e)
-    {
-        if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
-            e.DataView.Contains(StandardDataFormats.Text))
-        {
-            e.AcceptedOperation = DataPackageOperation.Copy;
-            e.Handled = true;
-        }
-    }
-
-    private async void DropOverlay_Drop(object sender, DragEventArgs e)
-    {
-        HideDropOverlay();
-        await HandleDropAsync(e);
-    }
-
-    private async void InputBox_Drop(object sender, DragEventArgs e)
-    {
-        HideDropOverlay();
-        await HandleDropAsync(e);
-    }
-
-    /// Shared drop handling for the input box and the drop-to-tag overlay: paths
-    /// become @tokens, ordinary text inserts as text.
-    private async Task HandleDropAsync(DragEventArgs e)
-    {
-        e.Handled = true;
-        var deferral = e.GetDeferral();
-        try
-        {
-            if (e.DataView.Contains(StandardDataFormats.StorageItems))
-            {
-                // Shell drop (Windows Explorer): real files/folders with paths.
-                var items = await e.DataView.GetStorageItemsAsync();
-                InsertFileTokens(items.Select(i => i.Path).Where(p => !string.IsNullOrEmpty(p)));
-            }
-            else if (e.DataView.Contains(StandardDataFormats.Text))
-            {
-                // Text drop: explorer-tree rows arrive as newline-joined full paths. If every
-                // line is an existing path, tokenize; otherwise it's ordinary dragged text.
-                var text = await e.DataView.GetTextAsync();
-                var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
-                if (lines.Length > 0 && lines.All(l => File.Exists(l) || Directory.Exists(l)))
-                    InsertFileTokens(lines);
-                else
-                    InsertAtCaret(text);
-            }
-        }
-        catch (Exception ex)
-        {
-            _transcript.Append(_html.Warn($"Couldn't read the dropped item: {ex.Message}"));
-        }
-        finally
-        {
-            deferral.Complete();
-        }
-    }
-
-    /// Converts full paths into the same @tokens the autocomplete inserts: project-root
-    /// relative, forward slashes, trailing '/' for folders. Items outside this tab's project
-    /// root can't be resolved by the @ pipeline, so they're skipped with a warning.
-    private void InsertFileTokens(IEnumerable fullPaths)
-    {
-        var root = _controller.ProjectRootPath;
-        var rootPrefix = Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar;
-        var tokens = new List();
-        var outside = new List();
-
-        foreach (var raw in fullPaths)
-        {
-            string full;
-            try { full = Path.GetFullPath(raw); }
-            catch { continue; }
-            if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
-            {
-                outside.Add(full);
-                continue;
-            }
-            var rel = Path.GetRelativePath(root, full).Replace('\\', '/');
-            tokens.Add("@" + rel + (Directory.Exists(full) ? "/" : ""));
-        }
-
-        if (tokens.Count > 0)
-            InsertAtCaret(string.Join(" ", tokens) + " ");
-        if (outside.Count > 0)
-            _transcript.Append(_html.Warn(
-                $"Skipped {outside.Count} dropped item{(outside.Count == 1 ? "" : "s")} outside this tab's project folder — @ references only work under {root}"));
-    }
-
-    /// Inserts at the caret with token-safe spacing: a separating space is added when
-    /// the caret touches non-whitespace, so a dropped @token never glues onto existing text.
-    private void InsertAtCaret(string insert)
-    {
-        var text = InputBox.Text;
-        var caret = Math.Clamp(InputBox.SelectionStart, 0, text.Length);
-        if (caret > 0 && !char.IsWhiteSpace(text[caret - 1])) insert = " " + insert;
-        InputBox.Text = text[..caret] + insert + text[caret..];
-        InputBox.SelectionStart = caret + insert.Length;
-        InputBox.Focus(FocusState.Programmatic);
-    }
-
-    // ============================================================
-    // Input handling
-    // ============================================================
-
-    private void SendButton_Click(object sender, RoutedEventArgs e)
-    {
-        if (_controller.IsProcessing)
-        {
-            _controller.CancelActiveRequest();
-            return;
-        }
-        SubmitCurrentInput();
-    }
-
-    private void SubmitCurrentInput()
-    {
-        var text = InputBox.Text;
-        if (string.IsNullOrWhiteSpace(text) || _controller.IsProcessing) return;
-
-        EmitWorkspaceDelta();   // queue outside-the-conversation changes before this send
-        InputBox.Text = "";
-        HideSuggestions();
-        UpdateHeader();
-
-        _ = Task.Run(async () =>
-        {
-            try
-            {
-                await _controller.SubmitAsync(text);
-            }
-            catch (Exception ex)
-            {
-                _transcript.Append(_html.Error($"Unexpected error: {ex.Message}"));
-            }
-        });
-    }
-
-    // PreviewKeyDown, NOT KeyDown: the TextBox's own class handler runs before instance
-    // KeyDown handlers, so with AcceptsReturn=true an Enter had already inserted a newline
-    // — which made TextChanged hide the suggestions popup, and the handler then fell
-    // through to submit. Preview (tunneling) fires first, so Handled=true genuinely
-    // suppresses the newline and Enter-to-accept behaves exactly like a mouse click.
-    private void InputBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
-    {
-        if (e.Key == VirtualKey.Enter)
-        {
-            var shift = Microsoft.UI.Input.InputKeyboardSource
-                .GetKeyStateForCurrentThread(VirtualKey.Shift)
-                .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
-            if (!shift)
-            {
-                e.Handled = true;
-
-                // If suggestions are open, Enter accepts (falling back to the first row —
-                // never submit the half-typed token as a message).
-                if (SuggestionsPanel.Visibility == Visibility.Visible)
-                {
-                    var pick = SuggestionsList.SelectedItem as CommandSuggestion ?? _suggestions.FirstOrDefault();
-                    if (pick != null)
-                    {
-                        AcceptSuggestion(pick);
-                        return;
-                    }
-                }
-                SubmitCurrentInput();
-            }
-        }
-        else if (e.Key == VirtualKey.Tab && SuggestionsPanel.Visibility == Visibility.Visible)
-        {
-            var pick = (SuggestionsList.SelectedItem ?? _suggestions.FirstOrDefault()) as CommandSuggestion;
-            if (pick != null)
-            {
-                e.Handled = true;
-                AcceptSuggestion(pick);
-            }
-        }
-        else if (e.Key == VirtualKey.Down && SuggestionsPanel.Visibility == Visibility.Visible)
-        {
-            e.Handled = true;
-            SuggestionsList.SelectedIndex = Math.Min(SuggestionsList.SelectedIndex + 1, _suggestions.Count - 1);
-            SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
-        }
-        else if (e.Key == VirtualKey.Up && SuggestionsPanel.Visibility == Visibility.Visible)
-        {
-            e.Handled = true;
-            SuggestionsList.SelectedIndex = Math.Max(SuggestionsList.SelectedIndex - 1, 0);
-            SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
-        }
-        else if (e.Key == VirtualKey.Escape)
-        {
-            if (SuggestionsPanel.Visibility == Visibility.Visible) HideSuggestions();
-            else _controller.CancelActiveRequest();
-        }
-    }
-
-    private void InputBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSuggestions();
-
-    private void UpdateSuggestions()
-    {
-        var text = InputBox.Text;
-        var caret = InputBox.SelectionStart;
-
-        // Slash commands: input starts with '/' and is still a single token.
-        if (text.StartsWith('/') && !text.Contains(' '))
-        {
-            var matches = _controller.GetCommandSuggestions(text);
-            if (ShowSuggestions(SuggestMode.Command, 0, caret,
-                    matches.Select(m => new CommandSuggestion { Command = m.Command, Description = m.Description })))
-                return;
-        }
-
-        // @file references: find the token containing the caret; if it starts with '@',
-        // filter project files/directories through the same provider the CLI uses
-        // (directories come back with a trailing '/' — selecting one drills into it).
-        var tokenStart = caret;
-        while (tokenStart > 0 && !char.IsWhiteSpace(text[tokenStart - 1]))
-            tokenStart--;
-
-        if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == '@')
-        {
-            var fragment = text[(tokenStart + 1)..caret];
-            List matches;
-            try { matches = _fileProvider.FilterFiles(fragment); }
-            catch { matches = new List(); }
-
-            if (ShowSuggestions(SuggestMode.File, tokenStart, caret,
-                    matches.Select(m => new CommandSuggestion
-                    {
-                        Command = m,
-                        Description = m.EndsWith('/') ? "folder — select to drill in" : "file"
-                    })))
-                return;
-        }
-
-        // :emoji: shortcodes (Slack-style). Two behaviors on the token containing the caret:
-        //  - ":name:" fully typed with an exact match → replace it with the emoji right here.
-        //  - ":fra" partially typed (2+ chars, no closing ':') → suggest matching shortcodes.
-        // The 2-char minimum keeps ordinary colons (":)", "note:") from popping the list.
-        if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == ':')
-        {
-            var body = text[(tokenStart + 1)..caret];
-            if (body.Length > 1 && body.EndsWith(':'))
-            {
-                var name = body[..^1].ToLowerInvariant();
-                var exact = EmojiShortcodes.FirstOrDefault(s => s.Name == name).Emoji;
-                if (exact != null)
-                {
-                    InputBox.Text = text[..tokenStart] + exact + text[caret..];
-                    InputBox.SelectionStart = tokenStart + exact.Length;
-                    HideSuggestions();
-                    return;
-                }
-            }
-            else if (body.Length >= 2 && !body.Contains(':'))
-            {
-                var frag = body.ToLowerInvariant();
-                var matches = EmojiShortcodes.Where(s => s.Name.StartsWith(frag))
-                    .Concat(EmojiShortcodes.Where(s => !s.Name.StartsWith(frag) && s.Name.Contains(frag)));
-
-                if (ShowSuggestions(SuggestMode.Emoji, tokenStart, caret,
-                        matches.Select(m => new CommandSuggestion
-                        {
-                            Command = ":" + m.Name + ":",
-                            Description = m.Emoji,
-                            InsertText = m.Emoji,
-                        })))
-                    return;
-            }
-        }
-
-        HideSuggestions();
-    }
-
-    private bool ShowSuggestions(SuggestMode mode, int tokenStart, int tokenEnd, IEnumerable items)
-    {
-        _suggestions.Clear();
-        foreach (var item in items) _suggestions.Add(item);
-        if (_suggestions.Count == 0) return false;
-
-        _suggestMode = mode;
-        _tokenStart = tokenStart;
-        _tokenEnd = tokenEnd;
-        SuggestionsPanel.Visibility = Visibility.Visible;
-        SuggestionsList.SelectedIndex = 0;
-        SuggestionsList.ScrollIntoView(SuggestionsList.SelectedItem);
-        return true;
-    }
-
-    private void SuggestionsList_ItemClick(object sender, ItemClickEventArgs e)
-    {
-        if (e.ClickedItem is CommandSuggestion s) AcceptSuggestion(s);
-    }
-
-    private void AcceptSuggestion(CommandSuggestion s)
-    {
-        if (_suggestMode == SuggestMode.File)
-        {
-            var text = InputBox.Text;
-            var start = Math.Min(_tokenStart, text.Length);
-            var end = Math.Min(_tokenEnd, text.Length);
-
-            // Replace the @token with the picked path. Directories keep the caret hot
-            // (no trailing space) so the reopened popup shows their contents; files
-            // close the token with a space.
-            var isFolder = s.Command.EndsWith('/');
-            var replacement = "@" + s.Command + (isFolder ? "" : " ");
-            InputBox.Text = text[..start] + replacement + text[end..];
-            InputBox.SelectionStart = start + replacement.Length;
-
-            // Setting .Text resets the caret to 0 BEFORE the line above restores it, and
-            // TextChanged runs in that window — it sees no token at caret 0 and hides the
-            // popup. Recompute now that the caret is where the user expects it:
-            // folder → drilled listing reopens; file → token ended with a space, stays hidden.
-            UpdateSuggestions();
-        }
-        else if (_suggestMode == SuggestMode.Emoji)
-        {
-            var text = InputBox.Text;
-            var start = Math.Min(_tokenStart, text.Length);
-            var end = Math.Min(_tokenEnd, text.Length);
-            var emoji = s.InsertText ?? s.Command;
-            InputBox.Text = text[..start] + emoji + text[end..];
-            InputBox.SelectionStart = start + emoji.Length;
-            HideSuggestions();
-        }
-        else
-        {
-            InputBox.Text = s.Command + " ";
-            InputBox.SelectionStart = InputBox.Text.Length;
-            HideSuggestions();
-        }
-        InputBox.Focus(FocusState.Programmatic);
-    }
-
-    /// Curated quick-pick set for the emoji flyout; Win + . remains the full picker.
-    private static readonly string[] QuickEmojis =
-    {
-        "😀", "😄", "😂", "🤣", "😊", "😉", "😍", "🥰", "😎", "🤓", "🤔", "🙃",
-        "😅", "😬", "😭", "🥳", "🤯", "😴", "🙄", "😤", "😱", "🫠", "🤗", "🫡",
-        "👍", "👎", "👌", "🙏", "👏", "💪", "🤝", "✌️", "🤞", "👀", "🧠", "💯",
-        "🔥", "✨", "🚀", "🎉", "🎯", "💡", "⚡", "⭐", "❤️", "💔", "✅", "❌",
-        "⚠️", "❓", "❗", "💬", "🐛", "🔧", "🔒", "🔑", "📝", "📌", "📁", "🖥️",
-        "☕", "🍕", "🎮", "🤖",
-    };
-
-    /// Slack-style shortcode → emoji. Aliases are separate rows pointing at the same
-    /// emoji. Names must be lowercase; lookup lowercases the typed fragment.
-    private static readonly (string Name, string Emoji)[] EmojiShortcodes =
-    {
-        ("grinning", "😀"), ("smile", "😄"), ("joy", "😂"), ("rofl", "🤣"),
-        ("blush", "😊"), ("wink", "😉"), ("heart_eyes", "😍"), ("smiling_hearts", "🥰"),
-        ("sunglasses", "😎"), ("coolglasses", "😎"), ("nerd", "🤓"), ("thinking", "🤔"),
-        ("upside_down", "🙃"), ("sweat_smile", "😅"), ("grimacing", "😬"), ("sob", "😭"),
-        ("partying", "🥳"), ("mind_blown", "🤯"), ("sleeping", "😴"), ("eye_roll", "🙄"),
-        ("triumph", "😤"), ("scream", "😱"), ("melting", "🫠"), ("hugs", "🤗"),
-        ("salute", "🫡"), ("thumbsup", "👍"), ("+1", "👍"), ("thumbsdown", "👎"),
-        ("-1", "👎"), ("ok_hand", "👌"), ("pray", "🙏"), ("clap", "👏"),
-        ("muscle", "💪"), ("handshake", "🤝"), ("victory", "✌️"), ("crossed_fingers", "🤞"),
-        ("eyes", "👀"), ("brain", "🧠"), ("100", "💯"), ("fire", "🔥"),
-        ("sparkles", "✨"), ("rocket", "🚀"), ("tada", "🎉"), ("party_popper", "🎉"),
-        ("dart", "🎯"), ("bulb", "💡"), ("idea", "💡"), ("zap", "⚡"),
-        ("star", "⭐"), ("heart", "❤️"), ("broken_heart", "💔"), ("check", "✅"),
-        ("white_check_mark", "✅"), ("x", "❌"), ("cross", "❌"), ("warning", "⚠️"),
-        ("question", "❓"), ("exclamation", "❗"), ("speech_balloon", "💬"), ("bug", "🐛"),
-        ("wrench", "🔧"), ("lock", "🔒"), ("key", "🔑"), ("memo", "📝"),
-        ("note", "📝"), ("pushpin", "📌"), ("pin", "📌"), ("folder", "📁"),
-        ("desktop", "🖥️"), ("coffee", "☕"), ("pizza", "🍕"), ("video_game", "🎮"),
-        ("robot", "🤖"),
-    };
-
-    private void EmojiGrid_ItemClick(object sender, ItemClickEventArgs e)
-    {
-        if (e.ClickedItem is not string emoji || !InputBox.IsEnabled) return;
-        var caret = Math.Min(InputBox.SelectionStart, InputBox.Text.Length);
-        InputBox.Text = InputBox.Text.Insert(caret, emoji);
-        InputBox.SelectionStart = caret + emoji.Length;
-        InputBox.Focus(FocusState.Programmatic);
-    }
-
-    private void HideSuggestions()
-    {
-        _suggestMode = SuggestMode.None;
-        SuggestionsPanel.Visibility = Visibility.Collapsed;
-        _suggestions.Clear();
-    }
-
-    // ============================================================
-    // IApprovalUi — this tab's approval overlay (completes the harness's awaited TCS)
-    // ============================================================
-
-    public Task ShowApprovalAsync(ApprovalRequest request, CancellationToken ct = default)
-    {
-        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        _approvalTcs = tcs;
-
-        var reg = ct.CanBeCanceled
-            ? ct.Register(() =>
-            {
-                tcs.TrySetCanceled(ct);
-                OnUi(() => { HideApprovalOverlay(); HidePlanApprovalBar(); });
-            })
-            : default(CancellationTokenRegistration);
-
-        OnUi(() =>
-        {
-            // What the cross-tab toast will say — a specific "what's waiting" line, not the modal's
-            // question. Set for both the bottom-bar and modal paths.
-            _approvalSummary = string.IsNullOrEmpty(request.ToastSummary) ? request.Title : request.ToastSummary;
-
-            // Plan approvals render as a non-covering bottom bar so the plan card stays readable.
-            if (request.BottomBar)
-            {
-                ShowPlanApprovalBar(request, choice =>
-                {
-                    HidePlanApprovalBar();
-                    reg.Dispose();
-                    tcs.TrySetResult(choice);
-                    if (ReferenceEquals(_approvalTcs, tcs)) _approvalTcs = null;
-                });
-                return;
-            }
-
-            ApprovalTitle.Text = request.Title;
-
-            ApprovalSubtitle.Text = request.Subtitle ?? "";
-            ApprovalSubtitle.Visibility = string.IsNullOrEmpty(request.Subtitle) ? Visibility.Collapsed : Visibility.Visible;
-
-            ApprovalDetail.Text = request.Detail ?? "";
-            ApprovalDetail.Visibility = string.IsNullOrEmpty(request.Detail) ? Visibility.Collapsed : Visibility.Visible;
-
-            // Pull the shared, theme-mutated brushes from app resources so the approval diff
-            // follows the active theme (these used to be hardcoded LightSkyBlue/red/gray, which
-            // stayed blue under every theme — jarring under E-Ink). Mirrors the transcript's
-            // diff coloring: command/added -> sky, removed -> red, context -> dim.
-            var skyBrush = (SolidColorBrush)Application.Current.Resources["MandoSkyBrush"];
-            var redBrush = (SolidColorBrush)Application.Current.Resources["MandoRedBrush"];
-            var dimBrush = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
-
-            var rows = new List();
-            if (request.CommandText != null)
-            {
-                rows.Add(new DiffLineVm
-                {
-                    Text = $"$ {request.CommandText}",
-                    Brush = skyBrush
-                });
-            }
-            if (request.DiffLines != null)
-            {
-                foreach (var line in request.DiffLines)
-                {
-                    var (prefix, brush) = line.LineType switch
-                    {
-                        DiffLineType.Added => ("+ ", skyBrush),
-                        DiffLineType.Removed => ("- ", redBrush),
-                        _ => ("  ", dimBrush)
-                    };
-                    var num = (line.LineType == DiffLineType.Added ? line.NewLineNumber : line.OldLineNumber);
-                    rows.Add(new DiffLineVm
-                    {
-                        Text = $"{(num.HasValue ? num.Value.ToString().PadLeft(4) : "    ")} {prefix}{line.Content}",
-                        Brush = brush
-                    });
-                }
-                if (request.DiffSummary != null)
-                    rows.Add(new DiffLineVm { Text = "", Brush = dimBrush });
-            }
-            ApprovalDiffList.ItemsSource = rows;
-            ApprovalBodyScroll.Visibility = rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
-
-            if (!string.IsNullOrEmpty(request.DiffSummary))
-            {
-                ApprovalDetail.Text = request.DiffSummary;
-                ApprovalDetail.Visibility = Visibility.Visible;
-            }
-
-            ApprovalButtons.Children.Clear();
-            foreach (var option in request.Options)
-            {
-                var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
-                if (!string.IsNullOrEmpty(option.Glyph))
-                    content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
-                content.Children.Add(new TextBlock { Text = option.Label });
-                var button = new Button
-                {
-                    Content = content,
-                    HorizontalAlignment = HorizontalAlignment.Stretch,
-                    HorizontalContentAlignment = HorizontalAlignment.Left,
-                    Tag = option.Label
-                };
-                button.Foreground = option.Kind switch
-                {
-                    ApprovalOptionKind.Proceed => (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"],
-                    ApprovalOptionKind.Destructive => (SolidColorBrush)Application.Current.Resources["MandoRedBrush"],
-                    _ => (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
-                };
-                if (!string.IsNullOrEmpty(option.Description))
-                    ToolTipService.SetToolTip(button, option.Description);
-                button.Click += (_, _) =>
-                {
-                    var choice = (string)button.Tag;
-                    HideApprovalOverlay();
-                    reg.Dispose();
-                    _approvalTcs?.TrySetResult(choice);
-                    _approvalTcs = null;
-                };
-                ApprovalButtons.Children.Add(button);
-            }
-
-            InstructionPanel.Visibility = Visibility.Collapsed;
-            ApprovalButtons.Visibility = Visibility.Visible;
-            SetApprovalCardSize(instructionMode: false);
-            ShowApprovalOverlay();
-        });
-
-        return tcs.Task;
-    }
-
-    /// Approval mode: compact centered card. Instruction mode: full width and
-    /// half the window height, centered — room to write real instructions.
-    private void SetApprovalCardSize(bool instructionMode)
-    {
-        if (instructionMode)
-        {
-            ApprovalCard.HorizontalAlignment = HorizontalAlignment.Stretch;
-            ApprovalCard.MaxWidth = double.PositiveInfinity;
-            ApprovalCard.MaxHeight = double.PositiveInfinity;
-            ApprovalCard.Height = Math.Max(320, ChatRoot.ActualHeight * 0.5);
-        }
-        else
-        {
-            ApprovalCard.HorizontalAlignment = HorizontalAlignment.Center;
-            ApprovalCard.MaxWidth = 860;
-            ApprovalCard.MaxHeight = 640;
-            ApprovalCard.Height = double.NaN;
-        }
-    }
-
-    public Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default)
-    {
-        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        _instructionTcs = tcs;
-
-        OnUi(() =>
-        {
-            // First line is the question; any extra lines (e.g. a validation error on
-            // re-prompt) render below it in the smaller prompt text.
-            var newline = prompt.IndexOf('\n');
-            ApprovalTitle.Text = newline < 0 ? prompt : prompt[..newline];
-            InstructionPrompt.Text = newline < 0 ? "" : prompt[(newline + 1)..].Trim();
-            InstructionPrompt.Visibility = InstructionPrompt.Text.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
-
-            ApprovalSubtitle.Visibility = Visibility.Collapsed;
-            ApprovalDetail.Visibility = Visibility.Collapsed;
-            ApprovalBodyScroll.Visibility = Visibility.Collapsed;
-            ApprovalButtons.Visibility = Visibility.Collapsed;
-
-            InstructionBox.Text = "";
-            InstructionBox.PlaceholderText = string.IsNullOrEmpty(placeholder)
-                ? "Type your answer and press Enter"
-                : placeholder;
-            InstructionCancelButton.Visibility = allowCancel ? Visibility.Visible : Visibility.Collapsed;
-            InstructionPanel.Visibility = Visibility.Visible;
-            SetApprovalCardSize(instructionMode: true);
-            ShowApprovalOverlay();
-            InstructionBox.Focus(FocusState.Programmatic);
-        });
-
-        return tcs.Task;
-    }
-
-    private void InstructionBox_KeyDown(object sender, KeyRoutedEventArgs e)
-    {
-        if (e.Key == VirtualKey.Enter)
-        {
-            // Shift+Enter inserts a newline (the box is multi-line); plain Enter submits.
-            // PreviewKeyDown is required here — with AcceptsReturn, the class handler
-            // would insert the newline before a plain KeyDown handler ever ran.
-            var shift = Microsoft.UI.Input.InputKeyboardSource
-                .GetKeyStateForCurrentThread(VirtualKey.Shift)
-                .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
-            if (shift) return;
-            e.Handled = true;
-            SubmitInstruction();
-        }
-        else if (e.Key == VirtualKey.Escape && InstructionCancelButton.Visibility == Visibility.Visible)
-        {
-            e.Handled = true;
-            CancelInstruction();
-        }
-    }
-
-    private void InstructionSubmit_Click(object sender, RoutedEventArgs e) => SubmitInstruction();
-
-    private void InstructionCancel_Click(object sender, RoutedEventArgs e) => CancelInstruction();
-
-    private void SubmitInstruction()
-    {
-        var text = InstructionBox.Text;
-        HideApprovalOverlay();
-        _instructionTcs?.TrySetResult(text);
-        _instructionTcs = null;
-    }
-
-    private void CancelInstruction()
-    {
-        HideApprovalOverlay();
-        _instructionTcs?.TrySetResult(ApprovalSignals.Cancelled);
-        _instructionTcs = null;
-    }
-
-    /// An approval raised in a background tab can't steal focus, so MainWindow badges
-    /// that tab and raises the toast instead.
-    private void ShowApprovalOverlay()
-    {
-        ApprovalOverlay.Visibility = Visibility.Visible;
-        ApprovalStateChanged?.Invoke(this);
-    }
-
-    private void HideApprovalOverlay()
-    {
-        ApprovalOverlay.Visibility = Visibility.Collapsed;
-        ApprovalDiffList.ItemsSource = null;
-        ApprovalStateChanged?.Invoke(this);
-        InputBox.Focus(FocusState.Programmatic);
-    }
-
-    /// Slides the plan-approval bar up above the input. Unlike the modal it doesn't cover the
-    /// transcript (the plan stays readable), but it DOES gate input — the turn is awaiting the choice.
-    private void ShowPlanApprovalBar(ApprovalRequest request, Action onChosen)
-    {
-        // Windows 98 theme: the bar drops its rounded card look and reads as a silver
-        // dialog strip — square corners, dialog-face background. Rebuilt on every show,
-        // so live theme switches take effect on the next approval.
-        var win98 = ThemeManager.Current.Win98;
-        PlanApprovalBar.CornerRadius = new CornerRadius(win98 ? 0 : 12);
-        PlanApprovalBar.Background = (Brush)Application.Current.Resources[
-            win98 ? "MandoBackgroundBrush" : "MandoPanelBrush"];
-
-        PlanApprovalTitle.Text = request.Title;
-
-        // Command approvals ride this bar too: show the command in monospace. The buttons
-        // live in a WrapPanel — one horizontal row whenever it fits, wrapping only when the
-        // window is too narrow for the long "don't ask again" labels.
-        PlanApprovalCommand.Text = string.IsNullOrEmpty(request.CommandText) ? "" : "$ " + request.CommandText;
-        PlanApprovalCommand.Visibility = string.IsNullOrEmpty(request.CommandText)
-            ? Visibility.Collapsed : Visibility.Visible;
-
-        PlanApprovalButtons.Children.Clear();
-        foreach (var option in request.Options)
-        {
-            var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
-            if (!string.IsNullOrEmpty(option.Glyph))
-                content.Children.Add(new FontIcon { Glyph = option.Glyph, FontSize = 13 });
-            content.Children.Add(new TextBlock { Text = option.Label });
-
-            var button = new Button { Content = content, Tag = option.Label, Padding = new Thickness(14, 6, 14, 6) };
-            if (win98) button.CornerRadius = new CornerRadius(0);   // square, like every 98 control
-            if (option.Kind == ApprovalOptionKind.Proceed)
-                button.Style = (Style)Application.Current.Resources["AccentButtonStyle"];   // primary
-            else
-                button.Foreground = option.Kind == ApprovalOptionKind.Destructive
-                    ? (SolidColorBrush)Application.Current.Resources["MandoRedBrush"]
-                    : (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
-            if (!string.IsNullOrEmpty(option.Description))
-                ToolTipService.SetToolTip(button, option.Description);
-            button.Click += (_, _) => onChosen((string)button.Tag);
-            PlanApprovalButtons.Children.Add(button);
-        }
-
-        // Gate input while the plan is awaiting a decision.
-        InputBox.IsEnabled = false;
-        SendButton.IsEnabled = false;
-        EmojiButton.IsEnabled = false;
-
-        PlanApprovalBar.Visibility = Visibility.Visible;
-        ApprovalStateChanged?.Invoke(this);
-
-        var slide = new DoubleAnimation
-        {
-            From = 24, To = 0,
-            Duration = new Duration(TimeSpan.FromMilliseconds(220)),
-            EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
-        };
-        Storyboard.SetTarget(slide, PlanApprovalTransform);
-        Storyboard.SetTargetProperty(slide, "Y");
-        var fade = new DoubleAnimation
-        {
-            From = 0, To = 1,
-            Duration = new Duration(TimeSpan.FromMilliseconds(180)),
-        };
-        Storyboard.SetTarget(fade, PlanApprovalBar);
-        Storyboard.SetTargetProperty(fade, "Opacity");
-        var sb = new Storyboard();
-        sb.Children.Add(slide);
-        sb.Children.Add(fade);
-        sb.Begin();
-    }
-
-    private void HidePlanApprovalBar()
-    {
-        if (PlanApprovalBar.Visibility != Visibility.Visible) return;
-        PlanApprovalBar.Visibility = Visibility.Collapsed;
-        InputBox.IsEnabled = true;
-        SendButton.IsEnabled = true;
-        EmojiButton.IsEnabled = true;
-        ApprovalStateChanged?.Invoke(this);
-        InputBox.Focus(FocusState.Programmatic);
-    }
-}
-
-/// One row in the header's model dropdown: the model tag plus a cloud/local badge.
-/// Built on the UI thread when the flyout opens, so it can carry ready-made brushes.
-public sealed class ModelItem
-{
-    public ModelItem(string name, string badge, Brush badgeForeground, Brush badgeBackground)
-    {
-        Name = name;
-        Badge = badge;
-        BadgeForeground = badgeForeground;
-        BadgeBackground = badgeBackground;
-    }
-
-    public string Name { get; }
-    public string Badge { get; }
-    public Brush BadgeForeground { get; }
-    public Brush BadgeBackground { get; }
-}
-
-/// One row in the file-explorer tree. Folder nodes are created with unrealized
-/// children and lazy-load their contents on first expand (ChatTabView.ExplorerTree_Expanding).
-public sealed class ExplorerItem : System.ComponentModel.INotifyPropertyChanged
-{
-    public string Name { get; private init; } = "";
-    public string FullPath { get; private init; } = "";
-    public bool IsDirectory { get; private init; }
-
-    /// Root-relative path with forward slashes \u2014 the key used to match this row
-    /// against git change entries.
-    public string RelPath { get; private init; } = "";
-
-    /// The exact @token the row produces (root-relative, forward slashes, trailing
-    /// '/' on folders) \u2014 shown in the tag button's tooltip so hovering teaches the @ syntax.
-    public string Token { get; private init; } = "";
-
-    public string TagTooltip => $"Tag in prompt \u2014 inserts {Token}";
-
-    /// Files: this file has uncommitted changes. Folders: something inside does.
-    /// Mutable + observable so rows already realized in the tree light up in place when a
-    /// git refresh lands (rebuilding the tree would lose expansion state).
-    public bool Dirty
-    {
-        get => _dirty;
-        set
-        {
-            if (_dirty == value) return;
-            _dirty = value;
-            PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(DirtyVisibility)));
-        }
-    }
-    private bool _dirty;
-
-    public Visibility DirtyVisibility => _dirty ? Visibility.Visible : Visibility.Collapsed;
-
-    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
-
-    public string Glyph => IsDirectory ? "\uE8B7" : "\uE8A5";   // folder / document
-
-    /// Resolved per-realization from app resources, so icons pick up live theme
-    /// switches the next time rows are created (matching how transcript colors retheme).
-    public Brush? IconBrush =>
-        Application.Current.Resources[IsDirectory ? "MandoGoldBrush" : "MandoDimBrush"] as Brush;
-
-    public static ExplorerItem ForFolder(string path, string root)
-    {
-        var rel = Rel(path, root);
-        return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = true, RelPath = rel, Token = "@" + rel + "/" };
-    }
-
-    public static ExplorerItem ForFile(string path, string root)
-    {
-        var rel = Rel(path, root);
-        return new() { Name = Path.GetFileName(path), FullPath = path, IsDirectory = false, RelPath = rel, Token = "@" + rel };
-    }
-
-    private static string Rel(string path, string root) =>
-        Path.GetRelativePath(root, path).Replace('\\', '/');
-}
-
-/// One row in the explorer's Changes tab: a working-tree change with its display
-/// letter/color, split name + directory, and the @token its tag button inserts. Built on
-/// the UI thread from a GitQuickStatus snapshot, so it carries ready-made brushes
-/// (same pattern as ModelItem).
-public sealed class GitChangeItem
-{
-    public string Kind { get; init; } = "";
-    public string KindLabel { get; init; } = "";
-    public Brush? KindBrush { get; init; }
-    public string Name { get; init; } = "";
-    public string Dir { get; init; } = "";
-    public string FullPath { get; init; } = "";
-    public string RelPath { get; init; } = "";
-    public string TagTooltip { get; init; } = "";
-
-    /// Undo restores from HEAD, so it needs a HEAD side: hidden for untracked rows
-    /// ("undoing" a new file would DELETE it — different action, different UI) and renamed
-    /// rows (a clean rename-undo needs both paths).
-    public Visibility UndoVisibility => Kind is "M" or "D" or "!" ? Visibility.Visible : Visibility.Collapsed;
-
-    public string UndoTooltip => Kind == "D"
-        ? "Restore this deleted file"
-        : "Undo changes — restore this file to the last commit (asks first)";
 }
diff --git a/src/MandoCode.Desktop/MainWindow.Appearance.cs b/src/MandoCode.Desktop/MainWindow.Appearance.cs
new file mode 100644
index 0000000..6dffafe
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Appearance.cs
@@ -0,0 +1,229 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Chat background image (Appearance page)
+    // ============================================================
+
+    private async void BgChoose_Click(object sender, RoutedEventArgs e)
+    {
+        var picker = new Windows.Storage.Pickers.FileOpenPicker();
+        // Desktop apps must marry the picker to an HWND before use.
+        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+        foreach (var ext in new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" })
+            picker.FileTypeFilter.Add(ext);
+
+        var file = await picker.PickSingleFileAsync();
+        if (file == null) return;
+
+        ThemeManager.SetChatBackground(file.Path);
+        UpdateBgControls();
+        ApplyThemeToAllTabs();
+    }
+
+    private void BgClear_Click(object sender, RoutedEventArgs e)
+    {
+        ThemeManager.SetChatBackground(null);
+        UpdateBgControls();
+        ApplyThemeToAllTabs();
+    }
+
+    private void BoxedMessages_Toggled(object sender, RoutedEventArgs e)
+    {
+        if (!_appearanceReady) return;   // see _appearanceReady — a Save() here wipes settings
+        ThemeManager.SetBoxedMessages(BoxedMessagesToggle.IsOn);
+        ApplyThemeToAllTabs();   // live — existing messages re-skin instantly
+    }
+
+    private void BgOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+    {
+        if (!_appearanceReady) return;   // see _appearanceReady — a Save() here wipes settings
+        S_BgOpacityLabel.Text = $"{(int)e.NewValue}%";
+        ThemeManager.SetChatBackgroundOpacity(e.NewValue / 100.0);
+        ApplyThemeToAllTabs();   // live preview while dragging — the script is tiny
+    }
+
+    private void UpdateBgControls()
+    {
+        var hasImage = ThemeManager.ChatBackgroundFile != null;
+        BgFileLabel.Text = hasImage ? "Image set ✓" : "No image set";
+        BgClearButton.IsEnabled = hasImage;
+        S_BgOpacity.IsEnabled = hasImage;
+        // The preview WebView renders the image itself (via the userdata host + theme
+        // script), so there is no XAML image to update here anymore.
+    }
+
+    // WinUI has no Window.Opacity — whole-window translucency is a Win32 layered-window
+    // attribute on the HWND. At 100% the layered style is removed entirely so the
+    // compositor does no extra work for the default solid window.
+    private const int GWL_EXSTYLE = -20;
+    private const int WS_EX_LAYERED = 0x80000;
+    private const uint LWA_ALPHA = 0x2;
+
+    [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
+    private static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
+    [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
+    private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
+    [System.Runtime.InteropServices.DllImport("user32.dll")]
+    private static extern bool SetLayeredWindowAttributes(nint hWnd, uint crKey, byte bAlpha, uint dwFlags);
+
+    private void ApplyWindowOpacity(double opacity)
+    {
+        var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
+        var exStyle = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
+        if (opacity >= 0.995)
+        {
+            SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle & ~(nint)WS_EX_LAYERED);
+        }
+        else
+        {
+            SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle | (nint)WS_EX_LAYERED);
+            SetLayeredWindowAttributes(hwnd, 0, (byte)Math.Round(opacity * 255), LWA_ALPHA);
+        }
+    }
+
+    private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        if (_loadingSettings || ThemeList.SelectedItem is not ThemeVm vm) return;
+        ThemeManager.Apply(vm.Theme, Root);
+        ThemeHeaderValue.Text = vm.Theme.Name;
+        SettingsStatus.Text = $"Theme set to {vm.Theme.Name}.";
+    }
+
+    private string _modelComboTarget = "";
+
+    /// An editable ComboBox drops programmatic Text while its template isn't
+    /// loaded (the Settings page starts collapsed) — so the intended model name is kept
+    /// here and re-applied on the combo's Loaded event. Selecting the matching pulled
+    /// model when one exists also marks it in the dropdown.
+    private void ApplyModelComboTarget()
+    {
+        if (_modelComboTarget.Length == 0) return;
+        if (ModelCombo.ItemsSource is IList models)
+        {
+            var idx = models.IndexOf(_modelComboTarget);
+            if (idx >= 0)
+            {
+                ModelCombo.SelectedIndex = idx;
+                return;
+            }
+        }
+        ModelCombo.Text = _modelComboTarget;
+    }
+
+    /// One write path for the whole page: ConfigKeySetter via the controller.
+    private async Task ApplySettingAsync(string key, string value)
+    {
+        var (ok, message) = await _controller.ApplyConfigKeyAsync(key, value);
+        SettingsStatus.Text = message;
+        if (!ok) LoadSettings();   // revert the control to the real value
+    }
+
+    private async void Setting_Toggled(object sender, RoutedEventArgs e)
+    {
+        if (_loadingSettings) return;
+        var toggle = (ToggleSwitch)sender;
+        await ApplySettingAsync((string)toggle.Tag, toggle.IsOn ? "true" : "false");
+    }
+
+    private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
+    {
+        if (_loadingSettings) return;
+
+        // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and
+        // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons
+        // keep working. If even the old value is gone, reload the whole form from config.
+        if (double.IsNaN(args.NewValue))
+        {
+            if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue;
+            else LoadSettings();
+            return;
+        }
+
+        await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString());
+    }
+
+    private async void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+    {
+        if (_loadingSettings) return;
+        S_TemperatureLabel.Text = e.NewValue.ToString("0.##");
+        await ApplySettingAsync("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
+    }
+
+    private async void Streaming_Changed(object sender, SelectionChangedEventArgs e)
+    {
+        if (_loadingSettings || S_Streaming.SelectedItem is not string mode) return;
+        await ApplySettingAsync("streaming", mode);
+    }
+
+    /// Enables View as soon as there's anything to reveal (saved key or fresh typing).
+    private void TavilyKey_Changed(object sender, RoutedEventArgs e) =>
+        TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0;
+
+    private void TavilyView_Click(object sender, RoutedEventArgs e)
+    {
+        var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible;
+        S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden;
+        TavilyViewButton.Content = show ? "Hide" : "View";
+    }
+
+    private async void TavilySave_Click(object sender, RoutedEventArgs e)
+    {
+        var key = S_TavilyKey.Password;
+        if (string.IsNullOrWhiteSpace(key))
+        {
+            SettingsStatus.Text = "Enter a key first (or type 'clear' to remove the saved one).";
+            return;
+        }
+        await ApplySettingAsync("tavilyKey", key.Trim());
+        LoadSettings();
+    }
+
+    private async void RefreshModels_Click(object sender, RoutedEventArgs e) =>
+        await RefreshModelListAsync();
+
+    private async Task RefreshModelListAsync()
+    {
+        ModelListStatus.Text = "Fetching models…";
+        var models = await Task.Run(_controller.ListModelsAsync);
+        if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text;
+        ModelCombo.ItemsSource = models;
+        ApplyModelComboTarget();
+        ModelListStatus.Text = models.Count == 0
+            ? "No models found — is Ollama running? (ollama serve, then ollama pull )"
+            : $"{models.Count} model(s) available.";
+    }
+
+    private async void SettingsSave_Click(object sender, RoutedEventArgs e)
+    {
+        var endpoint = EndpointBox.Text;
+        var model = ModelCombo.Text;
+        SettingsStatus.Text = "Connecting… (details land in the chat transcript)";
+        await Task.Run(() => _controller.ApplyConnectionSettingsAsync(endpoint, model));
+        SettingsStatus.Text = _controller.IsConnected
+            ? $"✓ Connected — {_controller.ModelName}"
+            : "Couldn't connect — see the chat transcript for details.";
+        LoadSettings();
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs
new file mode 100644
index 0000000..e1d0bca
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.History.cs
@@ -0,0 +1,475 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // History panel — reopen a closed conversation. Shares the docked column with Snapshots.
+    // ============================================================
+
+    /// Files a just-closed tab into the archive so it can be reopened later. A session that
+    /// never had a real turn is forgotten instead (deleting its files), same as /clear —
+    /// there's nothing worth reopening, and an empty row would only be noise.
+    private void ArchiveClosedSession(AgentSession session)
+    {
+        var key = session.PersistKey;
+        var turns = ConversationLog.Load(key);
+        if (turns.Count == 0)
+        {
+            TranscriptJournal.Delete(key);
+            ConversationLog.Delete(key);
+            SessionHistoryStore.Delete(key);
+            return;
+        }
+
+        var preview = turns.FirstOrDefault(t => t.R == "u")?.T?.Trim();
+        if (preview is { Length: > 140 }) preview = preview[..140].TrimEnd() + "…";
+
+        _archive.Add(new SessionArchiveEntry
+        {
+            Key = key,
+            Title = session.Title,
+            ProjectRoot = session.ProjectRoot.ProjectRoot,
+            Model = session.Controller.ModelName,
+            ClosedAt = DateTimeOffset.Now,
+            TurnCount = turns.Count,
+            Preview = preview,
+        });
+    }
+
+    private void OnArchiveChanged()
+    {
+        if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
+        else RefreshHistoryBadge();
+    }
+
+    /// Marks every current archived conversation as seen, clearing the History rail badge.
+    private void MarkHistorySeen()
+    {
+        _historySeenAt = DateTimeOffset.Now;
+        SavePanelState();
+        RefreshHistoryBadge();
+    }
+
+    private void NavHistory_Click(object sender, RoutedEventArgs e)
+    {
+        if (_historyPanelOpen) CloseLeftPanel();
+        else OpenHistory();
+    }
+
+    private void CloseHistory_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
+
+    private void OpenHistory()
+    {
+        MarkHistorySeen();   // opening the panel IS reading it — clear the unread badge
+        PopulateHistory();
+        ShowLeftPanel(HistoryPanel, snapshots: false);
+    }
+
+    /// Current text in the history search box; empty means "show everything".
+    private string _historyFilter = "";
+
+    /// Project labels whose History group is folded shut (survives search/reopen/delete).
+    private readonly HashSet _collapsedHistoryGroups = new();
+
+    private void HistoryGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
+    {
+        if (sender.Tag is not HistoryGroup g) return;
+        g.IsExpanded = true;
+        _collapsedHistoryGroups.Remove(g.Project);
+        SavePanelState();
+    }
+
+    private void HistoryGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
+    {
+        if (sender.Tag is not HistoryGroup g) return;
+        g.IsExpanded = false;
+        _collapsedHistoryGroups.Add(g.Project);
+        SavePanelState();
+    }
+
+    private void HistorySearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+    {
+        if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
+        _historyFilter = sender.Text?.Trim() ?? "";
+        PopulateHistory();
+    }
+
+    private static bool Matches(SessionArchiveEntry s, string q) =>
+        s.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || (s.Model?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
+        || (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
+
+    private void PopulateHistory()
+    {
+        var all = _archive.Items;   // newest-first copy
+        var storeEmpty = all.Count == 0;
+        HistorySearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
+
+        var q = _historyFilter;
+        var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
+
+        // Group by project (freshest project first), newest-first within each, carrying remembered
+        // collapse state — same shape as the Snapshots panel.
+        var groups = filtered
+            .GroupBy(s => s.ProjectLabel)
+            .OrderByDescending(g => g.Max(s => s.ClosedAt))
+            .Select(g => new HistoryGroup(g.Key, g) { IsExpanded = !_collapsedHistoryGroups.Contains(g.Key) })
+            .ToList();
+
+        HistoryList.ItemsSource = groups;
+
+        var nothingToShow = groups.Count == 0;
+        HistoryEmpty.Text = storeEmpty
+            ? "No past conversations yet. Close a tab and it lands here — reopen it any time to pick up where you left off. (Clearing a tab with /clear forgets it for good; closing keeps it.)"
+            : $"No conversations match “{q}”.";
+        HistoryEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
+        HistoryScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
+        RefreshHistoryBadge();
+    }
+
+    private void RefreshHistoryBadge()
+    {
+        // Unread = conversations closed after the last visit. Never-visited (null) counts them all.
+        var n = _historySeenAt is { } seen
+            ? _archive.Items.Count(s => s.ClosedAt > seen)
+            : _archive.Count;
+        NavHistoryBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
+        NavHistoryBadgeText.Text = n > 99 ? "99+" : n.ToString();
+    }
+
+    /// Reopens an archived conversation as a fresh tab on its original persist-key, so the
+    /// standard restore cascade (transcript replay → memory rehydrate) brings it back. The row
+    /// leaves the archive — it's live again — but its files stay; closing re-archives it.
+    private void HistoryOpen_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
+
+        // Defensive: an archived key should never also be open, but if it is, just go there.
+        var existing = _tabs.FirstOrDefault(t =>
+            string.Equals(t.View.Session.PersistKey, entry.Key, StringComparison.OrdinalIgnoreCase));
+        if (existing != null)
+        {
+            _archive.Remove(entry.Key, deleteFiles: false);
+            CloseLeftPanel();
+            SwitchPage("chat");
+            SelectTab(existing);
+            return;
+        }
+
+        // Fall back to the current directory if the original folder is gone — the transcript and
+        // memory still restore; only new file operations would need a live folder.
+        var root = Directory.Exists(entry.ProjectRoot) ? entry.ProjectRoot : Environment.CurrentDirectory;
+        var tab = CreateChatTab(root, entry.Title, entry.Model, entry.Key);   // CreateChatTab selects it
+        _archive.Remove(entry.Key, deleteFiles: false);
+        CloseLeftPanel();
+        SwitchPage("chat");
+        _ = InitTabAsync(tab);   // InitializeAsync replays the transcript; then model + memory restore
+        SaveWorkspace();
+    }
+
+    private void HistoryDelete_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
+        _archive.Remove(entry.Key, deleteFiles: true);   // explicit forget — files go too
+        PopulateHistory();
+    }
+
+    /// "Make Default for New Agents" — snapshot the selected agent's settings to disk.
+    private void MakeDefault_Click(object sender, RoutedEventArgs e)
+    {
+        var agent = _sessions.Active;
+        if (agent == null) return;
+
+        _controller.SaveAsDefaults();
+        SettingsStatus.Text = $"Saved {agent.Title}'s settings as the default for new agents. "
+                            + "Agents already open keep their own.";
+    }
+
+    /// Resets the visible tab's settings to the app's factory defaults (this agent, this
+    /// session). Reads a fresh  for the defaults and applies each key
+    /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the
+    /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset.
+    private async void ResetTab_Click(object sender, RoutedEventArgs e)
+    {
+        var d = new MandoCodeConfig();   // factory defaults (property initializers)
+        var s = SettingsTabs.SelectedItem;
+        var resets = new List<(string Key, string Value)>();
+        string tabName;
+
+        static string Bool(bool b) => b ? "true" : "false";
+        static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+        if (s == Tab_Behavior)
+        {
+            tabName = "Behavior";
+            resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning)));
+            resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals)));
+            resets.Add(("autoContinue", Bool(d.EnableAutoContinuation)));
+            resets.Add(("maxContinuations", Num(d.MaxAutoContinuations)));
+            resets.Add(("timeout", Num(d.RequestTimeoutMinutes)));
+            resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds)));
+            resets.Add(("toolBudget", Num(d.ToolResultCharBudget)));
+            resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds)));
+        }
+        else if (s == Tab_Integrations)
+        {
+            tabName = "Integrations";
+            resets.Add(("webSearch", Bool(d.EnableWebSearch)));
+        }
+        else
+        {
+            tabName = "Model";
+            resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)));
+            resets.Add(("maxTokens", Num(d.MaxTokens)));
+            resets.Add(("contextLength", Num(d.ContextLength)));
+            resets.Add(("streaming", d.ResponseStreaming));
+        }
+
+        ResetTabButton.IsEnabled = false;
+        foreach (var (key, value) in resets)
+            await _controller.ApplyConfigKeyAsync(key, value);
+        ResetTabButton.IsEnabled = true;
+
+        LoadSettings();   // reflect the restored values (also clears the status line)
+        SettingsStatus.Text = $"{tabName} settings reset to factory defaults.";
+    }
+
+    private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title)
+    {
+        var label = new TextBlock
+        {
+            Text = title,
+            FontSize = 13,
+            VerticalAlignment = VerticalAlignment.Center,
+            TextTrimming = TextTrimming.CharacterEllipsis
+        };
+
+        // Gold dot: an approval is waiting in a tab you aren't looking at.
+        var badge = new Ellipse
+        {
+            Width = 7,
+            Height = 7,
+            Visibility = Visibility.Collapsed,
+            VerticalAlignment = VerticalAlignment.Center,
+            Fill = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
+        };
+
+        // Options "..." menu (rename / snapshot / export / close) replaces a bare close button — so
+        // the last remaining tab isn't stuck showing an X it isn't allowed to use.
+        var options = new Button
+        {
+            Padding = new Thickness(3),
+            Background = new SolidColorBrush(Colors.Transparent),
+            BorderThickness = new Thickness(0),
+            VerticalAlignment = VerticalAlignment.Center,
+            Content = new FontIcon { Glyph = "", FontSize = 12 }   // More
+        };
+        ToolTipService.SetToolTip(options, "Tab options");
+        Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(options, "Tab options");
+
+        // A Grid (not a StackPanel) so the label flexes and ellipsizes when the tab is narrow,
+        // while the badge and options button stay pinned at the right. LayoutTabStrip sets each
+        // header's Width; this just governs how that width is divided.
+        var row = new Grid { ColumnSpacing = 7 };
+        row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+        row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+        row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+        Grid.SetColumn(label, 0);
+        Grid.SetColumn(badge, 1);
+        Grid.SetColumn(options, 2);
+        row.Children.Add(label);
+        row.Children.Add(badge);
+        row.Children.Add(options);
+
+        var header = new Border
+        {
+            Child = row,
+            Padding = new Thickness(12, 6, 8, 6),
+            CornerRadius = new CornerRadius(7),
+            BorderThickness = new Thickness(1),
+            BorderBrush = new SolidColorBrush(Colors.Transparent),
+            Background = new SolidColorBrush(Colors.Transparent)
+        };
+        return (header, label, badge);
+    }
+
+    /// Wired after the entry exists so the menu handlers can close over it.
+    private void WireHeader(ChatTabEntry entry)
+    {
+        // The options Button consumes the pointer, so opening its menu doesn't also raise Tapped
+        // on the header. Selecting first would be harmless anyway.
+        entry.Header.Tapped += (_, _) => SelectTab(entry);
+
+        var row = (Grid)entry.Header.Child;
+        var options = (Button)row.Children[^1];
+
+        var menu = new MenuFlyout();
+
+        var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } };
+        rename.Click += (_, _) => _ = RenameTabAsync(entry);
+
+        var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } };
+        snapshot.Click += (_, _) => entry.View.TakeSnapshotManually();
+
+        var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } };
+        export.Click += (_, _) => _ = entry.View.ExportTranscriptAsync();
+
+        var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
+        close.Click += (_, _) => CloseTab(entry);
+
+        menu.Items.Add(rename);
+        menu.Items.Add(snapshot);
+        menu.Items.Add(export);
+        menu.Items.Add(new MenuFlyoutSeparator());
+        menu.Items.Add(close);
+
+        // Closing the last agent is allowed now — it leaves an empty chat (see EnterEmptyState);
+        // Settings/MCP simply disable until a new agent exists.
+
+        options.Flyout = menu;
+    }
+
+    /// Renames a tab via a small dialog. The name is display-only (the folder stays in
+    /// the header); it survives folder changes and model switches.
+    private async Task RenameTabAsync(ChatTabEntry entry)
+    {
+        var box = new TextBox { Text = entry.View.Session.Title };
+        box.SelectAll();
+
+        var dialog = new ContentDialog
+        {
+            Title = "Rename agent",
+            Content = box,
+            PrimaryButtonText = "Rename",
+            CloseButtonText = "Cancel",
+            DefaultButton = ContentDialogButton.Primary,
+            XamlRoot = Content.XamlRoot,
+        };
+
+        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+        var name = box.Text.Trim();
+        if (name.Length == 0) return;
+
+        entry.View.Session.Title = name;
+        entry.Label.Text = name;
+        RefreshTabStrip();
+    }
+
+    /// Selecting an agent also returns you to the chat page — the Settings you were
+    /// looking at belonged to the agent you just left.
+    private void SelectTab(ChatTabEntry entry)
+    {
+        // Selecting a tab NEVER changes the compare pair — it only changes the active agent. If that
+        // agent is in the pair, ApplyPaneLayout shows the split; otherwise it shows the agent single.
+        _selected = entry;
+        _sessions.Activate(entry.View.Session);
+        RefreshTabStrip();
+        SwitchPage("chat");
+
+        // Reveal the selected tab. Try now (covers clicking an already-laid-out tab) and again when
+        // the strip re-lays-out (covers a just-added agent, whose width/extent settle a frame later,
+        // via TabStrip_SizeChanged). Pending stays set until the tab is actually laid out.
+        _scrollToSelectedPending = true;
+        DispatcherQueue.TryEnqueue(TryScrollToSelected);
+    }
+
+    private bool _scrollToSelectedPending;
+
+    // Scroll the strip so the selected tab is fully visible — a manual ChangeView so a newly created
+    // (last) tab scrolls ALL THE WAY to the end. StartBringIntoView only did a minimal scroll and ran
+    // before the extent settled, so it stopped short. No-op once the tab is visible; stays pending
+    // (retried on the next strip SizeChanged) while the tab isn't laid out yet (ActualWidth == 0).
+    private void TryScrollToSelected()
+    {
+        if (!_scrollToSelectedPending || _selected is null) return;
+        var header = _selected.Header;
+        if (header.ActualWidth <= 0) return;   // not laid out yet — retry on the next SizeChanged
+
+        double left = header.TransformToVisual(TabStrip)
+                            .TransformPoint(new Windows.Foundation.Point(0, 0)).X;
+        double right = left + header.ActualWidth;
+        double viewLeft = TabScroller.HorizontalOffset;
+        double viewRight = viewLeft + TabScroller.ViewportWidth;
+        const double pad = 8;
+
+        if (right > viewRight)                 // off the right (e.g. a just-added last tab)
+            TabScroller.ChangeView(right - TabScroller.ViewportWidth + pad, null, null);
+        else if (left < viewLeft)              // off the left
+            TabScroller.ChangeView(Math.Max(0, left - pad), null, null);
+
+        _scrollToSelectedPending = false;
+    }
+
+    private void TabStrip_SizeChanged(object sender, SizeChangedEventArgs e) => TryScrollToSelected();
+
+    private void CloseTab(ChatTabEntry entry)
+    {
+        var index = _tabs.IndexOf(entry);
+        if (index < 0) return;
+
+        _tabs.RemoveAt(index);
+        TabStrip.Children.Remove(entry.Header);
+
+        // Shut down BEFORE unparenting. Removing the view from the tree unloads the WebView2 and
+        // nulls its CoreWebView2, so Close() and any last transcript write would hit null.
+        entry.View.Shutdown();
+        TabHost.Children.Remove(entry.View);
+        _sessions.CloseSession(entry.View.Session);
+        ArchiveClosedSession(entry.View.Session);   // closed tab = recoverable from History, not gone
+
+        // Closing the last agent is allowed: you're left with the empty chat background until you
+        // open another. Settings/MCP disable meanwhile (they act on an agent), handled in SwitchPage.
+        if (_tabs.Count == 0)
+        {
+            _selected = null;
+            ValidateSplit();     // nothing left to compare → exits split
+            EnterEmptyState();
+            SaveWorkspace();
+            return;
+        }
+
+        if (!ReferenceEquals(_selected, entry))
+        {
+            ValidateSplit();     // repair the right pane if that's what closed
+            RefreshTabStrip();
+            SaveWorkspace();
+            return;
+        }
+
+        _selected = null;
+        SelectTab(_tabs[Math.Min(index, _tabs.Count - 1)]);
+        ValidateSplit();         // the new selection might collide with the right pane
+        SaveWorkspace();
+    }
+
+    /// Shows the "no agents open" background — the chat area with nothing in it. Bounces off
+    /// any full-screen page back to chat (Settings/MCP have no agent to act on now).
+    private void EnterEmptyState()
+    {
+        RefreshTabStrip();       // empties the toast; disables Settings/MCP via RefreshNavIcons
+        SwitchPage("chat");      // reveals the empty-state panel + its background
+        if (_snapshotsPanelOpen) PopulateSnapshots();   // no agent now → disable Import + show notice
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Mcp.cs b/src/MandoCode.Desktop/MainWindow.Mcp.cs
new file mode 100644
index 0000000..c3f3fe4
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Mcp.cs
@@ -0,0 +1,390 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // MCP page
+    // ============================================================
+
+    private async void McpRefresh_Click(object sender, RoutedEventArgs e) => await RefreshMcpListAsync();
+
+    // Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter).
+    private List _allMcpRows = new();
+    private bool _loadingMcp;
+
+    private async Task RefreshMcpListAsync()
+    {
+        // Servers are shared across agents and enabled/disabled per-server now, so MCP is always on
+        // at the agent level. Make sure the active agent actually attaches tools (new agents inherit
+        // EnableMcp=true from defaults; this only fires for an agent someone turned off previously).
+        if (!_controller.Config.EnableMcp)
+            await ApplySettingAsync("mcp", "true");
+
+        McpPageStatus.Text = "Checking server status…";
+        var rows = await Task.Run(_controller.GetMcpStatusRowsAsync);
+
+        var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
+        var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+        _allMcpRows = rows.Select(r => new McpRow
+        {
+            Name = r.Name,
+            Transport = r.Transport,
+            Status = r.Status,
+            StatusBrush = r.Connected ? green : gold,
+            Enabled = !r.Disabled,
+        }).ToList();
+
+        ApplyMcpFilter();
+    }
+
+    private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
+        ApplyMcpFilter();
+
+    private string _mcpFilter = "all";
+
+    private void McpFilter_Click(object sender, RoutedEventArgs e)
+    {
+        _mcpFilter = (string)((FrameworkElement)sender).Tag;
+        McpFilterAll.IsChecked = _mcpFilter == "all";
+        McpFilterEnabled.IsChecked = _mcpFilter == "enabled";
+        McpFilterDisabled.IsChecked = _mcpFilter == "disabled";
+        McpFilterFailed.IsChecked = _mcpFilter == "failed";
+        ApplyMcpFilter();
+    }
+
+    /// Applies search + active chip, then groups into Enabled/Disabled sections. The
+    /// programmatic ItemsSource set realizes rows (firing each toggle), guarded in McpEnabled_Toggled.
+    private void ApplyMcpFilter()
+    {
+        var q = McpSearchBox.Text?.Trim() ?? "";
+        IEnumerable filtered = _allMcpRows;
+        if (!string.IsNullOrEmpty(q))
+            filtered = filtered.Where(r =>
+                r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+                r.Transport.Contains(q, StringComparison.OrdinalIgnoreCase));
+        filtered = _mcpFilter switch
+        {
+            "enabled" => filtered.Where(r => r.Enabled),
+            "disabled" => filtered.Where(r => !r.Enabled),
+            "failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
+            _ => filtered,
+        };
+        var shown = filtered.ToList();
+
+        var groups = new List();
+        var en = shown.Where(r => r.Enabled).ToList();
+        var dis = shown.Where(r => !r.Enabled).ToList();
+        if (en.Count > 0) groups.Add(new McpRowGroup($"Enabled ({en.Count})", en));
+        if (dis.Count > 0) groups.Add(new McpRowGroup($"Disabled ({dis.Count})", dis));
+
+        var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
+        _loadingMcp = true;
+        McpList.ItemsSource = cvs.View;
+        _loadingMcp = false;
+
+        McpEditButton.IsEnabled = false;
+        McpRemoveButton.IsEnabled = false;
+
+        var total = _allMcpRows.Count;
+        var enabledTotal = _allMcpRows.Count(r => r.Enabled);
+        var active = q.Length > 0 || _mcpFilter != "all";
+        if (total == 0)
+            McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one.";
+        else if (active)
+            McpPageStatus.Text = $"{shown.Count} of {total} shown  ·  {enabledTotal} enabled";
+        else
+            McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled";
+    }
+
+    /// Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts
+    /// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload).
+    private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
+    {
+        // Fires while the list realizes rows and binds IsOn — ignore those (state already matches).
+        if (_loadingMcp) return;
+        if (sender is not ToggleSwitch sw || sw.DataContext is not McpRow row) return;
+        if (sw.IsOn == row.Enabled) return;
+
+        // Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
+        if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
+        server.Disabled = !sw.IsOn;
+
+        McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
+        await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
+        await RefreshMcpListAsync();
+    }
+
+    /// Runs a slash command through the normal pipeline (transcript echo, wizard
+    /// overlays, busy state all included), then refreshes the server list.
+    private async Task RunMcpCommandAsync(string command)
+    {
+        if (_controller.IsProcessing)
+        {
+            McpPageStatus.Text = "Busy — wait for the current request to finish.";
+            return;
+        }
+        await Task.Run(() => _controller.SubmitAsync(command));
+        await RefreshMcpListAsync();
+    }
+
+    private void McpAdd_Click(object sender, RoutedEventArgs e) => OpenMcpEditor(null);
+
+    private void McpEdit_Click(object sender, RoutedEventArgs e)
+    {
+        if (McpList.SelectedItem is not McpRow row)
+        {
+            McpPageStatus.Text = "Select a server to edit first.";
+            return;
+        }
+        OpenMcpEditor(row.Name);
+    }
+
+    private void McpList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        var hasSelection = McpList.SelectedItem is McpRow;
+        McpEditButton.IsEnabled = hasSelection;
+        McpRemoveButton.IsEnabled = hasSelection;
+    }
+
+    private void McpList_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
+    {
+        if (McpList.SelectedItem is McpRow row) OpenMcpEditor(row.Name);
+    }
+
+    private async void McpRemove_Click(object sender, RoutedEventArgs e)
+    {
+        if (McpList.SelectedItem is not McpRow row)
+        {
+            McpPageStatus.Text = "Select a server to remove first.";
+            return;
+        }
+        await RunMcpCommandAsync($"/mcp remove {row.Name}");
+    }
+
+    private async void McpReload_Click(object sender, RoutedEventArgs e) =>
+        await RunMcpCommandAsync("/mcp-reload");
+
+    // ============================================================
+    // MCP server editor modal (add + edit)
+    // ============================================================
+
+    private string? _mcpEditOriginalName;
+
+    private void OpenMcpEditor(string? serverName)
+    {
+        _mcpEditOriginalName = serverName;
+        M_StatusBar.IsOpen = false;
+        M_TestToolsTable.Visibility = Visibility.Collapsed;
+        M_TestSpin.Visibility = Visibility.Collapsed;
+        McpEditorTestButton.IsEnabled = true;
+        McpEditorSaveButton.IsEnabled = true;
+
+        if (serverName != null && _controller.Config.McpServers.TryGetValue(serverName, out var cfg))
+        {
+            McpEditorTitle.Text = $"Edit MCP server — {serverName}";
+            McpEditorSaveButton.Content = "Save & Reconnect";
+            M_Name.Text = serverName;
+            M_Transport.SelectedIndex = cfg.IsHttp ? 1 : 0;
+            M_Command.Text = cfg.Command ?? "";
+            M_Args.Text = string.Join(" ", cfg.Args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a));
+            M_Env.Text = string.Join("\n", cfg.Env.Select(kv => $"{kv.Key}={kv.Value}"));
+            M_Url.Text = cfg.Url ?? "";
+            M_Headers.Text = string.Join("\n", cfg.Headers.Select(kv => $"{kv.Key}={kv.Value}"));
+            M_Disabled.IsOn = cfg.Disabled;
+        }
+        else
+        {
+            McpEditorTitle.Text = "Add MCP server";
+            McpEditorSaveButton.Content = "Save & Connect";
+            M_Name.Text = "";
+            M_Transport.SelectedIndex = 0;
+            M_Command.Text = "";
+            M_Args.Text = "";
+            M_Env.Text = "";
+            M_Url.Text = "";
+            M_Headers.Text = "";
+            M_Disabled.IsOn = false;
+        }
+
+        UpdateMcpTransportPanels();
+        McpEditorOverlay.Visibility = Visibility.Visible;
+        M_Name.Focus(FocusState.Programmatic);
+    }
+
+    private void M_Transport_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
+        UpdateMcpTransportPanels();
+
+    private void UpdateMcpTransportPanels()
+    {
+        // Guard: fires during InitializeComponent before panels exist.
+        if (M_StdioPanel == null || M_HttpPanel == null) return;
+        var isHttp = M_Transport.SelectedIndex == 1;
+        M_HttpPanel.Visibility = isHttp ? Visibility.Visible : Visibility.Collapsed;
+        M_StdioPanel.Visibility = isHttp ? Visibility.Collapsed : Visibility.Visible;
+    }
+
+    private void McpEditorCancel_Click(object sender, RoutedEventArgs e) =>
+        McpEditorOverlay.Visibility = Visibility.Collapsed;
+
+    private void ShowMcpEditorError(string message)
+    {
+        M_TestSpin.IsActive = false;
+        M_TestSpin.Visibility = Visibility.Collapsed;
+        M_TestToolsTable.Visibility = Visibility.Collapsed;
+        M_StatusBar.Severity = InfoBarSeverity.Error;
+        M_StatusBar.Title = "Check the form";
+        M_StatusBar.Message = message;
+        M_StatusBar.IsOpen = true;
+    }
+
+    /// Parses "KEY=value" lines. Returns null (with an error shown) on a bad line.
+    private Dictionary? ParseKeyValueLines(string text, string label)
+    {
+        var dict = new Dictionary();
+        foreach (var rawLine in text.Split('\n'))
+        {
+            var line = rawLine.Trim();
+            if (line.Length == 0) continue;
+            var eq = line.IndexOf('=');
+            if (eq <= 0)
+            {
+                ShowMcpEditorError($"{label}: '{line}' isn't KEY=value.");
+                return null;
+            }
+            dict[line[..eq].Trim()] = line[(eq + 1)..].Trim();
+        }
+        return dict;
+    }
+
+    /// Shared validate-and-build for Test and Save. Shows the error inline and
+    /// returns false when the form isn't valid.
+    private bool TryBuildServerFromForm(bool checkNameCollision, out string name, out MandoCode.Models.McpServerConfig server)
+    {
+        M_StatusBar.IsOpen = false;
+        server = new MandoCode.Models.McpServerConfig { Disabled = M_Disabled.IsOn };
+
+        // Lowercased — servers are referenced by name in tool prefixes (mcp_).
+        name = M_Name.Text.Trim().ToLowerInvariant();
+        if (string.IsNullOrWhiteSpace(name)) { ShowMcpEditorError("Name cannot be empty."); return false; }
+        if (name.Contains(' ')) { ShowMcpEditorError("Name cannot contain spaces."); return false; }
+        if (checkNameCollision && _mcpEditOriginalName == null && _controller.Config.McpServers.ContainsKey(name))
+        {
+            ShowMcpEditorError($"A server named '{name}' already exists — edit it instead, or pick another name.");
+            return false;
+        }
+
+        if (M_Transport.SelectedIndex == 1)   // http
+        {
+            var url = M_Url.Text.Trim();
+            if (!Uri.TryCreate(url, UriKind.Absolute, out _))
+            {
+                ShowMcpEditorError("URL must be absolute (e.g. https://mcp.example.com/mcp).");
+                return false;
+            }
+            server.Url = url;
+            server.Transport = "http";
+
+            var headers = ParseKeyValueLines(M_Headers.Text, "Headers");
+            if (headers == null) return false;
+            server.Headers = headers;
+        }
+        else                                   // stdio
+        {
+            var command = M_Command.Text.Trim();
+            if (string.IsNullOrWhiteSpace(command)) { ShowMcpEditorError("Command cannot be empty."); return false; }
+            server.Command = command;
+            server.Args = ChatController.ParseShellLikeArgs(M_Args.Text.Trim());
+
+            var env = ParseKeyValueLines(M_Env.Text, "Environment variables");
+            if (env == null) return false;
+            server.Env = env;
+        }
+
+        return true;
+    }
+
+    private async void McpEditorTest_Click(object sender, RoutedEventArgs e)
+    {
+        // No collision check — testing an existing name is fine, nothing is written.
+        if (!TryBuildServerFromForm(checkNameCollision: false, out var name, out var server)) return;
+
+        M_StatusBar.Severity = InfoBarSeverity.Informational;
+        M_StatusBar.Title = "Testing connection…";
+        M_StatusBar.Message = "Connecting with these values — nothing is saved, running servers aren't touched.";
+        M_StatusBar.IsOpen = true;
+        M_TestToolsTable.Visibility = Visibility.Collapsed;
+        M_TestSpin.Visibility = Visibility.Visible;
+        M_TestSpin.IsActive = true;
+        McpEditorTestButton.IsEnabled = false;
+        McpEditorSaveButton.IsEnabled = false;
+
+        try
+        {
+            var result = await Task.Run(() => _controller.TestMcpServerAsync(name, server));
+
+            M_TestSpin.IsActive = false;
+            M_TestSpin.Visibility = Visibility.Collapsed;
+
+            if (result.Ok)
+            {
+                M_StatusBar.Severity = InfoBarSeverity.Success;
+                M_StatusBar.Title = $"Connected — {result.Tools.Count} tool(s)";
+                M_StatusBar.Message = result.Message;
+                if (result.Tools.Count > 0)
+                {
+                    M_TestTools.ItemsSource = result.Tools
+                        .Select(t => new ToolChip { Name = t.Name, Description = t.Description ?? "(no description)" })
+                        .ToList();
+                    M_TestToolsTable.Visibility = Visibility.Visible;
+                }
+            }
+            else
+            {
+                M_StatusBar.Severity = InfoBarSeverity.Error;
+                M_StatusBar.Title = "Connection failed";
+                M_StatusBar.Message = result.Message;
+            }
+        }
+        finally
+        {
+            M_TestSpin.IsActive = false;
+            McpEditorTestButton.IsEnabled = true;
+            McpEditorSaveButton.IsEnabled = true;
+        }
+    }
+
+    private async void McpEditorSave_Click(object sender, RoutedEventArgs e)
+    {
+        if (!TryBuildServerFromForm(checkNameCollision: true, out var name, out var server)) return;
+
+        McpEditorOverlay.Visibility = Visibility.Collapsed;
+        SwitchPage("mcp");
+        McpPageStatus.Text = $"Saving '{name}' and connecting…";
+
+        var originalName = _mcpEditOriginalName;
+        var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server));
+        McpPageStatus.Text = message;
+        await RefreshMcpListAsync();
+        McpPageStatus.Text = message;
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Navigation.cs b/src/MandoCode.Desktop/MainWindow.Navigation.cs
new file mode 100644
index 0000000..4c74797
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Navigation.cs
@@ -0,0 +1,157 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Sidebar navigation — Settings and MCP are full-screen pages, not tabs. They act on
+    // whichever agent is selected, so switching pages never changes which agent that is.
+    // ============================================================
+
+    private string _currentPage = "chat";
+
+    private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat");
+
+    // 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 NavSkills_Click(object sender, RoutedEventArgs e)
+        => SwitchPage(_currentPage == "skills" ? "chat" : "skills");
+    private void NavAppearance_Click(object sender, RoutedEventArgs e)
+        => SwitchPage(_currentPage == "appearance" ? "chat" : "appearance");
+
+    private void SwitchPage(string page)
+    {
+        // Settings and MCP act on the selected agent — with none open there's nothing to edit, so
+        // fall back to the (empty) chat. Skills and Appearance are app-global and stay reachable.
+        if ((page == "settings" || page == "mcp") && _sessions.Active == null) page = "chat";
+
+        _currentPage = page;
+        var showingChat = page == "chat";
+
+        SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed;
+        McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed;
+        SkillsPage.Visibility = page == "skills" ? Visibility.Visible : Visibility.Collapsed;
+        AppearancePage.Visibility = page == "appearance" ? 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);
+        else if (page == "skills") SlideInPage(SkillsPage, SkillsPageTransform);
+        else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform);
+
+        // Every agent view stays loaded; only the visible one(s) show, and only on the chat page.
+        // Collapsing rather than removing is what keeps each WebView2's transcript alive. In split
+        // mode two views show at once (the compare pair, _compareA left / _compareB right).
+        ApplyPaneLayout();
+
+        // The empty-state background shows only on the chat page with no agents left.
+        EmptyAgentsState.Visibility = showingChat && _tabs.Count == 0
+            ? Visibility.Visible : Visibility.Collapsed;
+        _ = RefreshEmptyBackgroundAsync();
+
+        RefreshNavIcons();
+        // Re-evaluate the approval toast for the new page — leaving the chat can newly "hide" the
+        // selected agent's approval, which should now raise the toast (and returning clears it).
+        RefreshTabStrip();
+
+        switch (page)
+        {
+            case "settings":
+                LoadSettings();
+                _ = RefreshModelListAsync();
+                break;
+            case "mcp":
+                _ = RefreshMcpListAsync();
+                break;
+            case "skills":
+                RefreshSkillsList();
+                break;
+            default:
+                ActiveChat?.FocusInput();
+                break;
+        }
+    }
+
+    /// 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"];
+        var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+        var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+
+        // An approval waiting in ANY agent while you're on Settings/MCP: the agents icon goes gold,
+        // because from here you can't see which tab is badged.
+        var approvalPending = _currentPage != "chat" && _tabs.Any(t => t.View.IsApprovalOpen);
+
+        NavChatIcon.Foreground = _currentPage == "chat" ? accent : (approvalPending ? gold : normal);
+        NavSettingsIcon.Foreground = _currentPage == "settings" ? accent : normal;
+        NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal;
+        NavSkillsIcon.Foreground = _currentPage == "skills" ? accent : normal;
+        NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal;
+        NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal;
+        NavHistoryIcon.Foreground = _historyPanelOpen ? accent : normal;
+        NavTerminalIcon.Foreground = _terminalOpen ? accent : normal;
+
+        // Settings and MCP act on the selected agent — disable them while none is open.
+        var hasAgent = _sessions.Active != null;
+        NavSettings.IsEnabled = hasAgent;
+        NavMcp.IsEnabled = hasAgent;
+        ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents");
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Settings.cs b/src/MandoCode.Desktop/MainWindow.Settings.cs
new file mode 100644
index 0000000..b23913d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Settings.cs
@@ -0,0 +1,100 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Settings page
+    // ============================================================
+
+    private bool _loadingSettings;
+
+    /// Populates every control from the live config. Guarded so control-change
+    /// events fired during population don't write back.
+    private void LoadSettings()
+    {
+        _loadingSettings = true;
+        try
+        {
+            // The SELECTED agent's config, not the saved defaults. Switch agents and this page
+            // shows different values.
+            var cfg = _controller.Config;
+            SettingsAgentChip.Text = _sessions.Active?.Title ?? "";
+            EndpointBox.Text = cfg.OllamaEndpoint;
+            _modelComboTarget = cfg.GetEffectiveModelName();
+            ApplyModelComboTarget();
+            S_ContextLength.Value = cfg.ContextLength;
+            S_Temperature.Value = cfg.Temperature;
+            S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##");
+            S_MaxTokens.Value = cfg.MaxTokens;
+            S_Streaming.SelectedItem = cfg.ResponseStreaming;
+            S_TaskPlanning.IsOn = cfg.EnableTaskPlanning;
+            S_DiffApprovals.IsOn = cfg.EnableDiffApprovals;
+            S_AutoContinue.IsOn = cfg.EnableAutoContinuation;
+            S_MaxContinuations.Value = cfg.MaxAutoContinuations;
+            S_RequestTimeout.Value = cfg.RequestTimeoutMinutes;
+            S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds;
+            S_ToolBudget.Value = cfg.ToolResultCharBudget;
+            S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds;
+            S_WebSearch.IsOn = cfg.EnableWebSearch;
+            S_TavilyKey.Password = cfg.TavilyApiKey ?? "";
+            S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden;
+            TavilyViewButton.Content = "View";
+            TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey);
+            for (int i = 0; i < UiTheme.All.Count; i++)
+                if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i;
+            SettingsStatus.Text = "";
+        }
+        finally
+        {
+            _loadingSettings = false;
+        }
+    }
+
+    private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
+    {
+        var s = sender.SelectedItem;
+        TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed;
+        TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed;
+        TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed;
+
+        // "Reset" acts on the visible tab, so its label names that tab.
+        ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior"
+            : s == Tab_Integrations ? "Reset Integrations" : "Reset Model";
+        // Every remaining tab is per-agent now (Appearance moved to its own rail page), so
+        // "Make Default for New Agents" always applies.
+    }
+
+    /// False until the constructor has loaded persisted appearance settings into the
+    /// sliders. The sliders' XAML default Values fire ValueChanged during InitializeComponent —
+    /// BEFORE ThemeManager.Initialize reads ui-settings.json — and a Save() in that window
+    /// overwrites the file with defaults (that bug ate users' saved background image).
+    private bool _appearanceReady;
+
+    private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
+    {
+        if (!_appearanceReady) return;
+        S_WindowOpacityLabel.Text = $"{(int)e.NewValue}%";
+        ThemeManager.SetWindowOpacity(e.NewValue / 100.0);
+        ApplyWindowOpacity(ThemeManager.WindowOpacity);
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Shared.cs b/src/MandoCode.Desktop/MainWindow.Shared.cs
new file mode 100644
index 0000000..bd7fd8d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Shared.cs
@@ -0,0 +1,129 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Appearance-page live preview — a real miniature transcript
+    // ============================================================
+    // Same shell + theme script as the tabs, so it shows EXACTLY what they show (background
+    // image, boxed messages, E-Ink dithering, W98 chrome). Failures never break settings —
+    // the preview is a luxury.
+
+    private bool _previewWebReady;
+
+    private async void InitBgPreview()
+    {
+        try
+        {
+            await BgPreviewWeb.EnsureCoreWebView2Async();
+            var core = BgPreviewWeb.CoreWebView2;
+            core.Settings.AreDefaultContextMenusEnabled = false;
+
+            // Same virtual hosts the tabs map: assets (highlight.js) + userdata (bg image).
+            try
+            {
+                core.SetVirtualHostNameToFolderMapping(
+                    "mandocode.assets",
+                    System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "web"),
+                    Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
+            }
+            catch { /* already mapped / missing assets — preview still renders */ }
+            try
+            {
+                Directory.CreateDirectory(ThemeManager.UserDataFolder);
+                core.SetVirtualHostNameToFolderMapping(
+                    "mandocode.userdata", ThemeManager.UserDataFolder,
+                    Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
+            }
+            catch { /* already mapped / no user-data folder — preview still renders */ }
+
+            core.NavigationCompleted += (_, _) =>
+            {
+                if (_previewWebReady) return;
+                _previewWebReady = true;
+                _ = SeedBgPreviewAsync();
+            };
+            core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
+        }
+        catch { /* no preview — settings still fully functional */ }
+    }
+
+    private async Task SeedBgPreviewAsync()
+    {
+        try
+        {
+            var blocks =
+                _html.UserEcho("how does this look?") +
+                _html.AssistantCard(
+                    "Like this — the image fades, the text never does.\n\n" +
+                    "Inline `code` and a block, to judge every surface:\n\n" +
+                    "```csharp\nvar vibe = \"immaculate\";\n```");
+            await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+                "window.__append(" + JsonSerializer.Serialize(blocks) + ");");
+            await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+                ThemeManager.BuildTranscriptScript(ThemeManager.Current));
+        }
+        catch { /* preview seeding is cosmetic; the real transcript is unaffected */ }
+    }
+
+    private void OnUi(Action action)
+    {
+        if (_dispatcher.HasThreadAccess) action();
+        else _dispatcher.TryEnqueue(() => action());
+    }
+
+    private void Root_KeyDown(object sender, KeyRoutedEventArgs e)
+    {
+        if (e.Key == VirtualKey.Escape) { ActiveChat?.HandleEscape(); return; }
+
+        // Ctrl+`  toggles the terminal;  Ctrl+Shift+`  opens a new shell tab (VS Code parity).
+        // 192 == VK_OEM_3 (backtick/tilde). Handled here rather than via a KeyboardAccelerator:
+        // WinUI fast-fails natively when an accelerator is registered on an OEM key.
+        if (e.Key == (VirtualKey)192 && IsDown(VirtualKey.Control))
+        {
+            e.Handled = true;
+            if (IsDown(VirtualKey.Shift)) { OpenTerminalPanel(); _terminal!.NewTerminalTab(); }
+            else ToggleTerminal();
+        }
+    }
+
+    private static bool IsDown(VirtualKey key) =>
+        Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(key)
+            .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+
+    private void ApplyThemeToAllTabs()
+    {
+        foreach (var tab in _tabs) tab.View.ApplyTheme();
+        // The appearance preview is a transcript too — it re-themes with everyone else.
+        if (_previewWebReady && BgPreviewWeb.CoreWebView2 != null)
+            _ = BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
+                ThemeManager.BuildTranscriptScript(ThemeManager.Current));
+    }
+
+    private void CopyToClipboard(string text)
+    {
+        var package = new DataPackage();
+        package.SetText(text);
+        Clipboard.SetContent(package);
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Skills.cs b/src/MandoCode.Desktop/MainWindow.Skills.cs
new file mode 100644
index 0000000..d5bf840
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Skills.cs
@@ -0,0 +1,456 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Skills page — global (user) skills. All file work lives in SkillCoordinator; this is just
+    // the UI + the fan-out call that makes a change land in every open agent's prompt.
+    // ============================================================
+
+    private string? _editingSkillFolder;
+
+    // Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter).
+    private List _allSkillRows = new();
+
+    private void RefreshSkillsList()
+    {
+        _allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
+        {
+            Name = s.Name,
+            Description = s.Description,
+            Body = s.Body,
+            FolderPath = s.FolderPath,
+            Enabled = s.Enabled,
+        }).ToList();
+
+        ApplySkillFilter();
+    }
+
+    private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
+        ApplySkillFilter();
+
+    private string _skillFilter = "all";
+
+    private void SkillFilter_Click(object sender, RoutedEventArgs e)
+    {
+        _skillFilter = (string)((FrameworkElement)sender).Tag;
+        // Single-select: light the chosen chip, clear the rest.
+        SkillFilterAll.IsChecked = _skillFilter == "all";
+        SkillFilterEnabled.IsChecked = _skillFilter == "enabled";
+        SkillFilterDisabled.IsChecked = _skillFilter == "disabled";
+        SkillFilterLarge.IsChecked = _skillFilter == "large";
+        ApplySkillFilter();
+    }
+
+    /// Applies the search text + active chip, then groups the result into Enabled/Disabled
+    /// sections. Runs on every refresh and keystroke, so filters survive enable/install/delete.
+    private void ApplySkillFilter()
+    {
+        var q = SkillSearchBox.Text?.Trim() ?? "";
+        IEnumerable filtered = _allSkillRows;
+        if (!string.IsNullOrEmpty(q))
+            filtered = filtered.Where(r =>
+                r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+                r.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
+        filtered = _skillFilter switch
+        {
+            "enabled" => filtered.Where(r => r.Enabled),
+            "disabled" => filtered.Where(r => !r.Enabled),
+            "large" => filtered.Where(r => r.IsLarge),
+            _ => filtered,
+        };
+        var shown = filtered.ToList();
+
+        // Group by state — Enabled first, Disabled below; empty sections omitted.
+        var groups = new List();
+        var en = shown.Where(r => r.Enabled).ToList();
+        var dis = shown.Where(r => !r.Enabled).ToList();
+        if (en.Count > 0) groups.Add(new SkillRowGroup($"Enabled ({en.Count})", en));
+        if (dis.Count > 0) groups.Add(new SkillRowGroup($"Disabled ({dis.Count})", dis));
+
+        var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
+        SkillsList.ItemsSource = cvs.View;
+
+        // Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it.
+        SkillEditButton.IsEnabled = false;
+        SkillDeleteButton.IsEnabled = false;
+
+        var total = _allSkillRows.Count;
+        var enabledTotal = _allSkillRows.Count(r => r.Enabled);
+        var active = q.Length > 0 || _skillFilter != "all";
+        if (total == 0)
+            SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one.  ({_skillCoordinator.UserSkillsDirectory})";
+        else if (active)
+            SkillsPageStatus.Text = $"{shown.Count} of {total} shown  ·  {enabledTotal} enabled";
+        else
+            SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled  ·  {_skillCoordinator.UserSkillsDirectory}";
+    }
+
+    /// Reload every agent's skill set + prompt, then re-render the list and report.
+    private async Task ApplySkillChangeAsync(string status)
+    {
+        await _skillCoordinator.ReloadAllAsync();
+        RefreshSkillsList();
+        SkillsPageStatus.Text = status;
+    }
+
+    private void SkillRefresh_Click(object sender, RoutedEventArgs e) => RefreshSkillsList();
+
+    private void SkillsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        var has = SkillsList.SelectedItem is SkillRow;
+        SkillEditButton.IsEnabled = has;
+        SkillDeleteButton.IsEnabled = has;
+    }
+
+    private void SkillsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+    {
+        if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
+    }
+
+    private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
+    {
+        // Toggled also fires while the list realizes rows and binds IsOn from the row. In that case
+        // the new state equals the row's stored state — a no-op we must ignore, or realizing the
+        // list would rewrite files. A real user flip makes the two differ.
+        if (sender is not ToggleSwitch sw || sw.DataContext is not SkillRow row) return;
+        if (sw.IsOn == row.Enabled) return;
+
+        try
+        {
+            _skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
+            await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
+        }
+        catch (Exception ex)
+        {
+            SkillsPageStatus.Text = ex.Message;
+        }
+    }
+
+    private void SkillNew_Click(object sender, RoutedEventArgs e) => OpenSkillEditor(null);
+
+    private void SkillEdit_Click(object sender, RoutedEventArgs e)
+    {
+        if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
+    }
+
+    private async void SkillDelete_Click(object sender, RoutedEventArgs e)
+    {
+        if (SkillsList.SelectedItem is not SkillRow row) return;
+
+        var dialog = new ContentDialog
+        {
+            Title = "Delete skill",
+            Content = $"Delete “{row.Name}”? This removes its folder from disk and can't be undone.",
+            PrimaryButtonText = "Delete",
+            CloseButtonText = "Cancel",
+            DefaultButton = ContentDialogButton.Close,
+            XamlRoot = Content.XamlRoot,
+        };
+        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+        try
+        {
+            _skillCoordinator.DeleteSkill(row.FolderPath);
+            await ApplySkillChangeAsync($"Deleted “{row.Name}”.");
+        }
+        catch (Exception ex)
+        {
+            SkillsPageStatus.Text = ex.Message;
+        }
+    }
+
+    private void SkillOpenFolder_Click(object sender, RoutedEventArgs e)
+    {
+        var dir = _skillCoordinator.UserSkillsDirectory;
+        try { System.IO.Directory.CreateDirectory(dir); }
+        catch (Exception ex) { SkillsPageStatus.Text = ex.Message; return; }
+        if (ShellOpen.Try(dir) is { } err) SkillsPageStatus.Text = err.Message;
+    }
+
+    // ---- Skill editor modal ----
+
+    private void OpenSkillEditor(SkillRow? row)
+    {
+        Sk_StatusBar.IsOpen = false;
+        if (row == null)
+        {
+            _editingSkillFolder = null;
+            SkillEditorTitle.Text = "New skill";
+            Sk_Name.Text = "";
+            Sk_Description.Text = "";
+            Sk_Body.Text = "";
+        }
+        else
+        {
+            _editingSkillFolder = row.FolderPath;
+            SkillEditorTitle.Text = "Edit skill";
+            Sk_Name.Text = row.Name;
+            Sk_Description.Text = row.Description;
+            Sk_Body.Text = row.Body;
+        }
+
+        // Reset the AI panel and default its model to the active agent's (still changeable).
+        Sk_AiIntent.Text = "";
+        SetSkillAiBusy(false, "");
+        _ = LoadSkillAuthorModelsAsync(_sessions.Active?.Controller.ModelName ?? "");
+
+        UpdateSkillBodySize();   // explicit: setting Text="" above won't fire TextChanged if already empty
+        SkillEditorOverlay.Visibility = Visibility.Visible;
+        Sk_Name.Focus(FocusState.Programmatic);
+    }
+
+    private void Sk_Body_TextChanged(object sender, TextChangedEventArgs e) => UpdateSkillBodySize();
+
+    /// Live size readout for the instructions body — approximate tokens, gold when large,
+    /// matching the size column in the skills list.
+    private void UpdateSkillBodySize()
+    {
+        var chars = Sk_Body.Text?.Length ?? 0;
+        var tokens = (chars + 3) / 4;
+        var large = tokens >= 2000;
+        Sk_BodySize.Text = (tokens >= 1000 ? $"≈{tokens / 1000.0:0.0}k tokens" : $"≈{tokens} tokens")
+            + (large ? " · large — heavy on local models" : "");
+        Sk_BodySize.Foreground = new SolidColorBrush(
+            ThemeManager.C(large ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
+    }
+
+    /// Fills the AI model dropdown: the active agent's model shown selected instantly, then
+    /// the full installed-model list streamed in behind it. Mirrors the snapshot picker.
+    private async Task LoadSkillAuthorModelsAsync(string activeModel)
+    {
+        if (string.IsNullOrWhiteSpace(activeModel))
+        {
+            Sk_AiModel.ItemsSource = null;
+            return;
+        }
+
+        var current = new ModelChoice(activeModel, MandoCodeConfig.IsCloudModel(activeModel));
+        Sk_AiModel.ItemsSource = new List { current };
+        Sk_AiModel.SelectedIndex = 0;
+
+        var result = await _controller.LoadAvailableModelsAsync();
+        if (!result.Ok || result.Models.Count == 0) return;
+
+        // If the user already picked another model while the list loaded, don't clobber it.
+        if ((Sk_AiModel.SelectedItem as ModelChoice)?.Name != activeModel) return;
+
+        var choices = result.Models
+            .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
+            .ToList();
+        if (!choices.Any(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase)))
+            choices.Insert(0, current);
+
+        Sk_AiModel.ItemsSource = choices;
+        Sk_AiModel.SelectedItem =
+            choices.First(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private void SetSkillAiBusy(bool busy, string status)
+    {
+        Sk_AiSpin.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+        Sk_AiSpin.IsActive = busy;
+        Sk_GenerateButton.IsEnabled = !busy;
+        Sk_RefineButton.IsEnabled = !busy;
+        Sk_AiStatus.Text = status;
+    }
+
+    private async void SkillGenerate_Click(object sender, RoutedEventArgs e)
+    {
+        var intent = Sk_AiIntent.Text.Trim();
+        if (intent.Length == 0) { Sk_AiStatus.Text = "Describe what the skill should do first."; return; }
+        if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
+
+        Sk_StatusBar.IsOpen = false;
+        SetSkillAiBusy(true, "Drafting…");
+        try
+        {
+            var endpoint = _sessions.Active!.Config.OllamaEndpoint;
+            var draft = await SkillAuthor.GenerateAsync(endpoint, model.Name, intent);
+            if (!string.IsNullOrWhiteSpace(draft.Name)) Sk_Name.Text = draft.Name;
+            if (!string.IsNullOrWhiteSpace(draft.Description)) Sk_Description.Text = draft.Description;
+            if (!string.IsNullOrWhiteSpace(draft.Body)) Sk_Body.Text = draft.Body;
+            SetSkillAiBusy(false, "Draft ready — review and edit before saving.");
+        }
+        catch (Exception ex)
+        {
+            SetSkillAiBusy(false, "");
+            ShowSkillEditorError($"AI draft failed: {ex.Message}");
+        }
+    }
+
+    private async void SkillRefine_Click(object sender, RoutedEventArgs e)
+    {
+        var instruction = Sk_AiIntent.Text.Trim();
+        if (instruction.Length == 0) { Sk_AiStatus.Text = "Type what to change in the box above."; return; }
+        if (Sk_Body.Text.Trim().Length == 0) { Sk_AiStatus.Text = "Nothing to refine yet — write or generate instructions first."; return; }
+        if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
+
+        Sk_StatusBar.IsOpen = false;
+        SetSkillAiBusy(true, "Refining…");
+        try
+        {
+            var endpoint = _sessions.Active!.Config.OllamaEndpoint;
+            var body = await SkillAuthor.RefineAsync(endpoint, model.Name, Sk_Body.Text, instruction);
+            if (!string.IsNullOrWhiteSpace(body)) Sk_Body.Text = body;
+            SetSkillAiBusy(false, "Instructions updated.");
+        }
+        catch (Exception ex)
+        {
+            SetSkillAiBusy(false, "");
+            ShowSkillEditorError($"AI refine failed: {ex.Message}");
+        }
+    }
+
+    private void SkillEditorCancel_Click(object sender, RoutedEventArgs e) =>
+        SkillEditorOverlay.Visibility = Visibility.Collapsed;
+
+    private void ShowSkillEditorError(string message)
+    {
+        Sk_StatusBar.Title = "Check the form";
+        Sk_StatusBar.Message = message;
+        Sk_StatusBar.IsOpen = true;
+    }
+
+    private async void SkillEditorSave_Click(object sender, RoutedEventArgs e)
+    {
+        var name = Sk_Name.Text.Trim();
+        if (name.Length == 0) { ShowSkillEditorError("Give the skill a name."); return; }
+        if (Sk_Body.Text.Trim().Length == 0) { ShowSkillEditorError("The instructions can't be empty."); return; }
+
+        try
+        {
+            _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
+            SkillEditorOverlay.Visibility = Visibility.Collapsed;
+            await ApplySkillChangeAsync($"Saved “{name}”.");
+        }
+        catch (Exception ex)
+        {
+            ShowSkillEditorError(ex.Message);
+        }
+    }
+
+    // ---- Skill install modal ----
+
+    private void SkillInstall_Click(object sender, RoutedEventArgs e)
+    {
+        Sk_InstallMode.SelectedIndex = 0;   // Git by default
+        Sk_InstallGitPanel.Visibility = Visibility.Visible;
+        Sk_InstallLocalPanel.Visibility = Visibility.Collapsed;
+        Sk_InstallGitUrl.Text = "";
+        Sk_InstallLocalPath.Text = "";
+        Sk_InstallStatus.Text = "";
+        Sk_InstallError.IsOpen = false;
+        Sk_InstallSpin.IsActive = false;
+        Sk_InstallSpin.Visibility = Visibility.Collapsed;
+        SkillInstallConfirmButton.IsEnabled = true;
+        SkillInstallOverlay.Visibility = Visibility.Visible;
+        Sk_InstallGitUrl.Focus(FocusState.Programmatic);
+    }
+
+    private void SkillInstallMode_Changed(object sender, SelectionChangedEventArgs e)
+    {
+        // Fires during InitializeComponent before the panels exist.
+        if (Sk_InstallGitPanel == null || Sk_InstallLocalPanel == null) return;
+        var local = Sk_InstallMode.SelectedIndex == 1;
+        Sk_InstallGitPanel.Visibility = local ? Visibility.Collapsed : Visibility.Visible;
+        Sk_InstallLocalPanel.Visibility = local ? Visibility.Visible : Visibility.Collapsed;
+    }
+
+    private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
+    {
+        var picker = new Windows.Storage.Pickers.FolderPicker();
+        picker.FileTypeFilter.Add("*");
+        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+        var folder = await picker.PickSingleFolderAsync();
+        if (folder != null) Sk_InstallLocalPath.Text = folder.Path;
+    }
+
+    private async void SkillBrowseZip_Click(object sender, RoutedEventArgs e)
+    {
+        var picker = new Windows.Storage.Pickers.FileOpenPicker();
+        picker.FileTypeFilter.Add(".zip");
+        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
+        var file = await picker.PickSingleFileAsync();
+        if (file != null) Sk_InstallLocalPath.Text = file.Path;
+    }
+
+    private void SkillInstallCancel_Click(object sender, RoutedEventArgs e) =>
+        SkillInstallOverlay.Visibility = Visibility.Collapsed;
+
+    private async void SkillInstallConfirm_Click(object sender, RoutedEventArgs e)
+    {
+        var source = (Sk_InstallMode.SelectedIndex == 1 ? Sk_InstallLocalPath.Text : Sk_InstallGitUrl.Text).Trim();
+        if (source.Length == 0)
+        {
+            Sk_InstallError.Title = "Nothing to install";
+            Sk_InstallError.Message = "Enter a git URL, a .zip path, or a folder path.";
+            Sk_InstallError.IsOpen = true;
+            return;
+        }
+
+        Sk_InstallError.IsOpen = false;
+        Sk_InstallSpin.Visibility = Visibility.Visible;
+        Sk_InstallSpin.IsActive = true;
+        Sk_InstallStatus.Text = "Fetching…";
+        SkillInstallConfirmButton.IsEnabled = false;
+
+        try
+        {
+            // Clone / extract / copy can block; keep it off the UI thread.
+            var result = await Task.Run(() => _skillCoordinator.InstallFrom(source));
+
+            // Nothing found: keep the modal open so the user can fix the source, and say what a
+            // valid source looks like. (finally still resets the spinner/button below.)
+            if (result.Installed.Count == 0 && result.Skipped.Count == 0)
+            {
+                Sk_InstallError.Title = "No skills found";
+                Sk_InstallError.Message = "That source has no SKILL.md. A skill is a folder containing a SKILL.md file — point at one, or at a folder/repo/.zip that holds them (nested is fine).";
+                Sk_InstallError.IsOpen = true;
+                return;
+            }
+
+            SkillInstallOverlay.Visibility = Visibility.Collapsed;
+            await _skillCoordinator.ReloadAllAsync();
+            RefreshSkillsList();
+
+            var parts = new List();
+            if (result.Installed.Count > 0) parts.Add($"installed {string.Join(", ", result.Installed)}");
+            if (result.Skipped.Count > 0) parts.Add($"skipped (already present): {string.Join(", ", result.Skipped)}");
+            SkillsPageStatus.Text = string.Join("  ·  ", parts);
+        }
+        catch (Exception ex)
+        {
+            Sk_InstallError.Title = "Install failed";
+            Sk_InstallError.Message = ex.Message;
+            Sk_InstallError.IsOpen = true;
+        }
+        finally
+        {
+            Sk_InstallSpin.IsActive = false;
+            Sk_InstallSpin.Visibility = Visibility.Collapsed;
+            Sk_InstallStatus.Text = "";
+            SkillInstallConfirmButton.IsEnabled = true;
+        }
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Snapshots.cs b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
new file mode 100644
index 0000000..39f9af1
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
@@ -0,0 +1,242 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Snapshots panel — global (the store is app-wide), toggled from the rail. Docked left at
+    // ~37% width so the active chat stays visible; Import arms the selected agent's next message.
+    // ============================================================
+
+    private void NavSnapshots_Click(object sender, RoutedEventArgs e)
+    {
+        if (_snapshotsPanelOpen) CloseLeftPanel();
+        else OpenSnapshots();
+    }
+
+    private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
+
+    private void OpenSnapshots()
+    {
+        MarkSnapshotsSeen();   // opening the panel IS reading it — clear the unread badge
+        PopulateSnapshots();
+        ShowLeftPanel(SnapshotsPanel, snapshots: true);
+    }
+
+    /// Shows one of the two docked panels (Snapshots/History), swapping if the other was
+    /// already up (the column stays out — only the contents change) and sliding it in otherwise.
+    private void ShowLeftPanel(Border panel, bool snapshots)
+    {
+        bool wasOpen = _snapshotsPanelOpen || _historyPanelOpen;
+        _snapshotsPanelOpen = snapshots;
+        _historyPanelOpen = !snapshots;
+        SnapshotsPanel.Visibility = snapshots ? Visibility.Visible : Visibility.Collapsed;
+        HistoryPanel.Visibility = snapshots ? Visibility.Collapsed : Visibility.Visible;
+        RefreshNavIcons();
+        if (wasOpen) return;   // column already at width — contents swapped, no re-slide
+
+        // 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);
+        AnimateLeftColumn(target, hideOnDone: null);
+    }
+
+    private void CloseLeftPanel()
+    {
+        var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel
+                   : _historyPanelOpen ? HistoryPanel : null;
+        _snapshotsPanelOpen = false;
+        _historyPanelOpen = false;
+        RefreshNavIcons();
+        AnimateLeftColumn(0, hideOnDone: toHide);
+    }
+
+    /// Tweens the docked 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. , when set, is
+    /// collapsed once a close tween lands.
+    private void AnimateLeftColumn(double toPx, FrameworkElement? 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;
+        _snapAnimHide = 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 (_snapAnimHide != null) _snapAnimHide.Visibility = Visibility.Collapsed;
+            }
+        };
+        CompositionTarget.Rendering += _snapAnimHandler;
+    }
+
+    private void OnSnapshotsChanged()
+    {
+        // A change while you're looking at the panel is already seen; otherwise it's a new unread.
+        if (_snapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
+        else RefreshSnapshotsBadge();
+    }
+
+    /// Marks every current snapshot as seen (opening the panel, or a change while it's open),
+    /// clearing the rail badge. Persisted so the badge doesn't re-light on relaunch.
+    private void MarkSnapshotsSeen()
+    {
+        _snapshotsSeenAt = DateTimeOffset.Now;
+        SavePanelState();
+        RefreshSnapshotsBadge();
+    }
+
+    /// Current text in the snapshots search box; empty means "show everything".
+    private string _snapshotFilter = "";
+
+    /// Project labels whose group is folded shut. Survives repopulation (search, import,
+    /// delete) so a collapse the user made doesn't spring back open on the next keystroke.
+    private readonly HashSet _collapsedSnapshotGroups = new();
+
+    // "Last opened" watermarks — the rail badges show how many snapshots/closed conversations are
+    // newer than these, i.e. unread since the last visit. Persisted in panel-state.json.
+    private DateTimeOffset? _snapshotsSeenAt;
+    private DateTimeOffset? _historySeenAt;
+
+    /// Writes both panels' fold state and seen-watermarks to disk (survives relaunch).
+    private void SavePanelState() => PanelState.Save(new PanelStateShape(
+        _collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
+        _snapshotsSeenAt, _historySeenAt));
+
+    // The group object is kept in sync (not just the set) so that when the ListView recycles a
+    // container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
+    private void SnapshotGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
+    {
+        if (sender.Tag is not SnapshotGroup g) return;
+        g.IsExpanded = true;
+        _collapsedSnapshotGroups.Remove(g.Project);
+        SavePanelState();
+    }
+
+    private void SnapshotGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
+    {
+        if (sender.Tag is not SnapshotGroup g) return;
+        g.IsExpanded = false;
+        _collapsedSnapshotGroups.Add(g.Project);
+        SavePanelState();
+    }
+
+    private void SnapshotsSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+    {
+        // Only react to the user typing — not to programmatic Text changes on repopulate.
+        if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
+        _snapshotFilter = sender.Text?.Trim() ?? "";
+        PopulateSnapshots();
+    }
+
+    private static bool Matches(ContextSnapshot s, string q) =>
+        s.DisplayTitle.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || s.OriginModel.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || s.SummarizerModel.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
+        || (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
+
+    private void PopulateSnapshots()
+    {
+        var all = _snapshotStore.Items;   // newest-first copy of the shared store
+        var storeEmpty = all.Count == 0;
+
+        // The search box only earns its space once there's something to search.
+        SnapshotsSearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
+
+        // Explain the disabled Import buttons when there's a snapshot but no agent to import into.
+        SnapshotsNoAgentNotice.IsOpen = !storeEmpty && _sessions.Active == null;
+
+        var q = _snapshotFilter;
+        var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
+
+        // Group by project, preserving the store's newest-first order within each group and
+        // ordering the groups by their most-recent snapshot (so the freshest project leads).
+        // Each group carries its remembered expand/collapse state so folding a project sticks
+        // across searches and imports (which both rebuild this list).
+        var groups = filtered
+            .GroupBy(s => s.ProjectLabel)
+            .OrderByDescending(g => g.Max(s => s.CapturedAt))
+            .Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
+            .ToList();
+
+        SnapshotsList.ItemsSource = groups;
+
+        var nothingToShow = groups.Count == 0;
+        SnapshotsEmpty.Text = storeEmpty
+            ? "No snapshots yet. When you switch a tab's model — or pick Take snapshot from a tab's ⋯ menu — you'll be offered to save the conversation as a snapshot, summarized by a model you choose."
+            : $"No snapshots match “{q}”.";
+        SnapshotsEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
+        SnapshotsScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
+        RefreshSnapshotsBadge();
+    }
+
+    private void RefreshSnapshotsBadge()
+    {
+        // Unread = snapshots captured after the last visit. Never-visited (null) counts them all.
+        var n = _snapshotsSeenAt is { } seen
+            ? _snapshotStore.Items.Count(s => s.CapturedAt > seen)
+            : _snapshotStore.Count;
+        NavSnapshotsBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
+        NavSnapshotsBadgeText.Text = n > 99 ? "99+" : n.ToString();
+    }
+
+    /// Each Import button disables itself when there's no agent to import into — the action
+    /// arms an agent's next message, so it's meaningless with none open. Re-evaluated on load, and the
+    /// list is repopulated when the agent count crosses zero (so open buttons refresh too).
+    private void SnapshotImport_Loaded(object sender, RoutedEventArgs e)
+    {
+        if (sender is Button b) b.IsEnabled = _sessions.Active != null;
+    }
+
+    private void SnapshotImport_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
+        var target = _selected?.View;
+        if (target == null) return;   // no agent open — nothing to import into (button is disabled too)
+
+        target.Session.Controller.ImportContext(snap);   // arms the active agent's next message
+        SwitchPage("chat");   // so the "context armed" note is visible in the active tab
+        if (_snapshotsPanelOpen) CloseLeftPanel();   // get out of the way — the chat is where the confirmation shows
+        target.FocusInput();
+    }
+
+    private void SnapshotDelete_Click(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
+        _snapshotStore.Remove(snap);
+        PopulateSnapshots();
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Split.cs b/src/MandoCode.Desktop/MainWindow.Split.cs
new file mode 100644
index 0000000..c42b553
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Split.cs
@@ -0,0 +1,346 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Split / compare view — two agents side by side. The compare PAIR (_compareA left, _compareB
+    // right) is a remembered, explicit choice: set only by the Split button and the compare-bar
+    // pickers, NEVER by clicking a tab. The split is shown whenever the active tab (_selected) is one
+    // of the pair; clicking any other tab shows that agent normally while the pair waits, and
+    // clicking a paired tab brings the split back. Both panes are ordinary tab views moved between
+    // grid columns via ApplyPaneLayout — never reparented, so their WebViews survive.
+    // ============================================================
+
+    private ChatTabEntry? _compareA;   // left pane
+    private ChatTabEntry? _compareB;   // right pane
+    private double _splitLeftFraction = 0.5;   // divider position, preserved across page visits
+    private bool _syncingSplitCombos;
+    private bool _draggingPane;
+
+    /// A valid, distinct compare pair is configured (both agents still open).
+    private bool HasComparePair =>
+        _compareA != null && _compareB != null
+        && _tabs.Contains(_compareA) && _tabs.Contains(_compareB)
+        && !ReferenceEquals(_compareA, _compareB);
+
+    /// The split is actually being shown right now: a pair exists, we're on the chat page,
+    /// and the active tab is one of the two paired agents (clicking any other agent shows it single).
+    private bool SplitActive =>
+        HasComparePair && _currentPage == "chat" && _selected != null
+        && (ReferenceEquals(_selected, _compareA) || ReferenceEquals(_selected, _compareB));
+
+    private void SplitButton_Click(object sender, RoutedEventArgs e)
+    {
+        if (HasComparePair)
+        {
+            // Toggle: showing the split → turn compare off; pair configured but viewing another
+            // agent → jump back into the split.
+            if (SplitActive) ExitSplit();
+            else if (_compareA != null) SelectTab(_compareA);
+            return;
+        }
+        if (_tabs.Count < 2 || _selected == null) return;   // button is disabled here anyway
+
+        _compareA = _selected;
+        _compareB = _tabs.FirstOrDefault(t => !ReferenceEquals(t, _selected));
+        RefreshSplitCombos();
+        SwitchPage("chat");        // _selected is in the pair → ApplyPaneLayout shows the split
+        RefreshSplitButton();
+    }
+
+    private void ExitSplit_Click(object sender, RoutedEventArgs e) => ExitSplit();
+
+    private void ExitSplit()
+    {
+        _compareA = null;
+        _compareB = null;
+        ApplyPaneLayout();
+        RefreshSplitButton();
+    }
+
+    /// Places the visible agent view(s) into columns and sizes them. Single view: column 0
+    /// fills (divider + right column collapse to 0). Split: _compareA in column 0, _compareB in
+    /// column 2, divider between. Setting Grid.Column does NOT reparent, so WebViews are untouched.
+    private void ApplyPaneLayout()
+    {
+        var split = SplitActive;
+        var showingChat = _currentPage == "chat";
+
+        foreach (var tab in _tabs)
+        {
+            bool inPair = ReferenceEquals(tab, _compareA) || ReferenceEquals(tab, _compareB);
+            var visible = showingChat && (split ? inPair : ReferenceEquals(tab, _selected));
+            tab.View.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+            Grid.SetColumn(tab.View, split && ReferenceEquals(tab, _compareB) ? 2 : 0);
+        }
+
+        if (split)
+        {
+            PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
+            PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
+            PaneSplitCol.Width = GridLength.Auto;
+            PaneSplitter.Visibility = Visibility.Visible;
+            SplitBar.Visibility = Visibility.Visible;
+        }
+        else
+        {
+            PaneLeftCol.Width = new GridLength(1, GridUnitType.Star);
+            PaneSplitCol.Width = new GridLength(0);
+            PaneRightCol.Width = new GridLength(0);
+            PaneSplitter.Visibility = Visibility.Collapsed;
+            SplitBar.Visibility = Visibility.Collapsed;
+        }
+    }
+
+    /// Re-fills the two pane pickers and re-selects the sides. Items are plain STRINGS
+    /// (agent titles) selected by INDEX into  — deliberately NOT ComboBoxItem
+    /// objects: adding containers directly as items and rebuilding them makes WinUI's ComboBox throw
+    /// COMException 0x80070490 "Element not found" on the next selection. Each combo gets its own
+    /// list instance (a shared ItemsSource across two ComboBoxes is asking for trouble).
+    private void RefreshSplitCombos()
+    {
+        _syncingSplitCombos = true;
+        SplitLeftCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
+        SplitRightCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
+        SplitLeftCombo.SelectedIndex = _compareA == null ? -1 : _tabs.IndexOf(_compareA);
+        SplitRightCombo.SelectedIndex = _compareB == null ? -1 : _tabs.IndexOf(_compareB);
+        _syncingSplitCombos = false;
+    }
+
+    // Both pickers defer their ENTIRE reaction to the next dispatcher tick. A ComboBox raises
+    // SelectionChanged from inside a layout pass, and the reaction restructures the visual tree
+    // (moves a ChatTabView + its WebView between grid columns) and rebuilds the pickers — both
+    // illegal mid-layout / mid-event and the source of the App-level crash. Off the event, on a
+    // clean tick, they're safe. Picking an agent for one pane that's already the other pane swaps
+    // the two. The chosen agent becomes active, so the split stays on screen.
+    private void SplitLeftCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        if (_syncingSplitCombos) return;
+        var idx = SplitLeftCombo.SelectedIndex;
+        if (idx < 0 || idx >= _tabs.Count) return;
+        var entry = _tabs[idx];
+        DispatcherQueue.TryEnqueue(() =>
+        {
+            if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareA)) return;
+            if (ReferenceEquals(entry, _compareB)) _compareB = _compareA;   // swap sides
+            _compareA = entry;
+            RefreshSplitCombos();
+            SelectTab(entry);   // make the left pane active so the split stays shown
+        });
+    }
+
+    private void SplitRightCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+    {
+        if (_syncingSplitCombos) return;
+        var idx = SplitRightCombo.SelectedIndex;
+        if (idx < 0 || idx >= _tabs.Count) return;
+        var entry = _tabs[idx];
+        DispatcherQueue.TryEnqueue(() =>
+        {
+            if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareB)) return;
+            if (ReferenceEquals(entry, _compareA)) _compareA = _compareB;   // swap sides
+            _compareB = entry;
+            RefreshSplitCombos();
+            SelectTab(entry);   // make the right pane active so the split stays shown
+        });
+    }
+
+    /// Keeps the compare pair valid after the agent set changes. If either paired agent was
+    /// closed the pair is dropped (compare turns off); otherwise the pickers are resynced.
+    private void ValidateSplit()
+    {
+        if (_compareA == null && _compareB == null) return;   // no compare configured
+        if (!HasComparePair)
+        {
+            _compareA = null;
+            _compareB = null;
+            ApplyPaneLayout();
+            RefreshSplitButton();
+            return;
+        }
+        RefreshSplitCombos();
+        ApplyPaneLayout();
+        RefreshSplitButton();
+    }
+
+    private void RefreshSplitButton()
+    {
+        SplitButton.IsEnabled = HasComparePair || _tabs.Count >= 2;
+        var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+        var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+        // Accent whenever a compare pair is configured — even while viewing a non-paired agent — so
+        // it reads as "compare is on; click a paired tab (or me) to see it."
+        SplitButtonIcon.Foreground = HasComparePair ? accent : normal;
+    }
+
+    // ---- divider drag: repartition the two panes' star widths by pointer X over TabHost ----
+    private void PaneSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+    {
+        _draggingPane = true;
+        ((UIElement)sender).CapturePointer(e.Pointer);
+    }
+
+    private void PaneSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingPane) return;
+        var w = TabHost.ActualWidth;
+        if (w <= 0) return;
+        var x = e.GetCurrentPoint(TabHost).Position.X;
+        _splitLeftFraction = Math.Clamp(x / w, 0.2, 0.8);   // keep both panes usable
+        PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
+        PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
+    }
+
+    private void PaneSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingPane) return;
+        _draggingPane = false;
+        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+    }
+
+    /// Paints the custom chat background image behind the empty state, so closing every
+    /// agent leaves the same backdrop you'd see behind a transcript — same file and opacity. Hidden
+    /// when there's no image set, or when an agent is open (its own WebView paints it then). Loaded
+    /// via a StorageFile stream, the reliable path for an arbitrary filesystem image in unpackaged
+    /// WinUI; best-effort, so a missing/locked file just falls back to the flat themed colour.
+    private async Task RefreshEmptyBackgroundAsync()
+    {
+        var show = _currentPage == "chat" && _tabs.Count == 0;
+        var file = ThemeManager.ChatBackgroundFile;
+        if (!show || string.IsNullOrEmpty(file) || !File.Exists(file))
+        {
+            EmptyBgImage.Visibility = Visibility.Collapsed;
+            EmptyBgImage.Source = null;
+            return;
+        }
+        try
+        {
+            var sf = await Windows.Storage.StorageFile.GetFileFromPathAsync(file);
+            using var stream = await sf.OpenReadAsync();
+            var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage();
+            await bmp.SetSourceAsync(stream);
+            EmptyBgImage.Source = bmp;
+            EmptyBgImage.Opacity = ThemeManager.ChatBackgroundOpacity;
+            EmptyBgImage.Visibility = Visibility.Visible;
+        }
+        catch
+        {
+            EmptyBgImage.Visibility = Visibility.Collapsed;
+        }
+    }
+
+    private void RefreshTabStrip()
+    {
+        var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+        var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"];
+        var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+        var background = (SolidColorBrush)Application.Current.Resources["MandoBackgroundBrush"];
+        var transparent = new SolidColorBrush(Colors.Transparent);
+
+        ChatTabEntry? pending = null;
+
+        foreach (var tab in _tabs)
+        {
+            var isSelected = ReferenceEquals(tab, _selected);
+            tab.Header.Background = isSelected ? background : transparent;
+            tab.Header.BorderBrush = isSelected ? accent : border;
+            tab.Label.Foreground = isSelected ? accent : dim;
+
+            tab.View.IsSelected = isSelected;
+            var badged = tab.View.IsApprovalOpen && !isSelected;
+            tab.Badge.Visibility = badged ? Visibility.Visible : Visibility.Collapsed;
+
+            // Toast for any approval you can't currently see: a background tab, OR the selected tab
+            // while you're away on Settings/MCP/Appearance (its chat — and the approval — is
+            // collapsed there, so without this you'd get no notice at all).
+            if (tab.View.IsApprovalOpen && (!isSelected || _currentPage != "chat"))
+                pending ??= tab;
+        }
+
+        // With several agents running, "an approval is waiting" is useless without saying where,
+        // so the toast names the agent and selecting it is one click.
+        _pendingApprovalTab = pending;
+        if (pending != null && !_approvalToastDismissed)
+        {
+            ApprovalToastText.Text = pending.View.ApprovalHeadline;
+            ApprovalToastTarget.Text = $"Click to review in \"{pending.View.Session.Title}\"";
+            ApprovalToast.Visibility = Visibility.Visible;
+        }
+        else
+        {
+            ApprovalToast.Visibility = Visibility.Collapsed;
+            if (pending == null) _approvalToastDismissed = false;   // next approval earns a fresh toast
+        }
+
+        RefreshNavIcons();
+        RefreshSplitButton();
+        LayoutTabStrip();
+    }
+
+    // Tabs stay a comfortable width when there's room, and only shrink once enough agents are open
+    // that they'd otherwise overflow — down to a floor, past which the strip scrolls instead.
+    private const double TabComfortableWidth = 200;
+    private const double TabMinWidth = 104;
+
+    private void LayoutTabStrip()
+    {
+        int count = _tabs.Count;
+        if (count == 0) return;
+
+        // The visible strip is the scroller's viewport; a later SizeChanged fixes up the first
+        // pass if it hasn't been measured yet (ActualWidth == 0 during early layout).
+        double viewport = TabScroller.ActualWidth;   // tabs only — the add button now lives outside
+        if (viewport <= 0) return;
+
+        double spacing = 4 * Math.Max(0, count - 1);         // 4px between adjacent tabs
+        double avail = viewport - spacing - 8;               // margin so rounding never forces a scrollbar
+
+        double per = Math.Max(TabMinWidth, Math.Min(TabComfortableWidth, avail / count));
+        foreach (var tab in _tabs)
+            tab.Header.Width = per;
+    }
+
+    private void TabScroller_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTabStrip();
+
+    // Mouse wheel scrolls the strip horizontally when there are more tabs than fit — a convenience
+    // on top of the visible scrollbar (which sits in a reserved bottom lane so it never overlaps
+    // the tabs). Touchpad / touch horizontal scrolling works natively.
+    private void TabScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
+    {
+        if (TabScroller.ScrollableWidth <= 0) return;   // everything fits; nothing to scroll
+        var delta = e.GetCurrentPoint(TabScroller).Properties.MouseWheelDelta;
+        TabScroller.ChangeView(TabScroller.HorizontalOffset - delta, null, null);
+        e.Handled = true;
+    }
+
+    private void ApprovalToast_Tapped(object sender, TappedRoutedEventArgs e)
+    {
+        if (_pendingApprovalTab != null) SelectTab(_pendingApprovalTab);
+    }
+
+    private void ApprovalToastDismiss_Click(object sender, RoutedEventArgs e)
+    {
+        _approvalToastDismissed = true;
+        ApprovalToast.Visibility = Visibility.Collapsed;
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs
new file mode 100644
index 0000000..93e037d
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs
@@ -0,0 +1,95 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Tabs
+    // ============================================================
+
+    /// One independent agent: its strip header and its chat surface.
+    private sealed class ChatTabEntry
+    {
+        public required Border Header { get; init; }
+        public required TextBlock Label { get; init; }
+        public required Ellipse Badge { get; init; }
+        public required ChatTabView View { get; init; }
+
+        /// 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; }
+    }
+
+    private readonly List _tabs = new();
+    private ChatTabEntry? _selected;
+    private ChatTabEntry? _pendingApprovalTab;
+    private bool _approvalToastDismissed;
+
+    /// 
+    /// The agent everything else acts on: Esc, the Settings page, the MCP page. Stays put while
+    /// you're looking at Settings — that's what makes "these settings belong to Agent 2" true.
+    /// 
+    private ChatTabView? ActiveChat => _selected?.View;
+
+    private void AddTab_Click(object sender, RoutedEventArgs e)
+    {
+        var entry = CreateChatTab();
+        _ = entry.View.InitializeAsync();
+        SaveWorkspace();
+    }
+
+    private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = null, string? restoreModel = null, string? persistKey = null)
+    {
+        var session = _sessions.CreateSession(projectRoot, persistKey);
+        if (!string.IsNullOrWhiteSpace(title)) session.Title = title;
+        var view = new ChatTabView(this, session, _html) { Visibility = Visibility.Collapsed };
+
+        view.SetupRequested += () => SwitchPage("settings");
+        view.McpEditorRequested += name =>
+        {
+            SwitchPage("mcp");
+            OpenMcpEditor(name);
+        };
+        view.ClipboardCopyRequested += CopyToClipboard;
+        view.ExitRequested += Close;
+        view.ApprovalStateChanged += _ => RefreshTabStrip();
+        view.HeaderChanged += v =>
+        {
+            var tab = _tabs.FirstOrDefault(t => ReferenceEquals(t.View, v));
+            if (tab != null) tab.Label.Text = v.Session.Title;
+            SaveWorkspace();   // renames, folder switches, and model switches all land here
+        };
+
+        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 };
+        _tabs.Add(entry);
+        TabStrip.Children.Add(header);
+        WireHeader(entry);
+
+        SelectTab(entry);
+        if (_snapshotsPanelOpen) PopulateSnapshots();   // an agent exists now → re-enable Import
+        if (HasComparePair) RefreshSplitCombos();       // include the new agent in the pane pickers
+        return entry;
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Terminal.cs b/src/MandoCode.Desktop/MainWindow.Terminal.cs
new file mode 100644
index 0000000..8ae9d72
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Terminal.cs
@@ -0,0 +1,245 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+    // ============================================================
+    // Integrated terminal (VS-style sliding shell panel)
+    // ============================================================
+
+    private bool _terminalOpen;
+    private bool _terminalMaximized;
+    private double _savedTerminalHeight;   // px — the user's last dragged size, restored on reopen
+    private double _preMaxHeight;           // px — height to restore to when un-maximizing
+    private Microsoft.UI.Dispatching.DispatcherQueueTimer? _termAnim;
+    private Controls.TerminalPanel? _terminal;   // created lazily on first open
+
+    // Maximized terminal leaves ~5% at the top (just the agent tab strip peeking through).
+    private double MaxTerminalHeight() => Math.Max(160, ContentColumnGrid.ActualHeight * 0.95);
+
+    /// 
+    /// Builds the terminal panel the first time it's needed and drops it into Grid.Row 3 of the
+    /// content column. Deferred so no WebView2 or shell process is created until the user opens a
+    /// terminal.
+    /// 
+    private Controls.TerminalPanel EnsureTerminal()
+    {
+        if (_terminal != null) return _terminal;
+
+        _terminal = new Controls.TerminalPanel { Visibility = Visibility.Collapsed };
+        Grid.SetRow(_terminal, 3);
+        ContentColumnGrid.Children.Add(_terminal);
+
+        // Each new shell opens in whichever agent tab is active at the time.
+        _terminal.WorkingDirectoryProvider = () => ActiveChat?.Session.ProjectRoot.ProjectRoot;
+        _terminal.CloseRequested += (_, _) => CloseTerminalPanel();
+        _terminal.MaximizeRequested += (_, _) => ToggleMaximizeTerminal();
+        return _terminal;
+    }
+
+    private void NavTerminal_Click(object sender, RoutedEventArgs e) => ToggleTerminal();
+
+    private void ToggleTerminal()
+    {
+        if (_terminalOpen) CloseTerminalPanel();
+        else OpenTerminalPanel();
+    }
+
+    private void OpenTerminalPanel()
+    {
+        var term = EnsureTerminal();
+        if (_terminalOpen) { term.FocusActive(); return; }
+        _terminalOpen = true;
+        RefreshNavIcons();
+
+        term.Visibility = Visibility.Visible;
+        TerminalSplitter.Visibility = Visibility.Visible;
+        term.EnsureStartedAsync();
+
+        double target = _savedTerminalHeight > 0 ? _savedTerminalHeight : DefaultTerminalHeight();
+        AnimateTerminalHeight(target, onDone: () => term.Refit());
+    }
+
+    private void CloseTerminalPanel()
+    {
+        if (!_terminalOpen) return;
+        _terminalOpen = false;
+        RefreshNavIcons();
+
+        double current = TerminalRow.Height.Value;
+        if (current > 40) _savedTerminalHeight = current;   // remember size for next time
+
+        AnimateTerminalHeight(0, onDone: () =>
+        {
+            if (_terminal != null) _terminal.Visibility = Visibility.Collapsed;
+            TerminalSplitter.Visibility = Visibility.Collapsed;
+            ActiveChat?.FocusInput();
+        });
+    }
+
+    /// Expand the terminal to ~95% of the window (5% left at top), or restore its prior size.
+    private void ToggleMaximizeTerminal()
+    {
+        if (!_terminalOpen) { OpenTerminalPanel(); return; }   // first open lands at the default size
+
+        if (_terminalMaximized)
+        {
+            _terminalMaximized = false;
+            double restore = _preMaxHeight > 40 ? _preMaxHeight : DefaultTerminalHeight();
+            AnimateTerminalHeight(restore, onDone: () => _terminal?.Refit());
+        }
+        else
+        {
+            _terminalMaximized = true;
+            _preMaxHeight = TerminalRow.Height.Value;
+            AnimateTerminalHeight(MaxTerminalHeight(), onDone: () => _terminal?.Refit());
+        }
+        _terminal?.SetMaximized(_terminalMaximized);
+    }
+
+    private double DefaultTerminalHeight()
+    {
+        double h = ContentColumnGrid.ActualHeight;
+        if (h <= 0) h = 800;
+        return Math.Clamp(h * 0.30, 140, h * 0.7);
+    }
+
+    /// 
+    /// Slides the terminal row to  px over ~160ms. A short,
+    /// self-terminating step timer (never an indefinite animation — see the WebView
+    /// repaint history in project memory).
+    /// 
+    private void AnimateTerminalHeight(double target, Action? onDone)
+    {
+        _termAnim?.Stop();
+
+        double start = TerminalRow.Height.Value;
+        if (Math.Abs(target - start) < 0.5)
+        {
+            TerminalRow.Height = new GridLength(target);
+            onDone?.Invoke();
+            return;
+        }
+
+        var timer = _dispatcher.CreateTimer();
+        timer.Interval = TimeSpan.FromMilliseconds(15);
+        var sw = Stopwatch.StartNew();
+        const double durationMs = 160;
+
+        timer.Tick += (_, _) =>
+        {
+            double t = Math.Min(1.0, sw.Elapsed.TotalMilliseconds / durationMs);
+            double eased = 1 - Math.Pow(1 - t, 3);   // ease-out cubic
+            TerminalRow.Height = new GridLength(Math.Max(0, start + (target - start) * eased));
+            if (t >= 1.0)
+            {
+                timer.Stop();
+                TerminalRow.Height = new GridLength(target);
+                onDone?.Invoke();
+            }
+        };
+        _termAnim = timer;
+        timer.Start();
+    }
+
+    private bool _draggingSplitter;
+    private double _dragStartHeight;
+    private double _dragStartY;
+
+    private void TerminalSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+    {
+        _draggingSplitter = true;
+        _dragStartHeight = TerminalRow.Height.Value;
+        _dragStartY = e.GetCurrentPoint(Root).Position.Y;   // Root frame — stable as the grip moves
+        ((UIElement)sender).CapturePointer(e.Pointer);
+    }
+
+    private void TerminalSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingSplitter) return;
+        // Dragging up grows the terminal; down shrinks it. Ceiling is the maximized height (~95%).
+        double delta = e.GetCurrentPoint(Root).Position.Y - _dragStartY;
+        double next = Math.Clamp(_dragStartHeight - delta, 80, MaxTerminalHeight());
+        TerminalRow.Height = new GridLength(next);
+    }
+
+    private void TerminalSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+    {
+        if (!_draggingSplitter) return;
+        _draggingSplitter = false;
+        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
+
+        _savedTerminalHeight = TerminalRow.Height.Value;
+        // Keep the maximize button's glyph honest: dragging near the top counts as maximized.
+        _terminalMaximized = TerminalRow.Height.Value >= ContentColumnGrid.ActualHeight * 0.9;
+        if (!_terminalMaximized) _preMaxHeight = TerminalRow.Height.Value;
+        _terminal?.SetMaximized(_terminalMaximized);
+        _terminal?.Refit();
+    }
+
+    private bool _initialized;
+
+    private void Root_Loaded(object sender, RoutedEventArgs e)
+    {
+        if (_initialized) return;
+        _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).
+        foreach (var entry in _tabs) _ = InitTabAsync(entry);
+        InitBgPreview();
+
+        // Both stores load from disk at construction; reflect their counts on the rail at launch,
+        // before the user opens either panel.
+        RefreshSnapshotsBadge();
+        RefreshHistoryBadge();
+    }
+
+    private async Task InitTabAsync(ChatTabEntry entry)
+    {
+        await entry.View.InitializeAsync();
+        // Best-effort per-tab model restore: if the saved model is gone (Ollama not running,
+        // cloud model renamed), the tab simply keeps the default and says so in its header.
+        var desired = entry.RestoreModel;
+        if (!string.IsNullOrEmpty(desired) && desired != entry.View.Session.Controller.ModelName)
+        {
+            await Task.Run(() => entry.View.Session.Controller.SelectModelAsync(desired));
+            entry.View.UpdateHeader();
+        }
+
+        // Memory comes back only after the model has settled — selecting a model clears
+        // history, so this order is what keeps the restored memory alive.
+        await entry.View.RestoreConversationMemoryAsync();
+    }
+
+    /// 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.
+    private void SaveWorkspace()
+    {
+        var tabs = _tabs.Select(t => new WorkspaceTabState(
+            t.View.Session.Title,
+            t.View.Session.ProjectRoot.ProjectRoot,
+            t.View.Session.Controller.ModelName,
+            t.View.Session.PersistKey)).ToList();
+        var active = _selected == null ? 0 : Math.Max(0, _tabs.IndexOf(_selected));
+        WorkspaceState.Save(new WorkspaceShape(tabs, active));
+    }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
new file mode 100644
index 0000000..0c3ed99
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
@@ -0,0 +1,145 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text.Json;
+using MandoCode.Models;
+using MandoCode.Desktop.Services;
+using MandoCode.Desktop.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+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.Media.Imaging;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop;
+
+/// Row model for the slash-command suggestions list.
+public sealed class CommandSuggestion
+{
+    public string Command { get; init; } = "";
+    public string Description { get; init; } = "";
+
+    /// What accepting the row inserts, when that differs from 
+    /// (e.g. the ":fire:" row inserts 🔥). Null means insert the command itself.
+    public string? InsertText { 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";
+}
+
+/// A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
+///  so a  can group
+/// on it directly — the ListView's group-header template binds to  and
+/// .
+public sealed class SnapshotGroup : List
+{
+    public SnapshotGroup(string project, IEnumerable items) : base(items)
+        => Project = project;
+
+    public string Project { get; }
+
+    /// Whether the group's Expander is open. Set when the groups are rebuilt (from the
+    /// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
+    /// expand/collapse events keep the remembered set current thereafter.
+    public bool IsExpanded { get; set; } = true;
+}
+
+/// A project's closed conversations, as one collapsible group in the History panel —
+/// the archive twin of .
+public sealed class HistoryGroup : List
+{
+    public HistoryGroup(string project, IEnumerable items) : base(items)
+        => Project = project;
+
+    public string Project { get; }
+
+    public bool IsExpanded { get; set; } = true;
+}
+
+/// Row model for diff lines shown in the approval overlay.
+public sealed class DiffLineVm
+{
+    public string Text { get; init; } = "";
+    public SolidColorBrush Brush { get; init; } = new(Colors.Gray);
+}
+
+/// Row model for the MCP servers page.
+public sealed class McpRow
+{
+    public string Name { get; init; } = "";
+    public string Transport { get; init; } = "";
+    public string Status { get; init; } = "";
+    public SolidColorBrush StatusBrush { get; init; } = new(Colors.Gray);
+    /// Per-server on/off (the config's Disabled flag, inverted). Shared by every agent.
+    public bool Enabled { get; init; }
+}
+
+/// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a
+/// CollectionViewSource can group on it directly; the header binds to .
+public sealed class SkillRowGroup : List
+{
+    public string Key { get; }
+    public SkillRowGroup(string key, IEnumerable items) : base(items) => Key = key;
+}
+
+/// A section of the MCP servers list (e.g. "Disabled (3)").
+public sealed class McpRowGroup : List
+{
+    public string Key { get; }
+    public McpRowGroup(string key, IEnumerable items) : base(items) => Key = key;
+}
+
+/// Row model for the global-skills page. FolderPath rides along so per-row actions
+/// (the enable toggle) can act on the right skill without leaning on list selection.
+public sealed class SkillRow
+{
+    public string Name { get; init; } = "";
+    public string Description { get; init; } = "";
+    public string Body { get; init; } = "";
+    public string FolderPath { get; init; } = "";
+    public bool Enabled { get; init; }
+
+    // Size of the instructions body — what gets injected into the prompt on load, so it's the cost
+    // that spins a local model up. ~4 chars/token is the usual rough estimate.
+    public int ApproxTokens => (Body.Length + 3) / 4;
+    public bool IsLarge => ApproxTokens >= 2000;
+    public string SizeLabel =>
+        (ApproxTokens >= 1000 ? $"≈{ApproxTokens / 1000.0:0.0}k tok" : $"≈{ApproxTokens} tok")
+        + (IsLarge ? " · large" : "");
+    public SolidColorBrush SizeBrush =>
+        new(ThemeManager.C(IsLarge ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
+}
+
+/// Chip model for the MCP editor's tool preview (test results).
+public sealed class ToolChip
+{
+    public string Name { get; init; } = "";
+    public string Description { get; init; } = "";
+}
+
+/// Row model for the Appearance tab's theme picker — each card is drawn in
+/// its own theme's colors so the list doubles as a preview.
+public sealed class ThemeVm
+{
+    public required UiTheme Theme { get; init; }
+    public string Name => Theme.Name;
+    public string Description => Theme.Description;
+    public SolidColorBrush BgBrush => new(ThemeManager.C(Theme.Background));
+    public SolidColorBrush EdgeBrush => new(ThemeManager.C(Theme.Border));
+    public SolidColorBrush FgBrush => new(ThemeManager.C(Theme.Text));
+    public SolidColorBrush DimBrush => new(ThemeManager.C(Theme.Dim));
+    public SolidColorBrush AccentBrush => new(ThemeManager.C(Theme.Accent));
+    public SolidColorBrush GoldBrush => new(ThemeManager.C(Theme.Gold));
+    public SolidColorBrush SkyBrush => new(ThemeManager.C(Theme.Sky));
+    public SolidColorBrush GreenBrush => new(ThemeManager.C(Theme.Green));
+}
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 53342b3..444dcc3 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -1228,7 +1228,7 @@
         
 
         
-        
             Row model for the slash-command suggestions list.
-public sealed class CommandSuggestion
-{
-    public string Command { get; init; } = "";
-    public string Description { get; init; } = "";
-
-    /// What accepting the row inserts, when that differs from 
-    /// (e.g. the ":fire:" row inserts 🔥). Null means insert the command itself.
-    public string? InsertText { 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";
-}
-
-/// A project's snapshots, as one group in the (grouped) snapshots panel. Derives from
-///  so a  can group
-/// on it directly — the ListView's group-header template binds to  and
-/// .
-public sealed class SnapshotGroup : List
-{
-    public SnapshotGroup(string project, IEnumerable items) : base(items)
-        => Project = project;
-
-    public string Project { get; }
-
-    /// Whether the group's Expander is open. Set when the groups are rebuilt (from the
-    /// remembered collapsed-set) and read once via a OneTime x:Bind — the Expander's own
-    /// expand/collapse events keep the remembered set current thereafter.
-    public bool IsExpanded { get; set; } = true;
-}
-
-/// A project's closed conversations, as one collapsible group in the History panel —
-/// the archive twin of .
-public sealed class HistoryGroup : List
-{
-    public HistoryGroup(string project, IEnumerable items) : base(items)
-        => Project = project;
-
-    public string Project { get; }
-
-    public bool IsExpanded { get; set; } = true;
-}
-
-/// Row model for diff lines shown in the approval overlay.
-public sealed class DiffLineVm
-{
-    public string Text { get; init; } = "";
-    public SolidColorBrush Brush { get; init; } = new(Colors.Gray);
-}
-
-/// Row model for the MCP servers page.
-public sealed class McpRow
-{
-    public string Name { get; init; } = "";
-    public string Transport { get; init; } = "";
-    public string Status { get; init; } = "";
-    public SolidColorBrush StatusBrush { get; init; } = new(Colors.Gray);
-    /// Per-server on/off (the config's Disabled flag, inverted). Shared by every agent.
-    public bool Enabled { get; init; }
-}
-
-/// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a
-/// CollectionViewSource can group on it directly; the header binds to .
-public sealed class SkillRowGroup : List
-{
-    public string Key { get; }
-    public SkillRowGroup(string key, IEnumerable items) : base(items) => Key = key;
-}
-
-/// A section of the MCP servers list (e.g. "Disabled (3)").
-public sealed class McpRowGroup : List
-{
-    public string Key { get; }
-    public McpRowGroup(string key, IEnumerable items) : base(items) => Key = key;
-}
-
-/// Row model for the global-skills page. FolderPath rides along so per-row actions
-/// (the enable toggle) can act on the right skill without leaning on list selection.
-public sealed class SkillRow
-{
-    public string Name { get; init; } = "";
-    public string Description { get; init; } = "";
-    public string Body { get; init; } = "";
-    public string FolderPath { get; init; } = "";
-    public bool Enabled { get; init; }
-
-    // Size of the instructions body — what gets injected into the prompt on load, so it's the cost
-    // that spins a local model up. ~4 chars/token is the usual rough estimate.
-    public int ApproxTokens => (Body.Length + 3) / 4;
-    public bool IsLarge => ApproxTokens >= 2000;
-    public string SizeLabel =>
-        (ApproxTokens >= 1000 ? $"≈{ApproxTokens / 1000.0:0.0}k tok" : $"≈{ApproxTokens} tok")
-        + (IsLarge ? " · large" : "");
-    public SolidColorBrush SizeBrush =>
-        new(ThemeManager.C(IsLarge ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
-}
-
-/// Chip model for the MCP editor's tool preview (test results).
-public sealed class ToolChip
-{
-    public string Name { get; init; } = "";
-    public string Description { get; init; } = "";
-}
-
-/// Row model for the Appearance tab's theme picker — each card is drawn in
-/// its own theme's colors so the list doubles as a preview.
-public sealed class ThemeVm
-{
-    public required UiTheme Theme { get; init; }
-    public string Name => Theme.Name;
-    public string Description => Theme.Description;
-    public SolidColorBrush BgBrush => new(ThemeManager.C(Theme.Background));
-    public SolidColorBrush EdgeBrush => new(ThemeManager.C(Theme.Border));
-    public SolidColorBrush FgBrush => new(ThemeManager.C(Theme.Text));
-    public SolidColorBrush DimBrush => new(ThemeManager.C(Theme.Dim));
-    public SolidColorBrush AccentBrush => new(ThemeManager.C(Theme.Accent));
-    public SolidColorBrush GoldBrush => new(ThemeManager.C(Theme.Gold));
-    public SolidColorBrush SkyBrush => new(ThemeManager.C(Theme.Sky));
-    public SolidColorBrush GreenBrush => new(ThemeManager.C(Theme.Green));
-}
-
 public sealed partial class MainWindow : Window
 {
     private readonly SessionManager _sessions;
@@ -274,2610 +149,4 @@ private void MainWindow_Closed(object sender, WindowEventArgs args)
         catch { /* nothing playing, or already disposed */ }
     }
 
-    // ============================================================
-    // Integrated terminal (VS-style sliding shell panel)
-    // ============================================================
-
-    private bool _terminalOpen;
-    private bool _terminalMaximized;
-    private double _savedTerminalHeight;   // px — the user's last dragged size, restored on reopen
-    private double _preMaxHeight;           // px — height to restore to when un-maximizing
-    private Microsoft.UI.Dispatching.DispatcherQueueTimer? _termAnim;
-    private Controls.TerminalPanel? _terminal;   // created lazily on first open
-
-    // Maximized terminal leaves ~5% at the top (just the agent tab strip peeking through).
-    private double MaxTerminalHeight() => Math.Max(160, ContentColumnGrid.ActualHeight * 0.95);
-
-    /// 
-    /// Builds the terminal panel the first time it's needed and drops it into Grid.Row 3 of the
-    /// content column. Deferred so no WebView2 or shell process is created until the user opens a
-    /// terminal.
-    /// 
-    private Controls.TerminalPanel EnsureTerminal()
-    {
-        if (_terminal != null) return _terminal;
-
-        _terminal = new Controls.TerminalPanel { Visibility = Visibility.Collapsed };
-        Grid.SetRow(_terminal, 3);
-        ContentColumnGrid.Children.Add(_terminal);
-
-        // Each new shell opens in whichever agent tab is active at the time.
-        _terminal.WorkingDirectoryProvider = () => ActiveChat?.Session.ProjectRoot.ProjectRoot;
-        _terminal.CloseRequested += (_, _) => CloseTerminalPanel();
-        _terminal.MaximizeRequested += (_, _) => ToggleMaximizeTerminal();
-        return _terminal;
-    }
-
-    private void NavTerminal_Click(object sender, RoutedEventArgs e) => ToggleTerminal();
-
-    private void ToggleTerminal()
-    {
-        if (_terminalOpen) CloseTerminalPanel();
-        else OpenTerminalPanel();
-    }
-
-    private void OpenTerminalPanel()
-    {
-        var term = EnsureTerminal();
-        if (_terminalOpen) { term.FocusActive(); return; }
-        _terminalOpen = true;
-        RefreshNavIcons();
-
-        term.Visibility = Visibility.Visible;
-        TerminalSplitter.Visibility = Visibility.Visible;
-        term.EnsureStartedAsync();
-
-        double target = _savedTerminalHeight > 0 ? _savedTerminalHeight : DefaultTerminalHeight();
-        AnimateTerminalHeight(target, onDone: () => term.Refit());
-    }
-
-    private void CloseTerminalPanel()
-    {
-        if (!_terminalOpen) return;
-        _terminalOpen = false;
-        RefreshNavIcons();
-
-        double current = TerminalRow.Height.Value;
-        if (current > 40) _savedTerminalHeight = current;   // remember size for next time
-
-        AnimateTerminalHeight(0, onDone: () =>
-        {
-            if (_terminal != null) _terminal.Visibility = Visibility.Collapsed;
-            TerminalSplitter.Visibility = Visibility.Collapsed;
-            ActiveChat?.FocusInput();
-        });
-    }
-
-    /// Expand the terminal to ~95% of the window (5% left at top), or restore its prior size.
-    private void ToggleMaximizeTerminal()
-    {
-        if (!_terminalOpen) { OpenTerminalPanel(); return; }   // first open lands at the default size
-
-        if (_terminalMaximized)
-        {
-            _terminalMaximized = false;
-            double restore = _preMaxHeight > 40 ? _preMaxHeight : DefaultTerminalHeight();
-            AnimateTerminalHeight(restore, onDone: () => _terminal?.Refit());
-        }
-        else
-        {
-            _terminalMaximized = true;
-            _preMaxHeight = TerminalRow.Height.Value;
-            AnimateTerminalHeight(MaxTerminalHeight(), onDone: () => _terminal?.Refit());
-        }
-        _terminal?.SetMaximized(_terminalMaximized);
-    }
-
-    private double DefaultTerminalHeight()
-    {
-        double h = ContentColumnGrid.ActualHeight;
-        if (h <= 0) h = 800;
-        return Math.Clamp(h * 0.30, 140, h * 0.7);
-    }
-
-    /// 
-    /// Slides the terminal row to  px over ~160ms. A short,
-    /// self-terminating step timer (never an indefinite animation — see the WebView
-    /// repaint history in project memory).
-    /// 
-    private void AnimateTerminalHeight(double target, Action? onDone)
-    {
-        _termAnim?.Stop();
-
-        double start = TerminalRow.Height.Value;
-        if (Math.Abs(target - start) < 0.5)
-        {
-            TerminalRow.Height = new GridLength(target);
-            onDone?.Invoke();
-            return;
-        }
-
-        var timer = _dispatcher.CreateTimer();
-        timer.Interval = TimeSpan.FromMilliseconds(15);
-        var sw = Stopwatch.StartNew();
-        const double durationMs = 160;
-
-        timer.Tick += (_, _) =>
-        {
-            double t = Math.Min(1.0, sw.Elapsed.TotalMilliseconds / durationMs);
-            double eased = 1 - Math.Pow(1 - t, 3);   // ease-out cubic
-            TerminalRow.Height = new GridLength(Math.Max(0, start + (target - start) * eased));
-            if (t >= 1.0)
-            {
-                timer.Stop();
-                TerminalRow.Height = new GridLength(target);
-                onDone?.Invoke();
-            }
-        };
-        _termAnim = timer;
-        timer.Start();
-    }
-
-    private bool _draggingSplitter;
-    private double _dragStartHeight;
-    private double _dragStartY;
-
-    private void TerminalSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
-    {
-        _draggingSplitter = true;
-        _dragStartHeight = TerminalRow.Height.Value;
-        _dragStartY = e.GetCurrentPoint(Root).Position.Y;   // Root frame — stable as the grip moves
-        ((UIElement)sender).CapturePointer(e.Pointer);
-    }
-
-    private void TerminalSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingSplitter) return;
-        // Dragging up grows the terminal; down shrinks it. Ceiling is the maximized height (~95%).
-        double delta = e.GetCurrentPoint(Root).Position.Y - _dragStartY;
-        double next = Math.Clamp(_dragStartHeight - delta, 80, MaxTerminalHeight());
-        TerminalRow.Height = new GridLength(next);
-    }
-
-    private void TerminalSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingSplitter) return;
-        _draggingSplitter = false;
-        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
-
-        _savedTerminalHeight = TerminalRow.Height.Value;
-        // Keep the maximize button's glyph honest: dragging near the top counts as maximized.
-        _terminalMaximized = TerminalRow.Height.Value >= ContentColumnGrid.ActualHeight * 0.9;
-        if (!_terminalMaximized) _preMaxHeight = TerminalRow.Height.Value;
-        _terminal?.SetMaximized(_terminalMaximized);
-        _terminal?.Refit();
-    }
-
-    private bool _initialized;
-
-    private void Root_Loaded(object sender, RoutedEventArgs e)
-    {
-        if (_initialized) return;
-        _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).
-        foreach (var entry in _tabs) _ = InitTabAsync(entry);
-        InitBgPreview();
-
-        // Both stores load from disk at construction; reflect their counts on the rail at launch,
-        // before the user opens either panel.
-        RefreshSnapshotsBadge();
-        RefreshHistoryBadge();
-    }
-
-    private async Task InitTabAsync(ChatTabEntry entry)
-    {
-        await entry.View.InitializeAsync();
-        // Best-effort per-tab model restore: if the saved model is gone (Ollama not running,
-        // cloud model renamed), the tab simply keeps the default and says so in its header.
-        var desired = entry.RestoreModel;
-        if (!string.IsNullOrEmpty(desired) && desired != entry.View.Session.Controller.ModelName)
-        {
-            await Task.Run(() => entry.View.Session.Controller.SelectModelAsync(desired));
-            entry.View.UpdateHeader();
-        }
-
-        // Memory comes back only after the model has settled — selecting a model clears
-        // history, so this order is what keeps the restored memory alive.
-        await entry.View.RestoreConversationMemoryAsync();
-    }
-
-    /// 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.
-    private void SaveWorkspace()
-    {
-        var tabs = _tabs.Select(t => new WorkspaceTabState(
-            t.View.Session.Title,
-            t.View.Session.ProjectRoot.ProjectRoot,
-            t.View.Session.Controller.ModelName,
-            t.View.Session.PersistKey)).ToList();
-        var active = _selected == null ? 0 : Math.Max(0, _tabs.IndexOf(_selected));
-        WorkspaceState.Save(new WorkspaceShape(tabs, active));
-    }
-
-    // ============================================================
-    // Appearance-page live preview — a real miniature transcript
-    // ============================================================
-    // Same shell + theme script as the tabs, so it shows EXACTLY what they show (background
-    // image, boxed messages, E-Ink dithering, W98 chrome). Failures never break settings —
-    // the preview is a luxury.
-
-    private bool _previewWebReady;
-
-    private async void InitBgPreview()
-    {
-        try
-        {
-            await BgPreviewWeb.EnsureCoreWebView2Async();
-            var core = BgPreviewWeb.CoreWebView2;
-            core.Settings.AreDefaultContextMenusEnabled = false;
-
-            // Same virtual hosts the tabs map: assets (highlight.js) + userdata (bg image).
-            try
-            {
-                core.SetVirtualHostNameToFolderMapping(
-                    "mandocode.assets",
-                    System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "web"),
-                    Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
-            }
-            catch { }
-            try
-            {
-                Directory.CreateDirectory(ThemeManager.UserDataFolder);
-                core.SetVirtualHostNameToFolderMapping(
-                    "mandocode.userdata", ThemeManager.UserDataFolder,
-                    Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
-            }
-            catch { }
-
-            core.NavigationCompleted += (_, _) =>
-            {
-                if (_previewWebReady) return;
-                _previewWebReady = true;
-                _ = SeedBgPreviewAsync();
-            };
-            core.NavigateToString(TranscriptHtmlBuilder.BaseDocument(ThemeManager.Current));
-        }
-        catch { /* no preview — settings still fully functional */ }
-    }
-
-    private async Task SeedBgPreviewAsync()
-    {
-        try
-        {
-            var blocks =
-                _html.UserEcho("how does this look?") +
-                _html.AssistantCard(
-                    "Like this — the image fades, the text never does.\n\n" +
-                    "Inline `code` and a block, to judge every surface:\n\n" +
-                    "```csharp\nvar vibe = \"immaculate\";\n```");
-            await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
-                "window.__append(" + JsonSerializer.Serialize(blocks) + ");");
-            await BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
-                ThemeManager.BuildTranscriptScript(ThemeManager.Current));
-        }
-        catch { }
-    }
-
-    private void OnUi(Action action)
-    {
-        if (_dispatcher.HasThreadAccess) action();
-        else _dispatcher.TryEnqueue(() => action());
-    }
-
-    private void Root_KeyDown(object sender, KeyRoutedEventArgs e)
-    {
-        if (e.Key == VirtualKey.Escape) { ActiveChat?.HandleEscape(); return; }
-
-        // Ctrl+`  toggles the terminal;  Ctrl+Shift+`  opens a new shell tab (VS Code parity).
-        // 192 == VK_OEM_3 (backtick/tilde). Handled here rather than via a KeyboardAccelerator:
-        // WinUI fast-fails natively when an accelerator is registered on an OEM key.
-        if (e.Key == (VirtualKey)192 && IsDown(VirtualKey.Control))
-        {
-            e.Handled = true;
-            if (IsDown(VirtualKey.Shift)) { OpenTerminalPanel(); _terminal!.NewTerminalTab(); }
-            else ToggleTerminal();
-        }
-    }
-
-    private static bool IsDown(VirtualKey key) =>
-        Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(key)
-            .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
-
-    private void ApplyThemeToAllTabs()
-    {
-        foreach (var tab in _tabs) tab.View.ApplyTheme();
-        // The appearance preview is a transcript too — it re-themes with everyone else.
-        if (_previewWebReady && BgPreviewWeb.CoreWebView2 != null)
-            _ = BgPreviewWeb.CoreWebView2.ExecuteScriptAsync(
-                ThemeManager.BuildTranscriptScript(ThemeManager.Current));
-    }
-
-    private void CopyToClipboard(string text)
-    {
-        var package = new DataPackage();
-        package.SetText(text);
-        Clipboard.SetContent(package);
-    }
-
-    // ============================================================
-    // Tabs
-    // ============================================================
-
-    /// One independent agent: its strip header and its chat surface.
-    private sealed class ChatTabEntry
-    {
-        public required Border Header { get; init; }
-        public required TextBlock Label { get; init; }
-        public required Ellipse Badge { get; init; }
-        public required ChatTabView View { get; init; }
-
-        /// 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; }
-    }
-
-    private readonly List _tabs = new();
-    private ChatTabEntry? _selected;
-    private ChatTabEntry? _pendingApprovalTab;
-    private bool _approvalToastDismissed;
-
-    /// 
-    /// The agent everything else acts on: Esc, the Settings page, the MCP page. Stays put while
-    /// you're looking at Settings — that's what makes "these settings belong to Agent 2" true.
-    /// 
-    private ChatTabView? ActiveChat => _selected?.View;
-
-    private void AddTab_Click(object sender, RoutedEventArgs e)
-    {
-        var entry = CreateChatTab();
-        _ = entry.View.InitializeAsync();
-        SaveWorkspace();
-    }
-
-    private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = null, string? restoreModel = null, string? persistKey = null)
-    {
-        var session = _sessions.CreateSession(projectRoot, persistKey);
-        if (!string.IsNullOrWhiteSpace(title)) session.Title = title;
-        var view = new ChatTabView(this, session, _html) { Visibility = Visibility.Collapsed };
-
-        view.SetupRequested += () => SwitchPage("settings");
-        view.McpEditorRequested += name =>
-        {
-            SwitchPage("mcp");
-            OpenMcpEditor(name);
-        };
-        view.ClipboardCopyRequested += CopyToClipboard;
-        view.ExitRequested += Close;
-        view.ApprovalStateChanged += _ => RefreshTabStrip();
-        view.HeaderChanged += v =>
-        {
-            var tab = _tabs.FirstOrDefault(t => ReferenceEquals(t.View, v));
-            if (tab != null) tab.Label.Text = v.Session.Title;
-            SaveWorkspace();   // renames, folder switches, and model switches all land here
-        };
-
-        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 };
-        _tabs.Add(entry);
-        TabStrip.Children.Add(header);
-        WireHeader(entry);
-
-        SelectTab(entry);
-        if (_snapshotsPanelOpen) PopulateSnapshots();   // an agent exists now → re-enable Import
-        if (HasComparePair) RefreshSplitCombos();       // include the new agent in the pane pickers
-        return entry;
-    }
-
-    // ============================================================
-    // Sidebar navigation — Settings and MCP are full-screen pages, not tabs. They act on
-    // whichever agent is selected, so switching pages never changes which agent that is.
-    // ============================================================
-
-    private string _currentPage = "chat";
-
-    private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat");
-
-    // 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 NavSkills_Click(object sender, RoutedEventArgs e)
-        => SwitchPage(_currentPage == "skills" ? "chat" : "skills");
-    private void NavAppearance_Click(object sender, RoutedEventArgs e)
-        => SwitchPage(_currentPage == "appearance" ? "chat" : "appearance");
-
-    private void SwitchPage(string page)
-    {
-        // Settings and MCP act on the selected agent — with none open there's nothing to edit, so
-        // fall back to the (empty) chat. Skills and Appearance are app-global and stay reachable.
-        if ((page == "settings" || page == "mcp") && _sessions.Active == null) page = "chat";
-
-        _currentPage = page;
-        var showingChat = page == "chat";
-
-        SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed;
-        McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed;
-        SkillsPage.Visibility = page == "skills" ? Visibility.Visible : Visibility.Collapsed;
-        AppearancePage.Visibility = page == "appearance" ? 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);
-        else if (page == "skills") SlideInPage(SkillsPage, SkillsPageTransform);
-        else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform);
-
-        // Every agent view stays loaded; only the visible one(s) show, and only on the chat page.
-        // Collapsing rather than removing is what keeps each WebView2's transcript alive. In split
-        // mode two views show at once (the compare pair, _compareA left / _compareB right).
-        ApplyPaneLayout();
-
-        // The empty-state background shows only on the chat page with no agents left.
-        EmptyAgentsState.Visibility = showingChat && _tabs.Count == 0
-            ? Visibility.Visible : Visibility.Collapsed;
-        _ = RefreshEmptyBackgroundAsync();
-
-        RefreshNavIcons();
-        // Re-evaluate the approval toast for the new page — leaving the chat can newly "hide" the
-        // selected agent's approval, which should now raise the toast (and returning clears it).
-        RefreshTabStrip();
-
-        switch (page)
-        {
-            case "settings":
-                LoadSettings();
-                _ = RefreshModelListAsync();
-                break;
-            case "mcp":
-                _ = RefreshMcpListAsync();
-                break;
-            case "skills":
-                RefreshSkillsList();
-                break;
-            default:
-                ActiveChat?.FocusInput();
-                break;
-        }
-    }
-
-    /// 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"];
-        var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
-        var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
-
-        // An approval waiting in ANY agent while you're on Settings/MCP: the agents icon goes gold,
-        // because from here you can't see which tab is badged.
-        var approvalPending = _currentPage != "chat" && _tabs.Any(t => t.View.IsApprovalOpen);
-
-        NavChatIcon.Foreground = _currentPage == "chat" ? accent : (approvalPending ? gold : normal);
-        NavSettingsIcon.Foreground = _currentPage == "settings" ? accent : normal;
-        NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal;
-        NavSkillsIcon.Foreground = _currentPage == "skills" ? accent : normal;
-        NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal;
-        NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal;
-        NavHistoryIcon.Foreground = _historyPanelOpen ? accent : normal;
-        NavTerminalIcon.Foreground = _terminalOpen ? accent : normal;
-
-        // Settings and MCP act on the selected agent — disable them while none is open.
-        var hasAgent = _sessions.Active != null;
-        NavSettings.IsEnabled = hasAgent;
-        NavMcp.IsEnabled = hasAgent;
-        ToolTipService.SetToolTip(NavChat, approvalPending ? "Agents — approval waiting" : "Agents");
-    }
-
-    // ============================================================
-    // Snapshots panel — global (the store is app-wide), toggled from the rail. Docked left at
-    // ~37% width so the active chat stays visible; Import arms the selected agent's next message.
-    // ============================================================
-
-    private void NavSnapshots_Click(object sender, RoutedEventArgs e)
-    {
-        if (_snapshotsPanelOpen) CloseLeftPanel();
-        else OpenSnapshots();
-    }
-
-    private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
-
-    private void OpenSnapshots()
-    {
-        MarkSnapshotsSeen();   // opening the panel IS reading it — clear the unread badge
-        PopulateSnapshots();
-        ShowLeftPanel(SnapshotsPanel, snapshots: true);
-    }
-
-    /// Shows one of the two docked panels (Snapshots/History), swapping if the other was
-    /// already up (the column stays out — only the contents change) and sliding it in otherwise.
-    private void ShowLeftPanel(Border panel, bool snapshots)
-    {
-        bool wasOpen = _snapshotsPanelOpen || _historyPanelOpen;
-        _snapshotsPanelOpen = snapshots;
-        _historyPanelOpen = !snapshots;
-        SnapshotsPanel.Visibility = snapshots ? Visibility.Visible : Visibility.Collapsed;
-        HistoryPanel.Visibility = snapshots ? Visibility.Collapsed : Visibility.Visible;
-        RefreshNavIcons();
-        if (wasOpen) return;   // column already at width — contents swapped, no re-slide
-
-        // 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);
-        AnimateLeftColumn(target, hideOnDone: null);
-    }
-
-    private void CloseLeftPanel()
-    {
-        var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel
-                   : _historyPanelOpen ? HistoryPanel : null;
-        _snapshotsPanelOpen = false;
-        _historyPanelOpen = false;
-        RefreshNavIcons();
-        AnimateLeftColumn(0, hideOnDone: toHide);
-    }
-
-    /// Tweens the docked 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. , when set, is
-    /// collapsed once a close tween lands.
-    private void AnimateLeftColumn(double toPx, FrameworkElement? 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;
-        _snapAnimHide = 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 (_snapAnimHide != null) _snapAnimHide.Visibility = Visibility.Collapsed;
-            }
-        };
-        CompositionTarget.Rendering += _snapAnimHandler;
-    }
-
-    private void OnSnapshotsChanged()
-    {
-        // A change while you're looking at the panel is already seen; otherwise it's a new unread.
-        if (_snapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
-        else RefreshSnapshotsBadge();
-    }
-
-    /// Marks every current snapshot as seen (opening the panel, or a change while it's open),
-    /// clearing the rail badge. Persisted so the badge doesn't re-light on relaunch.
-    private void MarkSnapshotsSeen()
-    {
-        _snapshotsSeenAt = DateTimeOffset.Now;
-        SavePanelState();
-        RefreshSnapshotsBadge();
-    }
-
-    /// Current text in the snapshots search box; empty means "show everything".
-    private string _snapshotFilter = "";
-
-    /// Project labels whose group is folded shut. Survives repopulation (search, import,
-    /// delete) so a collapse the user made doesn't spring back open on the next keystroke.
-    private readonly HashSet _collapsedSnapshotGroups = new();
-
-    // "Last opened" watermarks — the rail badges show how many snapshots/closed conversations are
-    // newer than these, i.e. unread since the last visit. Persisted in panel-state.json.
-    private DateTimeOffset? _snapshotsSeenAt;
-    private DateTimeOffset? _historySeenAt;
-
-    /// Writes both panels' fold state and seen-watermarks to disk (survives relaunch).
-    private void SavePanelState() => PanelState.Save(new PanelStateShape(
-        _collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
-        _snapshotsSeenAt, _historySeenAt));
-
-    // The group object is kept in sync (not just the set) so that when the ListView recycles a
-    // container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
-    private void SnapshotGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
-    {
-        if (sender.Tag is not SnapshotGroup g) return;
-        g.IsExpanded = true;
-        _collapsedSnapshotGroups.Remove(g.Project);
-        SavePanelState();
-    }
-
-    private void SnapshotGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
-    {
-        if (sender.Tag is not SnapshotGroup g) return;
-        g.IsExpanded = false;
-        _collapsedSnapshotGroups.Add(g.Project);
-        SavePanelState();
-    }
-
-    private void SnapshotsSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
-    {
-        // Only react to the user typing — not to programmatic Text changes on repopulate.
-        if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
-        _snapshotFilter = sender.Text?.Trim() ?? "";
-        PopulateSnapshots();
-    }
-
-    private static bool Matches(ContextSnapshot s, string q) =>
-        s.DisplayTitle.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || s.OriginModel.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || s.SummarizerModel.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || (s.Recap?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
-
-    private void PopulateSnapshots()
-    {
-        var all = _snapshotStore.Items;   // newest-first copy of the shared store
-        var storeEmpty = all.Count == 0;
-
-        // The search box only earns its space once there's something to search.
-        SnapshotsSearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
-
-        // Explain the disabled Import buttons when there's a snapshot but no agent to import into.
-        SnapshotsNoAgentNotice.IsOpen = !storeEmpty && _sessions.Active == null;
-
-        var q = _snapshotFilter;
-        var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
-
-        // Group by project, preserving the store's newest-first order within each group and
-        // ordering the groups by their most-recent snapshot (so the freshest project leads).
-        // Each group carries its remembered expand/collapse state so folding a project sticks
-        // across searches and imports (which both rebuild this list).
-        var groups = filtered
-            .GroupBy(s => s.ProjectLabel)
-            .OrderByDescending(g => g.Max(s => s.CapturedAt))
-            .Select(g => new SnapshotGroup(g.Key, g) { IsExpanded = !_collapsedSnapshotGroups.Contains(g.Key) })
-            .ToList();
-
-        SnapshotsList.ItemsSource = groups;
-
-        var nothingToShow = groups.Count == 0;
-        SnapshotsEmpty.Text = storeEmpty
-            ? "No snapshots yet. When you switch a tab's model — or pick Take snapshot from a tab's ⋯ menu — you'll be offered to save the conversation as a snapshot, summarized by a model you choose."
-            : $"No snapshots match “{q}”.";
-        SnapshotsEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
-        SnapshotsScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
-        RefreshSnapshotsBadge();
-    }
-
-    private void RefreshSnapshotsBadge()
-    {
-        // Unread = snapshots captured after the last visit. Never-visited (null) counts them all.
-        var n = _snapshotsSeenAt is { } seen
-            ? _snapshotStore.Items.Count(s => s.CapturedAt > seen)
-            : _snapshotStore.Count;
-        NavSnapshotsBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
-        NavSnapshotsBadgeText.Text = n > 99 ? "99+" : n.ToString();
-    }
-
-    /// Each Import button disables itself when there's no agent to import into — the action
-    /// arms an agent's next message, so it's meaningless with none open. Re-evaluated on load, and the
-    /// list is repopulated when the agent count crosses zero (so open buttons refresh too).
-    private void SnapshotImport_Loaded(object sender, RoutedEventArgs e)
-    {
-        if (sender is Button b) b.IsEnabled = _sessions.Active != null;
-    }
-
-    private void SnapshotImport_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
-        var target = _selected?.View;
-        if (target == null) return;   // no agent open — nothing to import into (button is disabled too)
-
-        target.Session.Controller.ImportContext(snap);   // arms the active agent's next message
-        SwitchPage("chat");   // so the "context armed" note is visible in the active tab
-        if (_snapshotsPanelOpen) CloseLeftPanel();   // get out of the way — the chat is where the confirmation shows
-        target.FocusInput();
-    }
-
-    private void SnapshotDelete_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.Tag is not ContextSnapshot snap) return;
-        _snapshotStore.Remove(snap);
-        PopulateSnapshots();
-    }
-
-    // ============================================================
-    // History panel — reopen a closed conversation. Shares the docked column with Snapshots.
-    // ============================================================
-
-    /// Files a just-closed tab into the archive so it can be reopened later. A session that
-    /// never had a real turn is forgotten instead (deleting its files), same as /clear —
-    /// there's nothing worth reopening, and an empty row would only be noise.
-    private void ArchiveClosedSession(AgentSession session)
-    {
-        var key = session.PersistKey;
-        var turns = ConversationLog.Load(key);
-        if (turns.Count == 0)
-        {
-            TranscriptJournal.Delete(key);
-            ConversationLog.Delete(key);
-            SessionHistoryStore.Delete(key);
-            return;
-        }
-
-        var preview = turns.FirstOrDefault(t => t.R == "u")?.T?.Trim();
-        if (preview is { Length: > 140 }) preview = preview[..140].TrimEnd() + "…";
-
-        _archive.Add(new SessionArchiveEntry
-        {
-            Key = key,
-            Title = session.Title,
-            ProjectRoot = session.ProjectRoot.ProjectRoot,
-            Model = session.Controller.ModelName,
-            ClosedAt = DateTimeOffset.Now,
-            TurnCount = turns.Count,
-            Preview = preview,
-        });
-    }
-
-    private void OnArchiveChanged()
-    {
-        if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
-        else RefreshHistoryBadge();
-    }
-
-    /// Marks every current archived conversation as seen, clearing the History rail badge.
-    private void MarkHistorySeen()
-    {
-        _historySeenAt = DateTimeOffset.Now;
-        SavePanelState();
-        RefreshHistoryBadge();
-    }
-
-    private void NavHistory_Click(object sender, RoutedEventArgs e)
-    {
-        if (_historyPanelOpen) CloseLeftPanel();
-        else OpenHistory();
-    }
-
-    private void CloseHistory_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
-
-    private void OpenHistory()
-    {
-        MarkHistorySeen();   // opening the panel IS reading it — clear the unread badge
-        PopulateHistory();
-        ShowLeftPanel(HistoryPanel, snapshots: false);
-    }
-
-    /// Current text in the history search box; empty means "show everything".
-    private string _historyFilter = "";
-
-    /// Project labels whose History group is folded shut (survives search/reopen/delete).
-    private readonly HashSet _collapsedHistoryGroups = new();
-
-    private void HistoryGroup_Expanding(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderExpandingEventArgs args)
-    {
-        if (sender.Tag is not HistoryGroup g) return;
-        g.IsExpanded = true;
-        _collapsedHistoryGroups.Remove(g.Project);
-        SavePanelState();
-    }
-
-    private void HistoryGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender, Microsoft.UI.Xaml.Controls.ExpanderCollapsedEventArgs args)
-    {
-        if (sender.Tag is not HistoryGroup g) return;
-        g.IsExpanded = false;
-        _collapsedHistoryGroups.Add(g.Project);
-        SavePanelState();
-    }
-
-    private void HistorySearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
-    {
-        if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
-        _historyFilter = sender.Text?.Trim() ?? "";
-        PopulateHistory();
-    }
-
-    private static bool Matches(SessionArchiveEntry s, string q) =>
-        s.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || s.ProjectLabel.Contains(q, StringComparison.OrdinalIgnoreCase)
-        || (s.Model?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
-        || (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
-
-    private void PopulateHistory()
-    {
-        var all = _archive.Items;   // newest-first copy
-        var storeEmpty = all.Count == 0;
-        HistorySearch.Visibility = storeEmpty ? Visibility.Collapsed : Visibility.Visible;
-
-        var q = _historyFilter;
-        var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
-
-        // Group by project (freshest project first), newest-first within each, carrying remembered
-        // collapse state — same shape as the Snapshots panel.
-        var groups = filtered
-            .GroupBy(s => s.ProjectLabel)
-            .OrderByDescending(g => g.Max(s => s.ClosedAt))
-            .Select(g => new HistoryGroup(g.Key, g) { IsExpanded = !_collapsedHistoryGroups.Contains(g.Key) })
-            .ToList();
-
-        HistoryList.ItemsSource = groups;
-
-        var nothingToShow = groups.Count == 0;
-        HistoryEmpty.Text = storeEmpty
-            ? "No past conversations yet. Close a tab and it lands here — reopen it any time to pick up where you left off. (Clearing a tab with /clear forgets it for good; closing keeps it.)"
-            : $"No conversations match “{q}”.";
-        HistoryEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
-        HistoryScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
-        RefreshHistoryBadge();
-    }
-
-    private void RefreshHistoryBadge()
-    {
-        // Unread = conversations closed after the last visit. Never-visited (null) counts them all.
-        var n = _historySeenAt is { } seen
-            ? _archive.Items.Count(s => s.ClosedAt > seen)
-            : _archive.Count;
-        NavHistoryBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
-        NavHistoryBadgeText.Text = n > 99 ? "99+" : n.ToString();
-    }
-
-    /// Reopens an archived conversation as a fresh tab on its original persist-key, so the
-    /// standard restore cascade (transcript replay → memory rehydrate) brings it back. The row
-    /// leaves the archive — it's live again — but its files stay; closing re-archives it.
-    private void HistoryOpen_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
-
-        // Defensive: an archived key should never also be open, but if it is, just go there.
-        var existing = _tabs.FirstOrDefault(t =>
-            string.Equals(t.View.Session.PersistKey, entry.Key, StringComparison.OrdinalIgnoreCase));
-        if (existing != null)
-        {
-            _archive.Remove(entry.Key, deleteFiles: false);
-            CloseLeftPanel();
-            SwitchPage("chat");
-            SelectTab(existing);
-            return;
-        }
-
-        // Fall back to the current directory if the original folder is gone — the transcript and
-        // memory still restore; only new file operations would need a live folder.
-        var root = Directory.Exists(entry.ProjectRoot) ? entry.ProjectRoot : Environment.CurrentDirectory;
-        var tab = CreateChatTab(root, entry.Title, entry.Model, entry.Key);   // CreateChatTab selects it
-        _archive.Remove(entry.Key, deleteFiles: false);
-        CloseLeftPanel();
-        SwitchPage("chat");
-        _ = InitTabAsync(tab);   // InitializeAsync replays the transcript; then model + memory restore
-        SaveWorkspace();
-    }
-
-    private void HistoryDelete_Click(object sender, RoutedEventArgs e)
-    {
-        if ((sender as FrameworkElement)?.Tag is not SessionArchiveEntry entry) return;
-        _archive.Remove(entry.Key, deleteFiles: true);   // explicit forget — files go too
-        PopulateHistory();
-    }
-
-    /// "Make Default for New Agents" — snapshot the selected agent's settings to disk.
-    private void MakeDefault_Click(object sender, RoutedEventArgs e)
-    {
-        var agent = _sessions.Active;
-        if (agent == null) return;
-
-        _controller.SaveAsDefaults();
-        SettingsStatus.Text = $"Saved {agent.Title}'s settings as the default for new agents. "
-                            + "Agents already open keep their own.";
-    }
-
-    /// Resets the visible tab's settings to the app's factory defaults (this agent, this
-    /// session). Reads a fresh  for the defaults and applies each key
-    /// through the same validated path as editing a field. Leaves connection (endpoint/model) and the
-    /// Tavily secret untouched — those aren't "tunable knobs" you'd want wiped by a reset.
-    private async void ResetTab_Click(object sender, RoutedEventArgs e)
-    {
-        var d = new MandoCodeConfig();   // factory defaults (property initializers)
-        var s = SettingsTabs.SelectedItem;
-        var resets = new List<(string Key, string Value)>();
-        string tabName;
-
-        static string Bool(bool b) => b ? "true" : "false";
-        static string Num(long n) => n.ToString(System.Globalization.CultureInfo.InvariantCulture);
-
-        if (s == Tab_Behavior)
-        {
-            tabName = "Behavior";
-            resets.Add(("taskPlanning", Bool(d.EnableTaskPlanning)));
-            resets.Add(("diffApprovals", Bool(d.EnableDiffApprovals)));
-            resets.Add(("autoContinue", Bool(d.EnableAutoContinuation)));
-            resets.Add(("maxContinuations", Num(d.MaxAutoContinuations)));
-            resets.Add(("timeout", Num(d.RequestTimeoutMinutes)));
-            resets.Add(("modelResponseTimeout", Num(d.ModelResponseTimeoutSeconds)));
-            resets.Add(("toolBudget", Num(d.ToolResultCharBudget)));
-            resets.Add(("renderTimeout", Num(d.MarkdownRenderTimeoutSeconds)));
-        }
-        else if (s == Tab_Integrations)
-        {
-            tabName = "Integrations";
-            resets.Add(("webSearch", Bool(d.EnableWebSearch)));
-        }
-        else
-        {
-            tabName = "Model";
-            resets.Add(("temperature", d.Temperature.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)));
-            resets.Add(("maxTokens", Num(d.MaxTokens)));
-            resets.Add(("contextLength", Num(d.ContextLength)));
-            resets.Add(("streaming", d.ResponseStreaming));
-        }
-
-        ResetTabButton.IsEnabled = false;
-        foreach (var (key, value) in resets)
-            await _controller.ApplyConfigKeyAsync(key, value);
-        ResetTabButton.IsEnabled = true;
-
-        LoadSettings();   // reflect the restored values (also clears the status line)
-        SettingsStatus.Text = $"{tabName} settings reset to factory defaults.";
-    }
-
-    private (Border Header, TextBlock Label, Ellipse Badge) BuildTabHeader(string title)
-    {
-        var label = new TextBlock
-        {
-            Text = title,
-            FontSize = 13,
-            VerticalAlignment = VerticalAlignment.Center,
-            TextTrimming = TextTrimming.CharacterEllipsis
-        };
-
-        // Gold dot: an approval is waiting in a tab you aren't looking at.
-        var badge = new Ellipse
-        {
-            Width = 7,
-            Height = 7,
-            Visibility = Visibility.Collapsed,
-            VerticalAlignment = VerticalAlignment.Center,
-            Fill = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"]
-        };
-
-        // Options "..." menu (rename / snapshot / export / close) replaces a bare close button — so
-        // the last remaining tab isn't stuck showing an X it isn't allowed to use.
-        var options = new Button
-        {
-            Padding = new Thickness(3),
-            Background = new SolidColorBrush(Colors.Transparent),
-            BorderThickness = new Thickness(0),
-            VerticalAlignment = VerticalAlignment.Center,
-            Content = new FontIcon { Glyph = "", FontSize = 12 }   // More
-        };
-        ToolTipService.SetToolTip(options, "Tab options");
-        Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(options, "Tab options");
-
-        // A Grid (not a StackPanel) so the label flexes and ellipsizes when the tab is narrow,
-        // while the badge and options button stay pinned at the right. LayoutTabStrip sets each
-        // header's Width; this just governs how that width is divided.
-        var row = new Grid { ColumnSpacing = 7 };
-        row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
-        row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
-        row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
-        Grid.SetColumn(label, 0);
-        Grid.SetColumn(badge, 1);
-        Grid.SetColumn(options, 2);
-        row.Children.Add(label);
-        row.Children.Add(badge);
-        row.Children.Add(options);
-
-        var header = new Border
-        {
-            Child = row,
-            Padding = new Thickness(12, 6, 8, 6),
-            CornerRadius = new CornerRadius(7),
-            BorderThickness = new Thickness(1),
-            BorderBrush = new SolidColorBrush(Colors.Transparent),
-            Background = new SolidColorBrush(Colors.Transparent)
-        };
-        return (header, label, badge);
-    }
-
-    /// Wired after the entry exists so the menu handlers can close over it.
-    private void WireHeader(ChatTabEntry entry)
-    {
-        // The options Button consumes the pointer, so opening its menu doesn't also raise Tapped
-        // on the header. Selecting first would be harmless anyway.
-        entry.Header.Tapped += (_, _) => SelectTab(entry);
-
-        var row = (Grid)entry.Header.Child;
-        var options = (Button)row.Children[^1];
-
-        var menu = new MenuFlyout();
-
-        var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } };
-        rename.Click += (_, _) => _ = RenameTabAsync(entry);
-
-        var snapshot = new MenuFlyoutItem { Text = "Take snapshot", Icon = new FontIcon { Glyph = "" } };
-        snapshot.Click += (_, _) => entry.View.TakeSnapshotManually();
-
-        var export = new MenuFlyoutItem { Text = "Export transcript…", Icon = new FontIcon { Glyph = "" } };
-        export.Click += (_, _) => _ = entry.View.ExportTranscriptAsync();
-
-        var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
-        close.Click += (_, _) => CloseTab(entry);
-
-        menu.Items.Add(rename);
-        menu.Items.Add(snapshot);
-        menu.Items.Add(export);
-        menu.Items.Add(new MenuFlyoutSeparator());
-        menu.Items.Add(close);
-
-        // Closing the last agent is allowed now — it leaves an empty chat (see EnterEmptyState);
-        // Settings/MCP simply disable until a new agent exists.
-
-        options.Flyout = menu;
-    }
-
-    /// Renames a tab via a small dialog. The name is display-only (the folder stays in
-    /// the header); it survives folder changes and model switches.
-    private async Task RenameTabAsync(ChatTabEntry entry)
-    {
-        var box = new TextBox { Text = entry.View.Session.Title };
-        box.SelectAll();
-
-        var dialog = new ContentDialog
-        {
-            Title = "Rename agent",
-            Content = box,
-            PrimaryButtonText = "Rename",
-            CloseButtonText = "Cancel",
-            DefaultButton = ContentDialogButton.Primary,
-            XamlRoot = Content.XamlRoot,
-        };
-
-        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
-        var name = box.Text.Trim();
-        if (name.Length == 0) return;
-
-        entry.View.Session.Title = name;
-        entry.Label.Text = name;
-        RefreshTabStrip();
-    }
-
-    /// Selecting an agent also returns you to the chat page — the Settings you were
-    /// looking at belonged to the agent you just left.
-    private void SelectTab(ChatTabEntry entry)
-    {
-        // Selecting a tab NEVER changes the compare pair — it only changes the active agent. If that
-        // agent is in the pair, ApplyPaneLayout shows the split; otherwise it shows the agent single.
-        _selected = entry;
-        _sessions.Activate(entry.View.Session);
-        RefreshTabStrip();
-        SwitchPage("chat");
-
-        // Reveal the selected tab. Try now (covers clicking an already-laid-out tab) and again when
-        // the strip re-lays-out (covers a just-added agent, whose width/extent settle a frame later,
-        // via TabStrip_SizeChanged). Pending stays set until the tab is actually laid out.
-        _scrollToSelectedPending = true;
-        DispatcherQueue.TryEnqueue(TryScrollToSelected);
-    }
-
-    private bool _scrollToSelectedPending;
-
-    // Scroll the strip so the selected tab is fully visible — a manual ChangeView so a newly created
-    // (last) tab scrolls ALL THE WAY to the end. StartBringIntoView only did a minimal scroll and ran
-    // before the extent settled, so it stopped short. No-op once the tab is visible; stays pending
-    // (retried on the next strip SizeChanged) while the tab isn't laid out yet (ActualWidth == 0).
-    private void TryScrollToSelected()
-    {
-        if (!_scrollToSelectedPending || _selected is null) return;
-        var header = _selected.Header;
-        if (header.ActualWidth <= 0) return;   // not laid out yet — retry on the next SizeChanged
-
-        double left = header.TransformToVisual(TabStrip)
-                            .TransformPoint(new Windows.Foundation.Point(0, 0)).X;
-        double right = left + header.ActualWidth;
-        double viewLeft = TabScroller.HorizontalOffset;
-        double viewRight = viewLeft + TabScroller.ViewportWidth;
-        const double pad = 8;
-
-        if (right > viewRight)                 // off the right (e.g. a just-added last tab)
-            TabScroller.ChangeView(right - TabScroller.ViewportWidth + pad, null, null);
-        else if (left < viewLeft)              // off the left
-            TabScroller.ChangeView(Math.Max(0, left - pad), null, null);
-
-        _scrollToSelectedPending = false;
-    }
-
-    private void TabStrip_SizeChanged(object sender, SizeChangedEventArgs e) => TryScrollToSelected();
-
-    private void CloseTab(ChatTabEntry entry)
-    {
-        var index = _tabs.IndexOf(entry);
-        if (index < 0) return;
-
-        _tabs.RemoveAt(index);
-        TabStrip.Children.Remove(entry.Header);
-
-        // Shut down BEFORE unparenting. Removing the view from the tree unloads the WebView2 and
-        // nulls its CoreWebView2, so Close() and any last transcript write would hit null.
-        entry.View.Shutdown();
-        TabHost.Children.Remove(entry.View);
-        _sessions.CloseSession(entry.View.Session);
-        ArchiveClosedSession(entry.View.Session);   // closed tab = recoverable from History, not gone
-
-        // Closing the last agent is allowed: you're left with the empty chat background until you
-        // open another. Settings/MCP disable meanwhile (they act on an agent), handled in SwitchPage.
-        if (_tabs.Count == 0)
-        {
-            _selected = null;
-            ValidateSplit();     // nothing left to compare → exits split
-            EnterEmptyState();
-            SaveWorkspace();
-            return;
-        }
-
-        if (!ReferenceEquals(_selected, entry))
-        {
-            ValidateSplit();     // repair the right pane if that's what closed
-            RefreshTabStrip();
-            SaveWorkspace();
-            return;
-        }
-
-        _selected = null;
-        SelectTab(_tabs[Math.Min(index, _tabs.Count - 1)]);
-        ValidateSplit();         // the new selection might collide with the right pane
-        SaveWorkspace();
-    }
-
-    /// Shows the "no agents open" background — the chat area with nothing in it. Bounces off
-    /// any full-screen page back to chat (Settings/MCP have no agent to act on now).
-    private void EnterEmptyState()
-    {
-        RefreshTabStrip();       // empties the toast; disables Settings/MCP via RefreshNavIcons
-        SwitchPage("chat");      // reveals the empty-state panel + its background
-        if (_snapshotsPanelOpen) PopulateSnapshots();   // no agent now → disable Import + show notice
-    }
-
-    // ============================================================
-    // Split / compare view — two agents side by side. The compare PAIR (_compareA left, _compareB
-    // right) is a remembered, explicit choice: set only by the Split button and the compare-bar
-    // pickers, NEVER by clicking a tab. The split is shown whenever the active tab (_selected) is one
-    // of the pair; clicking any other tab shows that agent normally while the pair waits, and
-    // clicking a paired tab brings the split back. Both panes are ordinary tab views moved between
-    // grid columns via ApplyPaneLayout — never reparented, so their WebViews survive.
-    // ============================================================
-
-    private ChatTabEntry? _compareA;   // left pane
-    private ChatTabEntry? _compareB;   // right pane
-    private double _splitLeftFraction = 0.5;   // divider position, preserved across page visits
-    private bool _syncingSplitCombos;
-    private bool _draggingPane;
-
-    /// A valid, distinct compare pair is configured (both agents still open).
-    private bool HasComparePair =>
-        _compareA != null && _compareB != null
-        && _tabs.Contains(_compareA) && _tabs.Contains(_compareB)
-        && !ReferenceEquals(_compareA, _compareB);
-
-    /// The split is actually being shown right now: a pair exists, we're on the chat page,
-    /// and the active tab is one of the two paired agents (clicking any other agent shows it single).
-    private bool SplitActive =>
-        HasComparePair && _currentPage == "chat" && _selected != null
-        && (ReferenceEquals(_selected, _compareA) || ReferenceEquals(_selected, _compareB));
-
-    private void SplitButton_Click(object sender, RoutedEventArgs e)
-    {
-        if (HasComparePair)
-        {
-            // Toggle: showing the split → turn compare off; pair configured but viewing another
-            // agent → jump back into the split.
-            if (SplitActive) ExitSplit();
-            else if (_compareA != null) SelectTab(_compareA);
-            return;
-        }
-        if (_tabs.Count < 2 || _selected == null) return;   // button is disabled here anyway
-
-        _compareA = _selected;
-        _compareB = _tabs.FirstOrDefault(t => !ReferenceEquals(t, _selected));
-        RefreshSplitCombos();
-        SwitchPage("chat");        // _selected is in the pair → ApplyPaneLayout shows the split
-        RefreshSplitButton();
-    }
-
-    private void ExitSplit_Click(object sender, RoutedEventArgs e) => ExitSplit();
-
-    private void ExitSplit()
-    {
-        _compareA = null;
-        _compareB = null;
-        ApplyPaneLayout();
-        RefreshSplitButton();
-    }
-
-    /// Places the visible agent view(s) into columns and sizes them. Single view: column 0
-    /// fills (divider + right column collapse to 0). Split: _compareA in column 0, _compareB in
-    /// column 2, divider between. Setting Grid.Column does NOT reparent, so WebViews are untouched.
-    private void ApplyPaneLayout()
-    {
-        var split = SplitActive;
-        var showingChat = _currentPage == "chat";
-
-        foreach (var tab in _tabs)
-        {
-            bool inPair = ReferenceEquals(tab, _compareA) || ReferenceEquals(tab, _compareB);
-            var visible = showingChat && (split ? inPair : ReferenceEquals(tab, _selected));
-            tab.View.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
-            Grid.SetColumn(tab.View, split && ReferenceEquals(tab, _compareB) ? 2 : 0);
-        }
-
-        if (split)
-        {
-            PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
-            PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
-            PaneSplitCol.Width = GridLength.Auto;
-            PaneSplitter.Visibility = Visibility.Visible;
-            SplitBar.Visibility = Visibility.Visible;
-        }
-        else
-        {
-            PaneLeftCol.Width = new GridLength(1, GridUnitType.Star);
-            PaneSplitCol.Width = new GridLength(0);
-            PaneRightCol.Width = new GridLength(0);
-            PaneSplitter.Visibility = Visibility.Collapsed;
-            SplitBar.Visibility = Visibility.Collapsed;
-        }
-    }
-
-    /// Re-fills the two pane pickers and re-selects the sides. Items are plain STRINGS
-    /// (agent titles) selected by INDEX into  — deliberately NOT ComboBoxItem
-    /// objects: adding containers directly as items and rebuilding them makes WinUI's ComboBox throw
-    /// COMException 0x80070490 "Element not found" on the next selection. Each combo gets its own
-    /// list instance (a shared ItemsSource across two ComboBoxes is asking for trouble).
-    private void RefreshSplitCombos()
-    {
-        _syncingSplitCombos = true;
-        SplitLeftCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
-        SplitRightCombo.ItemsSource = _tabs.Select(t => t.View.Session.Title).ToList();
-        SplitLeftCombo.SelectedIndex = _compareA == null ? -1 : _tabs.IndexOf(_compareA);
-        SplitRightCombo.SelectedIndex = _compareB == null ? -1 : _tabs.IndexOf(_compareB);
-        _syncingSplitCombos = false;
-    }
-
-    // Both pickers defer their ENTIRE reaction to the next dispatcher tick. A ComboBox raises
-    // SelectionChanged from inside a layout pass, and the reaction restructures the visual tree
-    // (moves a ChatTabView + its WebView between grid columns) and rebuilds the pickers — both
-    // illegal mid-layout / mid-event and the source of the App-level crash. Off the event, on a
-    // clean tick, they're safe. Picking an agent for one pane that's already the other pane swaps
-    // the two. The chosen agent becomes active, so the split stays on screen.
-    private void SplitLeftCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        if (_syncingSplitCombos) return;
-        var idx = SplitLeftCombo.SelectedIndex;
-        if (idx < 0 || idx >= _tabs.Count) return;
-        var entry = _tabs[idx];
-        DispatcherQueue.TryEnqueue(() =>
-        {
-            if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareA)) return;
-            if (ReferenceEquals(entry, _compareB)) _compareB = _compareA;   // swap sides
-            _compareA = entry;
-            RefreshSplitCombos();
-            SelectTab(entry);   // make the left pane active so the split stays shown
-        });
-    }
-
-    private void SplitRightCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        if (_syncingSplitCombos) return;
-        var idx = SplitRightCombo.SelectedIndex;
-        if (idx < 0 || idx >= _tabs.Count) return;
-        var entry = _tabs[idx];
-        DispatcherQueue.TryEnqueue(() =>
-        {
-            if (!_tabs.Contains(entry) || ReferenceEquals(entry, _compareB)) return;
-            if (ReferenceEquals(entry, _compareA)) _compareA = _compareB;   // swap sides
-            _compareB = entry;
-            RefreshSplitCombos();
-            SelectTab(entry);   // make the right pane active so the split stays shown
-        });
-    }
-
-    /// Keeps the compare pair valid after the agent set changes. If either paired agent was
-    /// closed the pair is dropped (compare turns off); otherwise the pickers are resynced.
-    private void ValidateSplit()
-    {
-        if (_compareA == null && _compareB == null) return;   // no compare configured
-        if (!HasComparePair)
-        {
-            _compareA = null;
-            _compareB = null;
-            ApplyPaneLayout();
-            RefreshSplitButton();
-            return;
-        }
-        RefreshSplitCombos();
-        ApplyPaneLayout();
-        RefreshSplitButton();
-    }
-
-    private void RefreshSplitButton()
-    {
-        SplitButton.IsEnabled = HasComparePair || _tabs.Count >= 2;
-        var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
-        var normal = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
-        // Accent whenever a compare pair is configured — even while viewing a non-paired agent — so
-        // it reads as "compare is on; click a paired tab (or me) to see it."
-        SplitButtonIcon.Foreground = HasComparePair ? accent : normal;
-    }
-
-    // ---- divider drag: repartition the two panes' star widths by pointer X over TabHost ----
-    private void PaneSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
-    {
-        _draggingPane = true;
-        ((UIElement)sender).CapturePointer(e.Pointer);
-    }
-
-    private void PaneSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingPane) return;
-        var w = TabHost.ActualWidth;
-        if (w <= 0) return;
-        var x = e.GetCurrentPoint(TabHost).Position.X;
-        _splitLeftFraction = Math.Clamp(x / w, 0.2, 0.8);   // keep both panes usable
-        PaneLeftCol.Width = new GridLength(_splitLeftFraction, GridUnitType.Star);
-        PaneRightCol.Width = new GridLength(1 - _splitLeftFraction, GridUnitType.Star);
-    }
-
-    private void PaneSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
-    {
-        if (!_draggingPane) return;
-        _draggingPane = false;
-        ((UIElement)sender).ReleasePointerCapture(e.Pointer);
-    }
-
-    /// Paints the custom chat background image behind the empty state, so closing every
-    /// agent leaves the same backdrop you'd see behind a transcript — same file and opacity. Hidden
-    /// when there's no image set, or when an agent is open (its own WebView paints it then). Loaded
-    /// via a StorageFile stream, the reliable path for an arbitrary filesystem image in unpackaged
-    /// WinUI; best-effort, so a missing/locked file just falls back to the flat themed colour.
-    private async Task RefreshEmptyBackgroundAsync()
-    {
-        var show = _currentPage == "chat" && _tabs.Count == 0;
-        var file = ThemeManager.ChatBackgroundFile;
-        if (!show || string.IsNullOrEmpty(file) || !File.Exists(file))
-        {
-            EmptyBgImage.Visibility = Visibility.Collapsed;
-            EmptyBgImage.Source = null;
-            return;
-        }
-        try
-        {
-            var sf = await Windows.Storage.StorageFile.GetFileFromPathAsync(file);
-            using var stream = await sf.OpenReadAsync();
-            var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage();
-            await bmp.SetSourceAsync(stream);
-            EmptyBgImage.Source = bmp;
-            EmptyBgImage.Opacity = ThemeManager.ChatBackgroundOpacity;
-            EmptyBgImage.Visibility = Visibility.Visible;
-        }
-        catch
-        {
-            EmptyBgImage.Visibility = Visibility.Collapsed;
-        }
-    }
-
-    private void RefreshTabStrip()
-    {
-        var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
-        var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"];
-        var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
-        var background = (SolidColorBrush)Application.Current.Resources["MandoBackgroundBrush"];
-        var transparent = new SolidColorBrush(Colors.Transparent);
-
-        ChatTabEntry? pending = null;
-
-        foreach (var tab in _tabs)
-        {
-            var isSelected = ReferenceEquals(tab, _selected);
-            tab.Header.Background = isSelected ? background : transparent;
-            tab.Header.BorderBrush = isSelected ? accent : border;
-            tab.Label.Foreground = isSelected ? accent : dim;
-
-            tab.View.IsSelected = isSelected;
-            var badged = tab.View.IsApprovalOpen && !isSelected;
-            tab.Badge.Visibility = badged ? Visibility.Visible : Visibility.Collapsed;
-
-            // Toast for any approval you can't currently see: a background tab, OR the selected tab
-            // while you're away on Settings/MCP/Appearance (its chat — and the approval — is
-            // collapsed there, so without this you'd get no notice at all).
-            if (tab.View.IsApprovalOpen && (!isSelected || _currentPage != "chat"))
-                pending ??= tab;
-        }
-
-        // With several agents running, "an approval is waiting" is useless without saying where,
-        // so the toast names the agent and selecting it is one click.
-        _pendingApprovalTab = pending;
-        if (pending != null && !_approvalToastDismissed)
-        {
-            ApprovalToastText.Text = pending.View.ApprovalHeadline;
-            ApprovalToastTarget.Text = $"Click to review in \"{pending.View.Session.Title}\"";
-            ApprovalToast.Visibility = Visibility.Visible;
-        }
-        else
-        {
-            ApprovalToast.Visibility = Visibility.Collapsed;
-            if (pending == null) _approvalToastDismissed = false;   // next approval earns a fresh toast
-        }
-
-        RefreshNavIcons();
-        RefreshSplitButton();
-        LayoutTabStrip();
-    }
-
-    // Tabs stay a comfortable width when there's room, and only shrink once enough agents are open
-    // that they'd otherwise overflow — down to a floor, past which the strip scrolls instead.
-    private const double TabComfortableWidth = 200;
-    private const double TabMinWidth = 104;
-
-    private void LayoutTabStrip()
-    {
-        int count = _tabs.Count;
-        if (count == 0) return;
-
-        // The visible strip is the scroller's viewport; a later SizeChanged fixes up the first
-        // pass if it hasn't been measured yet (ActualWidth == 0 during early layout).
-        double viewport = TabScroller.ActualWidth;   // tabs only — the add button now lives outside
-        if (viewport <= 0) return;
-
-        double spacing = 4 * Math.Max(0, count - 1);         // 4px between adjacent tabs
-        double avail = viewport - spacing - 8;               // margin so rounding never forces a scrollbar
-
-        double per = Math.Max(TabMinWidth, Math.Min(TabComfortableWidth, avail / count));
-        foreach (var tab in _tabs)
-            tab.Header.Width = per;
-    }
-
-    private void TabScroller_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTabStrip();
-
-    // Mouse wheel scrolls the strip horizontally when there are more tabs than fit — a convenience
-    // on top of the visible scrollbar (which sits in a reserved bottom lane so it never overlaps
-    // the tabs). Touchpad / touch horizontal scrolling works natively.
-    private void TabScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
-    {
-        if (TabScroller.ScrollableWidth <= 0) return;   // everything fits; nothing to scroll
-        var delta = e.GetCurrentPoint(TabScroller).Properties.MouseWheelDelta;
-        TabScroller.ChangeView(TabScroller.HorizontalOffset - delta, null, null);
-        e.Handled = true;
-    }
-
-    private void ApprovalToast_Tapped(object sender, TappedRoutedEventArgs e)
-    {
-        if (_pendingApprovalTab != null) SelectTab(_pendingApprovalTab);
-    }
-
-    private void ApprovalToastDismiss_Click(object sender, RoutedEventArgs e)
-    {
-        _approvalToastDismissed = true;
-        ApprovalToast.Visibility = Visibility.Collapsed;
-    }
-
-    // ============================================================
-    // Settings page
-    // ============================================================
-
-    private bool _loadingSettings;
-
-    /// Populates every control from the live config. Guarded so control-change
-    /// events fired during population don't write back.
-    private void LoadSettings()
-    {
-        _loadingSettings = true;
-        try
-        {
-            // The SELECTED agent's config, not the saved defaults. Switch agents and this page
-            // shows different values.
-            var cfg = _controller.Config;
-            SettingsAgentChip.Text = _sessions.Active?.Title ?? "";
-            EndpointBox.Text = cfg.OllamaEndpoint;
-            _modelComboTarget = cfg.GetEffectiveModelName();
-            ApplyModelComboTarget();
-            S_ContextLength.Value = cfg.ContextLength;
-            S_Temperature.Value = cfg.Temperature;
-            S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##");
-            S_MaxTokens.Value = cfg.MaxTokens;
-            S_Streaming.SelectedItem = cfg.ResponseStreaming;
-            S_TaskPlanning.IsOn = cfg.EnableTaskPlanning;
-            S_DiffApprovals.IsOn = cfg.EnableDiffApprovals;
-            S_AutoContinue.IsOn = cfg.EnableAutoContinuation;
-            S_MaxContinuations.Value = cfg.MaxAutoContinuations;
-            S_RequestTimeout.Value = cfg.RequestTimeoutMinutes;
-            S_StallTimeout.Value = cfg.ModelResponseTimeoutSeconds;
-            S_ToolBudget.Value = cfg.ToolResultCharBudget;
-            S_RenderTimeout.Value = cfg.MarkdownRenderTimeoutSeconds;
-            S_WebSearch.IsOn = cfg.EnableWebSearch;
-            S_TavilyKey.Password = cfg.TavilyApiKey ?? "";
-            S_TavilyKey.PasswordRevealMode = PasswordRevealMode.Hidden;
-            TavilyViewButton.Content = "View";
-            TavilyViewButton.IsEnabled = !string.IsNullOrEmpty(cfg.TavilyApiKey);
-            for (int i = 0; i < UiTheme.All.Count; i++)
-                if (UiTheme.All[i] == ThemeManager.Current) ThemeList.SelectedIndex = i;
-            SettingsStatus.Text = "";
-        }
-        finally
-        {
-            _loadingSettings = false;
-        }
-    }
-
-    private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
-    {
-        var s = sender.SelectedItem;
-        TabPanel_Model.Visibility = s == Tab_Model ? Visibility.Visible : Visibility.Collapsed;
-        TabPanel_Behavior.Visibility = s == Tab_Behavior ? Visibility.Visible : Visibility.Collapsed;
-        TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed;
-
-        // "Reset" acts on the visible tab, so its label names that tab.
-        ResetTabButtonText.Text = s == Tab_Behavior ? "Reset Behavior"
-            : s == Tab_Integrations ? "Reset Integrations" : "Reset Model";
-        // Every remaining tab is per-agent now (Appearance moved to its own rail page), so
-        // "Make Default for New Agents" always applies.
-    }
-
-    /// False until the constructor has loaded persisted appearance settings into the
-    /// sliders. The sliders' XAML default Values fire ValueChanged during InitializeComponent —
-    /// BEFORE ThemeManager.Initialize reads ui-settings.json — and a Save() in that window
-    /// overwrites the file with defaults (that bug ate users' saved background image).
-    private bool _appearanceReady;
-
-    private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
-    {
-        if (!_appearanceReady) return;
-        S_WindowOpacityLabel.Text = $"{(int)e.NewValue}%";
-        ThemeManager.SetWindowOpacity(e.NewValue / 100.0);
-        ApplyWindowOpacity(ThemeManager.WindowOpacity);
-    }
-
-    // ============================================================
-    // Chat background image (Appearance page)
-    // ============================================================
-
-    private async void BgChoose_Click(object sender, RoutedEventArgs e)
-    {
-        var picker = new Windows.Storage.Pickers.FileOpenPicker();
-        // Desktop apps must marry the picker to an HWND before use.
-        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
-        foreach (var ext in new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" })
-            picker.FileTypeFilter.Add(ext);
-
-        var file = await picker.PickSingleFileAsync();
-        if (file == null) return;
-
-        ThemeManager.SetChatBackground(file.Path);
-        UpdateBgControls();
-        ApplyThemeToAllTabs();
-    }
-
-    private void BgClear_Click(object sender, RoutedEventArgs e)
-    {
-        ThemeManager.SetChatBackground(null);
-        UpdateBgControls();
-        ApplyThemeToAllTabs();
-    }
-
-    private void BoxedMessages_Toggled(object sender, RoutedEventArgs e)
-    {
-        if (!_appearanceReady) return;   // see _appearanceReady — a Save() here wipes settings
-        ThemeManager.SetBoxedMessages(BoxedMessagesToggle.IsOn);
-        ApplyThemeToAllTabs();   // live — existing messages re-skin instantly
-    }
-
-    private void BgOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
-    {
-        if (!_appearanceReady) return;   // see _appearanceReady — a Save() here wipes settings
-        S_BgOpacityLabel.Text = $"{(int)e.NewValue}%";
-        ThemeManager.SetChatBackgroundOpacity(e.NewValue / 100.0);
-        ApplyThemeToAllTabs();   // live preview while dragging — the script is tiny
-    }
-
-    private void UpdateBgControls()
-    {
-        var hasImage = ThemeManager.ChatBackgroundFile != null;
-        BgFileLabel.Text = hasImage ? "Image set ✓" : "No image set";
-        BgClearButton.IsEnabled = hasImage;
-        S_BgOpacity.IsEnabled = hasImage;
-        // The preview WebView renders the image itself (via the userdata host + theme
-        // script), so there is no XAML image to update here anymore.
-    }
-
-    // WinUI has no Window.Opacity — whole-window translucency is a Win32 layered-window
-    // attribute on the HWND. At 100% the layered style is removed entirely so the
-    // compositor does no extra work for the default solid window.
-    private const int GWL_EXSTYLE = -20;
-    private const int WS_EX_LAYERED = 0x80000;
-    private const uint LWA_ALPHA = 0x2;
-
-    [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
-    private static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
-    [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
-    private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
-    [System.Runtime.InteropServices.DllImport("user32.dll")]
-    private static extern bool SetLayeredWindowAttributes(nint hWnd, uint crKey, byte bAlpha, uint dwFlags);
-
-    private void ApplyWindowOpacity(double opacity)
-    {
-        var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
-        var exStyle = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
-        if (opacity >= 0.995)
-        {
-            SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle & ~(nint)WS_EX_LAYERED);
-        }
-        else
-        {
-            SetWindowLongPtr(hwnd, GWL_EXSTYLE, exStyle | (nint)WS_EX_LAYERED);
-            SetLayeredWindowAttributes(hwnd, 0, (byte)Math.Round(opacity * 255), LWA_ALPHA);
-        }
-    }
-
-    private void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        if (_loadingSettings || ThemeList.SelectedItem is not ThemeVm vm) return;
-        ThemeManager.Apply(vm.Theme, Root);
-        ThemeHeaderValue.Text = vm.Theme.Name;
-        SettingsStatus.Text = $"Theme set to {vm.Theme.Name}.";
-    }
-
-    private string _modelComboTarget = "";
-
-    /// An editable ComboBox drops programmatic Text while its template isn't
-    /// loaded (the Settings page starts collapsed) — so the intended model name is kept
-    /// here and re-applied on the combo's Loaded event. Selecting the matching pulled
-    /// model when one exists also marks it in the dropdown.
-    private void ApplyModelComboTarget()
-    {
-        if (_modelComboTarget.Length == 0) return;
-        if (ModelCombo.ItemsSource is IList models)
-        {
-            var idx = models.IndexOf(_modelComboTarget);
-            if (idx >= 0)
-            {
-                ModelCombo.SelectedIndex = idx;
-                return;
-            }
-        }
-        ModelCombo.Text = _modelComboTarget;
-    }
-
-    /// One write path for the whole page: ConfigKeySetter via the controller.
-    private async Task ApplySettingAsync(string key, string value)
-    {
-        var (ok, message) = await _controller.ApplyConfigKeyAsync(key, value);
-        SettingsStatus.Text = message;
-        if (!ok) LoadSettings();   // revert the control to the real value
-    }
-
-    private async void Setting_Toggled(object sender, RoutedEventArgs e)
-    {
-        if (_loadingSettings) return;
-        var toggle = (ToggleSwitch)sender;
-        await ApplySettingAsync((string)toggle.Tag, toggle.IsOn ? "true" : "false");
-    }
-
-    private async void Setting_NumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
-    {
-        if (_loadingSettings) return;
-
-        // Clearing the box (its "X") or typing something invalid yields NaN. Don't apply it, and
-        // don't leave the field empty/stuck — snap back to the last valid value so the spin buttons
-        // keep working. If even the old value is gone, reload the whole form from config.
-        if (double.IsNaN(args.NewValue))
-        {
-            if (!double.IsNaN(args.OldValue)) sender.Value = args.OldValue;
-            else LoadSettings();
-            return;
-        }
-
-        await ApplySettingAsync((string)sender.Tag, ((long)args.NewValue).ToString());
-    }
-
-    private async void Temperature_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
-    {
-        if (_loadingSettings) return;
-        S_TemperatureLabel.Text = e.NewValue.ToString("0.##");
-        await ApplySettingAsync("temperature", e.NewValue.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture));
-    }
-
-    private async void Streaming_Changed(object sender, SelectionChangedEventArgs e)
-    {
-        if (_loadingSettings || S_Streaming.SelectedItem is not string mode) return;
-        await ApplySettingAsync("streaming", mode);
-    }
-
-    /// Enables View as soon as there's anything to reveal (saved key or fresh typing).
-    private void TavilyKey_Changed(object sender, RoutedEventArgs e) =>
-        TavilyViewButton.IsEnabled = S_TavilyKey.Password.Length > 0;
-
-    private void TavilyView_Click(object sender, RoutedEventArgs e)
-    {
-        var show = S_TavilyKey.PasswordRevealMode != PasswordRevealMode.Visible;
-        S_TavilyKey.PasswordRevealMode = show ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden;
-        TavilyViewButton.Content = show ? "Hide" : "View";
-    }
-
-    private async void TavilySave_Click(object sender, RoutedEventArgs e)
-    {
-        var key = S_TavilyKey.Password;
-        if (string.IsNullOrWhiteSpace(key))
-        {
-            SettingsStatus.Text = "Enter a key first (or type 'clear' to remove the saved one).";
-            return;
-        }
-        await ApplySettingAsync("tavilyKey", key.Trim());
-        LoadSettings();
-    }
-
-    private async void RefreshModels_Click(object sender, RoutedEventArgs e) =>
-        await RefreshModelListAsync();
-
-    private async Task RefreshModelListAsync()
-    {
-        ModelListStatus.Text = "Fetching models…";
-        var models = await Task.Run(_controller.ListModelsAsync);
-        if (!string.IsNullOrEmpty(ModelCombo.Text)) _modelComboTarget = ModelCombo.Text;
-        ModelCombo.ItemsSource = models;
-        ApplyModelComboTarget();
-        ModelListStatus.Text = models.Count == 0
-            ? "No models found — is Ollama running? (ollama serve, then ollama pull )"
-            : $"{models.Count} model(s) available.";
-    }
-
-    private async void SettingsSave_Click(object sender, RoutedEventArgs e)
-    {
-        var endpoint = EndpointBox.Text;
-        var model = ModelCombo.Text;
-        SettingsStatus.Text = "Connecting… (details land in the chat transcript)";
-        await Task.Run(() => _controller.ApplyConnectionSettingsAsync(endpoint, model));
-        SettingsStatus.Text = _controller.IsConnected
-            ? $"✓ Connected — {_controller.ModelName}"
-            : "Couldn't connect — see the chat transcript for details.";
-        LoadSettings();
-    }
-
-    // ============================================================
-    // MCP page
-    // ============================================================
-
-    private async void McpRefresh_Click(object sender, RoutedEventArgs e) => await RefreshMcpListAsync();
-
-    // Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter).
-    private List _allMcpRows = new();
-    private bool _loadingMcp;
-
-    private async Task RefreshMcpListAsync()
-    {
-        // Servers are shared across agents and enabled/disabled per-server now, so MCP is always on
-        // at the agent level. Make sure the active agent actually attaches tools (new agents inherit
-        // EnableMcp=true from defaults; this only fires for an agent someone turned off previously).
-        if (!_controller.Config.EnableMcp)
-            await ApplySettingAsync("mcp", "true");
-
-        McpPageStatus.Text = "Checking server status…";
-        var rows = await Task.Run(_controller.GetMcpStatusRowsAsync);
-
-        var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
-        var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
-        _allMcpRows = rows.Select(r => new McpRow
-        {
-            Name = r.Name,
-            Transport = r.Transport,
-            Status = r.Status,
-            StatusBrush = r.Connected ? green : gold,
-            Enabled = !r.Disabled,
-        }).ToList();
-
-        ApplyMcpFilter();
-    }
-
-    private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
-        ApplyMcpFilter();
-
-    private string _mcpFilter = "all";
-
-    private void McpFilter_Click(object sender, RoutedEventArgs e)
-    {
-        _mcpFilter = (string)((FrameworkElement)sender).Tag;
-        McpFilterAll.IsChecked = _mcpFilter == "all";
-        McpFilterEnabled.IsChecked = _mcpFilter == "enabled";
-        McpFilterDisabled.IsChecked = _mcpFilter == "disabled";
-        McpFilterFailed.IsChecked = _mcpFilter == "failed";
-        ApplyMcpFilter();
-    }
-
-    /// Applies search + active chip, then groups into Enabled/Disabled sections. The
-    /// programmatic ItemsSource set realizes rows (firing each toggle), guarded in McpEnabled_Toggled.
-    private void ApplyMcpFilter()
-    {
-        var q = McpSearchBox.Text?.Trim() ?? "";
-        IEnumerable filtered = _allMcpRows;
-        if (!string.IsNullOrEmpty(q))
-            filtered = filtered.Where(r =>
-                r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
-                r.Transport.Contains(q, StringComparison.OrdinalIgnoreCase));
-        filtered = _mcpFilter switch
-        {
-            "enabled" => filtered.Where(r => r.Enabled),
-            "disabled" => filtered.Where(r => !r.Enabled),
-            "failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
-            _ => filtered,
-        };
-        var shown = filtered.ToList();
-
-        var groups = new List();
-        var en = shown.Where(r => r.Enabled).ToList();
-        var dis = shown.Where(r => !r.Enabled).ToList();
-        if (en.Count > 0) groups.Add(new McpRowGroup($"Enabled ({en.Count})", en));
-        if (dis.Count > 0) groups.Add(new McpRowGroup($"Disabled ({dis.Count})", dis));
-
-        var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
-        _loadingMcp = true;
-        McpList.ItemsSource = cvs.View;
-        _loadingMcp = false;
-
-        McpEditButton.IsEnabled = false;
-        McpRemoveButton.IsEnabled = false;
-
-        var total = _allMcpRows.Count;
-        var enabledTotal = _allMcpRows.Count(r => r.Enabled);
-        var active = q.Length > 0 || _mcpFilter != "all";
-        if (total == 0)
-            McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one.";
-        else if (active)
-            McpPageStatus.Text = $"{shown.Count} of {total} shown  ·  {enabledTotal} enabled";
-        else
-            McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled";
-    }
-
-    /// Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts
-    /// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload).
-    private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
-    {
-        // Fires while the list realizes rows and binds IsOn — ignore those (state already matches).
-        if (_loadingMcp) return;
-        if (sender is not ToggleSwitch sw || sw.DataContext is not McpRow row) return;
-        if (sw.IsOn == row.Enabled) return;
-
-        // Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
-        if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
-        server.Disabled = !sw.IsOn;
-
-        McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
-        await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
-        await RefreshMcpListAsync();
-    }
-
-    /// Runs a slash command through the normal pipeline (transcript echo, wizard
-    /// overlays, busy state all included), then refreshes the server list.
-    private async Task RunMcpCommandAsync(string command)
-    {
-        if (_controller.IsProcessing)
-        {
-            McpPageStatus.Text = "Busy — wait for the current request to finish.";
-            return;
-        }
-        await Task.Run(() => _controller.SubmitAsync(command));
-        await RefreshMcpListAsync();
-    }
-
-    private void McpAdd_Click(object sender, RoutedEventArgs e) => OpenMcpEditor(null);
-
-    private void McpEdit_Click(object sender, RoutedEventArgs e)
-    {
-        if (McpList.SelectedItem is not McpRow row)
-        {
-            McpPageStatus.Text = "Select a server to edit first.";
-            return;
-        }
-        OpenMcpEditor(row.Name);
-    }
-
-    private void McpList_SelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        var hasSelection = McpList.SelectedItem is McpRow;
-        McpEditButton.IsEnabled = hasSelection;
-        McpRemoveButton.IsEnabled = hasSelection;
-    }
-
-    private void McpList_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
-    {
-        if (McpList.SelectedItem is McpRow row) OpenMcpEditor(row.Name);
-    }
-
-    private async void McpRemove_Click(object sender, RoutedEventArgs e)
-    {
-        if (McpList.SelectedItem is not McpRow row)
-        {
-            McpPageStatus.Text = "Select a server to remove first.";
-            return;
-        }
-        await RunMcpCommandAsync($"/mcp remove {row.Name}");
-    }
-
-    private async void McpReload_Click(object sender, RoutedEventArgs e) =>
-        await RunMcpCommandAsync("/mcp-reload");
-
-    // ============================================================
-    // MCP server editor modal (add + edit)
-    // ============================================================
-
-    private string? _mcpEditOriginalName;
-
-    private void OpenMcpEditor(string? serverName)
-    {
-        _mcpEditOriginalName = serverName;
-        M_StatusBar.IsOpen = false;
-        M_TestToolsTable.Visibility = Visibility.Collapsed;
-        M_TestSpin.Visibility = Visibility.Collapsed;
-        McpEditorTestButton.IsEnabled = true;
-        McpEditorSaveButton.IsEnabled = true;
-
-        if (serverName != null && _controller.Config.McpServers.TryGetValue(serverName, out var cfg))
-        {
-            McpEditorTitle.Text = $"Edit MCP server — {serverName}";
-            McpEditorSaveButton.Content = "Save & Reconnect";
-            M_Name.Text = serverName;
-            M_Transport.SelectedIndex = cfg.IsHttp ? 1 : 0;
-            M_Command.Text = cfg.Command ?? "";
-            M_Args.Text = string.Join(" ", cfg.Args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a));
-            M_Env.Text = string.Join("\n", cfg.Env.Select(kv => $"{kv.Key}={kv.Value}"));
-            M_Url.Text = cfg.Url ?? "";
-            M_Headers.Text = string.Join("\n", cfg.Headers.Select(kv => $"{kv.Key}={kv.Value}"));
-            M_Disabled.IsOn = cfg.Disabled;
-        }
-        else
-        {
-            McpEditorTitle.Text = "Add MCP server";
-            McpEditorSaveButton.Content = "Save & Connect";
-            M_Name.Text = "";
-            M_Transport.SelectedIndex = 0;
-            M_Command.Text = "";
-            M_Args.Text = "";
-            M_Env.Text = "";
-            M_Url.Text = "";
-            M_Headers.Text = "";
-            M_Disabled.IsOn = false;
-        }
-
-        UpdateMcpTransportPanels();
-        McpEditorOverlay.Visibility = Visibility.Visible;
-        M_Name.Focus(FocusState.Programmatic);
-    }
-
-    private void M_Transport_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
-        UpdateMcpTransportPanels();
-
-    private void UpdateMcpTransportPanels()
-    {
-        // Guard: fires during InitializeComponent before panels exist.
-        if (M_StdioPanel == null || M_HttpPanel == null) return;
-        var isHttp = M_Transport.SelectedIndex == 1;
-        M_HttpPanel.Visibility = isHttp ? Visibility.Visible : Visibility.Collapsed;
-        M_StdioPanel.Visibility = isHttp ? Visibility.Collapsed : Visibility.Visible;
-    }
-
-    private void McpEditorCancel_Click(object sender, RoutedEventArgs e) =>
-        McpEditorOverlay.Visibility = Visibility.Collapsed;
-
-    private void ShowMcpEditorError(string message)
-    {
-        M_TestSpin.IsActive = false;
-        M_TestSpin.Visibility = Visibility.Collapsed;
-        M_TestToolsTable.Visibility = Visibility.Collapsed;
-        M_StatusBar.Severity = InfoBarSeverity.Error;
-        M_StatusBar.Title = "Check the form";
-        M_StatusBar.Message = message;
-        M_StatusBar.IsOpen = true;
-    }
-
-    /// Parses "KEY=value" lines. Returns null (with an error shown) on a bad line.
-    private Dictionary? ParseKeyValueLines(string text, string label)
-    {
-        var dict = new Dictionary();
-        foreach (var rawLine in text.Split('\n'))
-        {
-            var line = rawLine.Trim();
-            if (line.Length == 0) continue;
-            var eq = line.IndexOf('=');
-            if (eq <= 0)
-            {
-                ShowMcpEditorError($"{label}: '{line}' isn't KEY=value.");
-                return null;
-            }
-            dict[line[..eq].Trim()] = line[(eq + 1)..].Trim();
-        }
-        return dict;
-    }
-
-    /// Shared validate-and-build for Test and Save. Shows the error inline and
-    /// returns false when the form isn't valid.
-    private bool TryBuildServerFromForm(bool checkNameCollision, out string name, out MandoCode.Models.McpServerConfig server)
-    {
-        M_StatusBar.IsOpen = false;
-        server = new MandoCode.Models.McpServerConfig { Disabled = M_Disabled.IsOn };
-
-        // Lowercased — servers are referenced by name in tool prefixes (mcp_).
-        name = M_Name.Text.Trim().ToLowerInvariant();
-        if (string.IsNullOrWhiteSpace(name)) { ShowMcpEditorError("Name cannot be empty."); return false; }
-        if (name.Contains(' ')) { ShowMcpEditorError("Name cannot contain spaces."); return false; }
-        if (checkNameCollision && _mcpEditOriginalName == null && _controller.Config.McpServers.ContainsKey(name))
-        {
-            ShowMcpEditorError($"A server named '{name}' already exists — edit it instead, or pick another name.");
-            return false;
-        }
-
-        if (M_Transport.SelectedIndex == 1)   // http
-        {
-            var url = M_Url.Text.Trim();
-            if (!Uri.TryCreate(url, UriKind.Absolute, out _))
-            {
-                ShowMcpEditorError("URL must be absolute (e.g. https://mcp.example.com/mcp).");
-                return false;
-            }
-            server.Url = url;
-            server.Transport = "http";
-
-            var headers = ParseKeyValueLines(M_Headers.Text, "Headers");
-            if (headers == null) return false;
-            server.Headers = headers;
-        }
-        else                                   // stdio
-        {
-            var command = M_Command.Text.Trim();
-            if (string.IsNullOrWhiteSpace(command)) { ShowMcpEditorError("Command cannot be empty."); return false; }
-            server.Command = command;
-            server.Args = ChatController.ParseShellLikeArgs(M_Args.Text.Trim());
-
-            var env = ParseKeyValueLines(M_Env.Text, "Environment variables");
-            if (env == null) return false;
-            server.Env = env;
-        }
-
-        return true;
-    }
-
-    private async void McpEditorTest_Click(object sender, RoutedEventArgs e)
-    {
-        // No collision check — testing an existing name is fine, nothing is written.
-        if (!TryBuildServerFromForm(checkNameCollision: false, out var name, out var server)) return;
-
-        M_StatusBar.Severity = InfoBarSeverity.Informational;
-        M_StatusBar.Title = "Testing connection…";
-        M_StatusBar.Message = "Connecting with these values — nothing is saved, running servers aren't touched.";
-        M_StatusBar.IsOpen = true;
-        M_TestToolsTable.Visibility = Visibility.Collapsed;
-        M_TestSpin.Visibility = Visibility.Visible;
-        M_TestSpin.IsActive = true;
-        McpEditorTestButton.IsEnabled = false;
-        McpEditorSaveButton.IsEnabled = false;
-
-        try
-        {
-            var result = await Task.Run(() => _controller.TestMcpServerAsync(name, server));
-
-            M_TestSpin.IsActive = false;
-            M_TestSpin.Visibility = Visibility.Collapsed;
-
-            if (result.Ok)
-            {
-                M_StatusBar.Severity = InfoBarSeverity.Success;
-                M_StatusBar.Title = $"Connected — {result.Tools.Count} tool(s)";
-                M_StatusBar.Message = result.Message;
-                if (result.Tools.Count > 0)
-                {
-                    M_TestTools.ItemsSource = result.Tools
-                        .Select(t => new ToolChip { Name = t.Name, Description = t.Description ?? "(no description)" })
-                        .ToList();
-                    M_TestToolsTable.Visibility = Visibility.Visible;
-                }
-            }
-            else
-            {
-                M_StatusBar.Severity = InfoBarSeverity.Error;
-                M_StatusBar.Title = "Connection failed";
-                M_StatusBar.Message = result.Message;
-            }
-        }
-        finally
-        {
-            M_TestSpin.IsActive = false;
-            McpEditorTestButton.IsEnabled = true;
-            McpEditorSaveButton.IsEnabled = true;
-        }
-    }
-
-    private async void McpEditorSave_Click(object sender, RoutedEventArgs e)
-    {
-        if (!TryBuildServerFromForm(checkNameCollision: true, out var name, out var server)) return;
-
-        McpEditorOverlay.Visibility = Visibility.Collapsed;
-        SwitchPage("mcp");
-        McpPageStatus.Text = $"Saving '{name}' and connecting…";
-
-        var originalName = _mcpEditOriginalName;
-        var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server));
-        McpPageStatus.Text = message;
-        await RefreshMcpListAsync();
-        McpPageStatus.Text = message;
-    }
-
-    // ============================================================
-    // Skills page — global (user) skills. All file work lives in SkillCoordinator; this is just
-    // the UI + the fan-out call that makes a change land in every open agent's prompt.
-    // ============================================================
-
-    private string? _editingSkillFolder;
-
-    // Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter).
-    private List _allSkillRows = new();
-
-    private void RefreshSkillsList()
-    {
-        _allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
-        {
-            Name = s.Name,
-            Description = s.Description,
-            Body = s.Body,
-            FolderPath = s.FolderPath,
-            Enabled = s.Enabled,
-        }).ToList();
-
-        ApplySkillFilter();
-    }
-
-    private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) =>
-        ApplySkillFilter();
-
-    private string _skillFilter = "all";
-
-    private void SkillFilter_Click(object sender, RoutedEventArgs e)
-    {
-        _skillFilter = (string)((FrameworkElement)sender).Tag;
-        // Single-select: light the chosen chip, clear the rest.
-        SkillFilterAll.IsChecked = _skillFilter == "all";
-        SkillFilterEnabled.IsChecked = _skillFilter == "enabled";
-        SkillFilterDisabled.IsChecked = _skillFilter == "disabled";
-        SkillFilterLarge.IsChecked = _skillFilter == "large";
-        ApplySkillFilter();
-    }
-
-    /// Applies the search text + active chip, then groups the result into Enabled/Disabled
-    /// sections. Runs on every refresh and keystroke, so filters survive enable/install/delete.
-    private void ApplySkillFilter()
-    {
-        var q = SkillSearchBox.Text?.Trim() ?? "";
-        IEnumerable filtered = _allSkillRows;
-        if (!string.IsNullOrEmpty(q))
-            filtered = filtered.Where(r =>
-                r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
-                r.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
-        filtered = _skillFilter switch
-        {
-            "enabled" => filtered.Where(r => r.Enabled),
-            "disabled" => filtered.Where(r => !r.Enabled),
-            "large" => filtered.Where(r => r.IsLarge),
-            _ => filtered,
-        };
-        var shown = filtered.ToList();
-
-        // Group by state — Enabled first, Disabled below; empty sections omitted.
-        var groups = new List();
-        var en = shown.Where(r => r.Enabled).ToList();
-        var dis = shown.Where(r => !r.Enabled).ToList();
-        if (en.Count > 0) groups.Add(new SkillRowGroup($"Enabled ({en.Count})", en));
-        if (dis.Count > 0) groups.Add(new SkillRowGroup($"Disabled ({dis.Count})", dis));
-
-        var cvs = new Microsoft.UI.Xaml.Data.CollectionViewSource { IsSourceGrouped = true, Source = groups };
-        SkillsList.ItemsSource = cvs.View;
-
-        // Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it.
-        SkillEditButton.IsEnabled = false;
-        SkillDeleteButton.IsEnabled = false;
-
-        var total = _allSkillRows.Count;
-        var enabledTotal = _allSkillRows.Count(r => r.Enabled);
-        var active = q.Length > 0 || _skillFilter != "all";
-        if (total == 0)
-            SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one.  ({_skillCoordinator.UserSkillsDirectory})";
-        else if (active)
-            SkillsPageStatus.Text = $"{shown.Count} of {total} shown  ·  {enabledTotal} enabled";
-        else
-            SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled  ·  {_skillCoordinator.UserSkillsDirectory}";
-    }
-
-    /// Reload every agent's skill set + prompt, then re-render the list and report.
-    private async Task ApplySkillChangeAsync(string status)
-    {
-        await _skillCoordinator.ReloadAllAsync();
-        RefreshSkillsList();
-        SkillsPageStatus.Text = status;
-    }
-
-    private void SkillRefresh_Click(object sender, RoutedEventArgs e) => RefreshSkillsList();
-
-    private void SkillsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        var has = SkillsList.SelectedItem is SkillRow;
-        SkillEditButton.IsEnabled = has;
-        SkillDeleteButton.IsEnabled = has;
-    }
-
-    private void SkillsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
-    {
-        if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
-    }
-
-    private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
-    {
-        // Toggled also fires while the list realizes rows and binds IsOn from the row. In that case
-        // the new state equals the row's stored state — a no-op we must ignore, or realizing the
-        // list would rewrite files. A real user flip makes the two differ.
-        if (sender is not ToggleSwitch sw || sw.DataContext is not SkillRow row) return;
-        if (sw.IsOn == row.Enabled) return;
-
-        try
-        {
-            _skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
-            await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
-        }
-        catch (Exception ex)
-        {
-            SkillsPageStatus.Text = ex.Message;
-        }
-    }
-
-    private void SkillNew_Click(object sender, RoutedEventArgs e) => OpenSkillEditor(null);
-
-    private void SkillEdit_Click(object sender, RoutedEventArgs e)
-    {
-        if (SkillsList.SelectedItem is SkillRow row) OpenSkillEditor(row);
-    }
-
-    private async void SkillDelete_Click(object sender, RoutedEventArgs e)
-    {
-        if (SkillsList.SelectedItem is not SkillRow row) return;
-
-        var dialog = new ContentDialog
-        {
-            Title = "Delete skill",
-            Content = $"Delete “{row.Name}”? This removes its folder from disk and can't be undone.",
-            PrimaryButtonText = "Delete",
-            CloseButtonText = "Cancel",
-            DefaultButton = ContentDialogButton.Close,
-            XamlRoot = Content.XamlRoot,
-        };
-        if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
-
-        try
-        {
-            _skillCoordinator.DeleteSkill(row.FolderPath);
-            await ApplySkillChangeAsync($"Deleted “{row.Name}”.");
-        }
-        catch (Exception ex)
-        {
-            SkillsPageStatus.Text = ex.Message;
-        }
-    }
-
-    private void SkillOpenFolder_Click(object sender, RoutedEventArgs e)
-    {
-        var dir = _skillCoordinator.UserSkillsDirectory;
-        try
-        {
-            System.IO.Directory.CreateDirectory(dir);
-            Process.Start(new ProcessStartInfo { FileName = dir, UseShellExecute = true });
-        }
-        catch (Exception ex)
-        {
-            SkillsPageStatus.Text = ex.Message;
-        }
-    }
-
-    // ---- Skill editor modal ----
-
-    private void OpenSkillEditor(SkillRow? row)
-    {
-        Sk_StatusBar.IsOpen = false;
-        if (row == null)
-        {
-            _editingSkillFolder = null;
-            SkillEditorTitle.Text = "New skill";
-            Sk_Name.Text = "";
-            Sk_Description.Text = "";
-            Sk_Body.Text = "";
-        }
-        else
-        {
-            _editingSkillFolder = row.FolderPath;
-            SkillEditorTitle.Text = "Edit skill";
-            Sk_Name.Text = row.Name;
-            Sk_Description.Text = row.Description;
-            Sk_Body.Text = row.Body;
-        }
-
-        // Reset the AI panel and default its model to the active agent's (still changeable).
-        Sk_AiIntent.Text = "";
-        SetSkillAiBusy(false, "");
-        _ = LoadSkillAuthorModelsAsync(_sessions.Active?.Controller.ModelName ?? "");
-
-        UpdateSkillBodySize();   // explicit: setting Text="" above won't fire TextChanged if already empty
-        SkillEditorOverlay.Visibility = Visibility.Visible;
-        Sk_Name.Focus(FocusState.Programmatic);
-    }
-
-    private void Sk_Body_TextChanged(object sender, TextChangedEventArgs e) => UpdateSkillBodySize();
-
-    /// Live size readout for the instructions body — approximate tokens, gold when large,
-    /// matching the size column in the skills list.
-    private void UpdateSkillBodySize()
-    {
-        var chars = Sk_Body.Text?.Length ?? 0;
-        var tokens = (chars + 3) / 4;
-        var large = tokens >= 2000;
-        Sk_BodySize.Text = (tokens >= 1000 ? $"≈{tokens / 1000.0:0.0}k tokens" : $"≈{tokens} tokens")
-            + (large ? " · large — heavy on local models" : "");
-        Sk_BodySize.Foreground = new SolidColorBrush(
-            ThemeManager.C(large ? ThemeManager.Current.Gold : ThemeManager.Current.Dim));
-    }
-
-    /// Fills the AI model dropdown: the active agent's model shown selected instantly, then
-    /// the full installed-model list streamed in behind it. Mirrors the snapshot picker.
-    private async Task LoadSkillAuthorModelsAsync(string activeModel)
-    {
-        if (string.IsNullOrWhiteSpace(activeModel))
-        {
-            Sk_AiModel.ItemsSource = null;
-            return;
-        }
-
-        var current = new ModelChoice(activeModel, MandoCodeConfig.IsCloudModel(activeModel));
-        Sk_AiModel.ItemsSource = new List { current };
-        Sk_AiModel.SelectedIndex = 0;
-
-        var result = await _controller.LoadAvailableModelsAsync();
-        if (!result.Ok || result.Models.Count == 0) return;
-
-        // If the user already picked another model while the list loaded, don't clobber it.
-        if ((Sk_AiModel.SelectedItem as ModelChoice)?.Name != activeModel) return;
-
-        var choices = result.Models
-            .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
-            .ToList();
-        if (!choices.Any(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase)))
-            choices.Insert(0, current);
-
-        Sk_AiModel.ItemsSource = choices;
-        Sk_AiModel.SelectedItem =
-            choices.First(c => string.Equals(c.Name, activeModel, StringComparison.OrdinalIgnoreCase));
-    }
-
-    private void SetSkillAiBusy(bool busy, string status)
-    {
-        Sk_AiSpin.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
-        Sk_AiSpin.IsActive = busy;
-        Sk_GenerateButton.IsEnabled = !busy;
-        Sk_RefineButton.IsEnabled = !busy;
-        Sk_AiStatus.Text = status;
-    }
-
-    private async void SkillGenerate_Click(object sender, RoutedEventArgs e)
-    {
-        var intent = Sk_AiIntent.Text.Trim();
-        if (intent.Length == 0) { Sk_AiStatus.Text = "Describe what the skill should do first."; return; }
-        if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
-
-        Sk_StatusBar.IsOpen = false;
-        SetSkillAiBusy(true, "Drafting…");
-        try
-        {
-            var endpoint = _sessions.Active!.Config.OllamaEndpoint;
-            var draft = await SkillAuthor.GenerateAsync(endpoint, model.Name, intent);
-            if (!string.IsNullOrWhiteSpace(draft.Name)) Sk_Name.Text = draft.Name;
-            if (!string.IsNullOrWhiteSpace(draft.Description)) Sk_Description.Text = draft.Description;
-            if (!string.IsNullOrWhiteSpace(draft.Body)) Sk_Body.Text = draft.Body;
-            SetSkillAiBusy(false, "Draft ready — review and edit before saving.");
-        }
-        catch (Exception ex)
-        {
-            SetSkillAiBusy(false, "");
-            ShowSkillEditorError($"AI draft failed: {ex.Message}");
-        }
-    }
-
-    private async void SkillRefine_Click(object sender, RoutedEventArgs e)
-    {
-        var instruction = Sk_AiIntent.Text.Trim();
-        if (instruction.Length == 0) { Sk_AiStatus.Text = "Type what to change in the box above."; return; }
-        if (Sk_Body.Text.Trim().Length == 0) { Sk_AiStatus.Text = "Nothing to refine yet — write or generate instructions first."; return; }
-        if (Sk_AiModel.SelectedItem is not ModelChoice model) { Sk_AiStatus.Text = "Pick a model first."; return; }
-
-        Sk_StatusBar.IsOpen = false;
-        SetSkillAiBusy(true, "Refining…");
-        try
-        {
-            var endpoint = _sessions.Active!.Config.OllamaEndpoint;
-            var body = await SkillAuthor.RefineAsync(endpoint, model.Name, Sk_Body.Text, instruction);
-            if (!string.IsNullOrWhiteSpace(body)) Sk_Body.Text = body;
-            SetSkillAiBusy(false, "Instructions updated.");
-        }
-        catch (Exception ex)
-        {
-            SetSkillAiBusy(false, "");
-            ShowSkillEditorError($"AI refine failed: {ex.Message}");
-        }
-    }
-
-    private void SkillEditorCancel_Click(object sender, RoutedEventArgs e) =>
-        SkillEditorOverlay.Visibility = Visibility.Collapsed;
-
-    private void ShowSkillEditorError(string message)
-    {
-        Sk_StatusBar.Title = "Check the form";
-        Sk_StatusBar.Message = message;
-        Sk_StatusBar.IsOpen = true;
-    }
-
-    private async void SkillEditorSave_Click(object sender, RoutedEventArgs e)
-    {
-        var name = Sk_Name.Text.Trim();
-        if (name.Length == 0) { ShowSkillEditorError("Give the skill a name."); return; }
-        if (Sk_Body.Text.Trim().Length == 0) { ShowSkillEditorError("The instructions can't be empty."); return; }
-
-        try
-        {
-            _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
-            SkillEditorOverlay.Visibility = Visibility.Collapsed;
-            await ApplySkillChangeAsync($"Saved “{name}”.");
-        }
-        catch (Exception ex)
-        {
-            ShowSkillEditorError(ex.Message);
-        }
-    }
-
-    // ---- Skill install modal ----
-
-    private void SkillInstall_Click(object sender, RoutedEventArgs e)
-    {
-        Sk_InstallMode.SelectedIndex = 0;   // Git by default
-        Sk_InstallGitPanel.Visibility = Visibility.Visible;
-        Sk_InstallLocalPanel.Visibility = Visibility.Collapsed;
-        Sk_InstallGitUrl.Text = "";
-        Sk_InstallLocalPath.Text = "";
-        Sk_InstallStatus.Text = "";
-        Sk_InstallError.IsOpen = false;
-        Sk_InstallSpin.IsActive = false;
-        Sk_InstallSpin.Visibility = Visibility.Collapsed;
-        SkillInstallConfirmButton.IsEnabled = true;
-        SkillInstallOverlay.Visibility = Visibility.Visible;
-        Sk_InstallGitUrl.Focus(FocusState.Programmatic);
-    }
-
-    private void SkillInstallMode_Changed(object sender, SelectionChangedEventArgs e)
-    {
-        // Fires during InitializeComponent before the panels exist.
-        if (Sk_InstallGitPanel == null || Sk_InstallLocalPanel == null) return;
-        var local = Sk_InstallMode.SelectedIndex == 1;
-        Sk_InstallGitPanel.Visibility = local ? Visibility.Collapsed : Visibility.Visible;
-        Sk_InstallLocalPanel.Visibility = local ? Visibility.Visible : Visibility.Collapsed;
-    }
-
-    private async void SkillBrowseFolder_Click(object sender, RoutedEventArgs e)
-    {
-        var picker = new Windows.Storage.Pickers.FolderPicker();
-        picker.FileTypeFilter.Add("*");
-        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
-        var folder = await picker.PickSingleFolderAsync();
-        if (folder != null) Sk_InstallLocalPath.Text = folder.Path;
-    }
-
-    private async void SkillBrowseZip_Click(object sender, RoutedEventArgs e)
-    {
-        var picker = new Windows.Storage.Pickers.FileOpenPicker();
-        picker.FileTypeFilter.Add(".zip");
-        WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
-        var file = await picker.PickSingleFileAsync();
-        if (file != null) Sk_InstallLocalPath.Text = file.Path;
-    }
-
-    private void SkillInstallCancel_Click(object sender, RoutedEventArgs e) =>
-        SkillInstallOverlay.Visibility = Visibility.Collapsed;
-
-    private async void SkillInstallConfirm_Click(object sender, RoutedEventArgs e)
-    {
-        var source = (Sk_InstallMode.SelectedIndex == 1 ? Sk_InstallLocalPath.Text : Sk_InstallGitUrl.Text).Trim();
-        if (source.Length == 0)
-        {
-            Sk_InstallError.Title = "Nothing to install";
-            Sk_InstallError.Message = "Enter a git URL, a .zip path, or a folder path.";
-            Sk_InstallError.IsOpen = true;
-            return;
-        }
-
-        Sk_InstallError.IsOpen = false;
-        Sk_InstallSpin.Visibility = Visibility.Visible;
-        Sk_InstallSpin.IsActive = true;
-        Sk_InstallStatus.Text = "Fetching…";
-        SkillInstallConfirmButton.IsEnabled = false;
-
-        try
-        {
-            // Clone / extract / copy can block; keep it off the UI thread.
-            var result = await Task.Run(() => _skillCoordinator.InstallFrom(source));
-
-            // Nothing found: keep the modal open so the user can fix the source, and say what a
-            // valid source looks like. (finally still resets the spinner/button below.)
-            if (result.Installed.Count == 0 && result.Skipped.Count == 0)
-            {
-                Sk_InstallError.Title = "No skills found";
-                Sk_InstallError.Message = "That source has no SKILL.md. A skill is a folder containing a SKILL.md file — point at one, or at a folder/repo/.zip that holds them (nested is fine).";
-                Sk_InstallError.IsOpen = true;
-                return;
-            }
-
-            SkillInstallOverlay.Visibility = Visibility.Collapsed;
-            await _skillCoordinator.ReloadAllAsync();
-            RefreshSkillsList();
-
-            var parts = new List();
-            if (result.Installed.Count > 0) parts.Add($"installed {string.Join(", ", result.Installed)}");
-            if (result.Skipped.Count > 0) parts.Add($"skipped (already present): {string.Join(", ", result.Skipped)}");
-            SkillsPageStatus.Text = string.Join("  ·  ", parts);
-        }
-        catch (Exception ex)
-        {
-            Sk_InstallError.Title = "Install failed";
-            Sk_InstallError.Message = ex.Message;
-            Sk_InstallError.IsOpen = true;
-        }
-        finally
-        {
-            Sk_InstallSpin.IsActive = false;
-            Sk_InstallSpin.Visibility = Visibility.Collapsed;
-            Sk_InstallStatus.Text = "";
-            SkillInstallConfirmButton.IsEnabled = true;
-        }
-    }
-
 }
diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
index 642bad6..5803b20 100644
--- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj
+++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj
@@ -68,7 +68,7 @@
     
     
+           Text="ChatController must not call _config.Save() — it holds a per-tab config clone, and Save() writes the shared config file. Mutate ConfigCoordinator.Defaults and call SaveDefaults() (or SaveDefaultsFrom(agentConfig)) instead." />
   
 
 
diff --git a/src/MandoCode.Desktop/Services/AgentNaming.cs b/src/MandoCode.Desktop/Services/AgentNaming.cs
new file mode 100644
index 0000000..2318097
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/AgentNaming.cs
@@ -0,0 +1,21 @@
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Default agent labels — "Agent 1", "Agent 2", … — reusing the lowest free number so closing
+/// "Agent 2" then opening a new tab gives "Agent 2" again rather than an ever-climbing count.
+/// Pure so it can be tested without a live  (which needs the whole
+/// app object graph). User-renamed titles are simply names that happen to be taken.
+/// 
+public static class AgentNaming
+{
+    public static string NextFreeName(IEnumerable existingTitles)
+    {
+        var taken = new HashSet(
+            existingTitles.Where(t => !string.IsNullOrEmpty(t))!, StringComparer.Ordinal);
+        for (var n = 1; ; n++)
+        {
+            var candidate = $"Agent {n}";
+            if (!taken.Contains(candidate)) return candidate;
+        }
+    }
+}
diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs
index e8848d4..afa2f55 100644
--- a/src/MandoCode.Desktop/Services/AgentSession.cs
+++ b/src/MandoCode.Desktop/Services/AgentSession.cs
@@ -113,7 +113,7 @@ public AgentSession(
         Shell = new ShellRunner(ProjectRoot, Transcript, html);
 
         Controller = new ChatController(
-            Ai, Config, Tokens, PlanHandoff, Planner,
+            new AiServiceAdapter(Ai), Config, Tokens, PlanHandoff, Planner,
             mcpManager, McpGate, Skills, FileProvider, ProjectRoot,
             music, updateCheck, Approvals, Transcript, html, Busy, Shell, PromptGate,
             configs, mcp, Snapshots);
diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
new file mode 100644
index 0000000..a8474e8
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs
@@ -0,0 +1,62 @@
+using MandoCode.Models;
+using MandoCode.Services;
+using Microsoft.SemanticKernel;
+
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Forwards  straight through to a harness . This is
+/// the ONLY place in the Desktop app that names the concrete AIService's method surface for the
+/// controller's sake, so a harness API change on the pin roll surfaces here — one file to fix —
+/// rather than scattered across ChatController's request loop and approval wiring.
+///
+/// The wrapped instance stays owned by AgentSession; other consumers (memory restore, MCP/skill
+/// refresh) keep using it directly. Only ChatController receives it through this adapter.
+/// 
+public sealed class AiServiceAdapter : IAiService
+{
+    private readonly AIService _ai;
+
+    public AiServiceAdapter(AIService ai) => _ai = ai;
+
+    public event Action? OnFunctionInvoked
+    {
+        add => _ai.OnFunctionInvoked += value;
+        remove => _ai.OnFunctionInvoked -= value;
+    }
+
+    public event Action? OnFunctionCompleted
+    {
+        add => _ai.OnFunctionCompleted += value;
+        remove => _ai.OnFunctionCompleted -= value;
+    }
+
+    public Func>? OnWriteApprovalRequested
+    {
+        get => _ai.OnWriteApprovalRequested;
+        set => _ai.OnWriteApprovalRequested = value;
+    }
+
+    public Func>? OnDeleteApprovalRequested
+    {
+        get => _ai.OnDeleteApprovalRequested;
+        set => _ai.OnDeleteApprovalRequested = value;
+    }
+
+    public Func>? OnCommandApprovalRequested
+    {
+        get => _ai.OnCommandApprovalRequested;
+        set => _ai.OnCommandApprovalRequested = value;
+    }
+
+    public Task ReinitializeAsync(MandoCodeConfig config) => _ai.ReinitializeAsync(config);
+    public Task RefreshSettingsAsync(MandoCodeConfig config) => _ai.RefreshSettingsAsync(config);
+    public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => _ai.AttachMcpPluginsAsync(cancellationToken);
+    public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => _ai.ValidateModelAsync();
+    public IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default) => _ai.ChatStreamAsync(userMessage, cancellationToken);
+    public string? ExportHistoryJson() => _ai.ExportHistoryJson();
+    public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json);
+    public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync();
+    public Task ClearHistoryAsync() => _ai.ClearHistoryAsync();
+    public Task> GetHistoryAsync() => _ai.GetHistoryAsync();
+}
diff --git a/src/MandoCode.Desktop/Services/ConfigCloning.cs b/src/MandoCode.Desktop/Services/ConfigCloning.cs
new file mode 100644
index 0000000..b7c1127
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ConfigCloning.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using MandoCode.Models;
+
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Pure  cloning — a JSON round-trip followed by a mandatory
+/// . Separated from 
+/// (which is coupled to live AgentSessions) so the round-trip can be unit-tested on its own:
+/// it is the exact case-sensitivity trap the config guardrails warn about.
+/// 
+public static class ConfigCloning
+{
+    // Mirrors the harness's internal ConfigJsonOptions (MandoCodeConfig.cs) — it isn't visible
+    // across the assembly boundary, and the round-trip has to be symmetric with Load()/Save().
+    private static readonly JsonSerializerOptions ReadOptions = new()
+    {
+        PropertyNameCaseInsensitive = true,
+        WriteIndented = true
+    };
+
+    private static readonly JsonSerializerOptions WriteOptions = new()
+    {
+        WriteIndented = true
+    };
+
+    /// A fresh, fully-detached copy of .
+    public static MandoCodeConfig DeepClone(MandoCodeConfig source)
+    {
+        var json = JsonSerializer.Serialize(source, WriteOptions);
+        var clone = JsonSerializer.Deserialize(json, ReadOptions)
+            ?? throw new InvalidOperationException("Failed to clone MandoCodeConfig.");
+
+        // Mandatory, not cosmetic. System.Text.Json builds McpServers with the default
+        // case-SENSITIVE comparer regardless of the property initializer; ValidateAndClamp
+        // rebuilds it as OrdinalIgnoreCase. Skip this and every MCP server lookup in the clone
+        // silently misses on a casing difference.
+        clone.ValidateAndClamp();
+        return clone;
+    }
+}
diff --git a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
index 63ed2c2..2266b1b 100644
--- a/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
+++ b/src/MandoCode.Desktop/Services/ConfigCoordinator.cs
@@ -1,5 +1,4 @@
 using System.Reflection;
-using System.Text.Json;
 using MandoCode.Models;
 
 namespace MandoCode.Desktop.Services;
@@ -29,19 +28,6 @@ namespace MandoCode.Desktop.Services;
 /// 
 public sealed class ConfigCoordinator
 {
-    // Mirrors the harness's internal ConfigJsonOptions (MandoCodeConfig.cs) — it isn't visible
-    // across the assembly boundary, and the round-trip has to be symmetric with Load()/Save().
-    private static readonly JsonSerializerOptions ReadOptions = new()
-    {
-        PropertyNameCaseInsensitive = true,
-        WriteIndented = true
-    };
-
-    private static readonly JsonSerializerOptions WriteOptions = new()
-    {
-        WriteIndented = true
-    };
-
     // Reflected once. Using reflection rather than a hand-written field list means a config key
     // added by the CLI harness is carried by "Make Default" without a change here.
     private static readonly PropertyInfo[] SettableProperties = typeof(MandoCodeConfig)
@@ -61,7 +47,7 @@ public sealed class ConfigCoordinator
     public ConfigCoordinator(MandoCodeConfig defaults) => Defaults = defaults;
 
     /// A fresh, fully-detached copy of the defaults for a new agent.
-    public MandoCodeConfig CreateClone() => DeepClone(Defaults);
+    public MandoCodeConfig CreateClone() => ConfigCloning.DeepClone(Defaults);
 
     /// 
     /// "Make Default for New Agents" — snapshots one agent's settings onto the defaults and
@@ -73,7 +59,7 @@ public void SaveDefaultsFrom(MandoCodeConfig agentConfig)
         {
             // Deep-clone first: reflection assigns reference-typed members straight across, and
             // Defaults must not end up sharing the agent's List/Dictionary instances.
-            var snapshot = DeepClone(agentConfig);
+            var snapshot = ConfigCloning.DeepClone(agentConfig);
             foreach (var property in SettableProperties)
                 property.SetValue(Defaults, property.GetValue(snapshot));
 
@@ -108,22 +94,8 @@ public void SyncMcpServersToAgents()
             {
                 // One fresh clone per agent — a shared dictionary would let one agent's session
                 // approvals mutate another's.
-                session.Config.McpServers = DeepClone(Defaults).McpServers;
+                session.Config.McpServers = ConfigCloning.DeepClone(Defaults).McpServers;
             }
         }
     }
-
-    private static MandoCodeConfig DeepClone(MandoCodeConfig source)
-    {
-        var json = JsonSerializer.Serialize(source, WriteOptions);
-        var clone = JsonSerializer.Deserialize(json, ReadOptions)
-            ?? throw new InvalidOperationException("Failed to clone MandoCodeConfig.");
-
-        // Mandatory, not cosmetic. System.Text.Json builds McpServers with the default
-        // case-SENSITIVE comparer regardless of the property initializer; ValidateAndClamp
-        // rebuilds it as OrdinalIgnoreCase. Skip this and every MCP server lookup in the clone
-        // silently misses on a casing difference.
-        clone.ValidateAndClamp();
-        return clone;
-    }
 }
diff --git a/src/MandoCode.Desktop/Services/ContextSnapshot.cs b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
index 5bf2fde..327c6bc 100644
--- a/src/MandoCode.Desktop/Services/ContextSnapshot.cs
+++ b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
@@ -42,19 +42,10 @@ public sealed class ContextSnapshot
     public string DisplayTitle => string.IsNullOrWhiteSpace(Name) ? OriginModel : Name!;
 
     [System.Text.Json.Serialization.JsonIgnore]
-    public string TimeLabel => CapturedAt.LocalDateTime.ToString("MMM d · h:mm tt");
+    public string TimeLabel => ProjectDisplay.TimeLabel(CapturedAt);
 
     /// Group heading for the panel: the project folder's leaf name, or a stand-in when the
     /// snapshot predates project tracking (older files) or was taken outside any folder.
     [System.Text.Json.Serialization.JsonIgnore]
-    public string ProjectLabel
-    {
-        get
-        {
-            if (string.IsNullOrWhiteSpace(ProjectRoot)) return "Unknown project";
-            var name = System.IO.Path.GetFileName(
-                ProjectRoot.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
-            return string.IsNullOrEmpty(name) ? ProjectRoot! : name;
-        }
-    }
+    public string ProjectLabel => ProjectDisplay.ProjectLabel(ProjectRoot);
 }
diff --git a/src/MandoCode.Desktop/Services/CrashLog.cs b/src/MandoCode.Desktop/Services/CrashLog.cs
new file mode 100644
index 0000000..f1db17e
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/CrashLog.cs
@@ -0,0 +1,30 @@
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Best-effort append to the same crash.log the App-level UnhandledException
+/// handler writes to. Use it for a swallowed exception that is worth a diagnostic breadcrumb
+/// but must never surface to the user or take the app down — an unexpected failure in an
+/// otherwise recoverable path.
+///
+/// Not every empty catch belongs here: paths that throw as part of normal operation
+/// (WebView2 host re-mapping, script execution during a teardown race) should stay silent so
+/// this log carries signal, not noise. Logging is itself best-effort — a failure to write is
+/// discarded rather than masking the original error.
+/// 
+public static class CrashLog
+{
+    private static readonly string LogPath = System.IO.Path.Combine(
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+        "MandoCode.Desktop", "crash.log");
+
+    public static void Write(string context, Exception ex)
+    {
+        try
+        {
+            System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(LogPath)!);
+            System.IO.File.AppendAllText(LogPath,
+                $"[{DateTimeOffset.Now:O}] {context}: {ex.Message}\n{ex}\n\n");
+        }
+        catch { /* logging is best-effort — never mask the original failure */ }
+    }
+}
diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs
new file mode 100644
index 0000000..339d12a
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/IAiService.cs
@@ -0,0 +1,44 @@
+using MandoCode.Models;
+using MandoCode.Services;
+using Microsoft.SemanticKernel;
+
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// The slice of the harness  that 
+/// actually depends on — its streaming loop, approval wiring, function-call events, and history.
+///
+/// Why this exists: AIService is a concrete type in the pinned, read-only harness
+/// submodule, so we can't put an interface on it directly. Depending on this abstraction instead
+/// (via ) does two things:
+/// 
+///   absorbs harness API drift in one place — when the pin rolls forward and a signature
+///   moves, the adapter breaks, not the controller's guts (the approval-wiring seam the README
+///   flags as highest-risk); and
+///   lets the request loop be driven by a fake in tests, without a live Ollama.
+/// 
+///
+/// The three approval callbacks are single-assignment Func properties (not events): they are
+/// set to this tab's handlers and nulled when diff approvals are off. That's safe only because each
+/// agent owns its own AIService — see AgentSession.
+/// 
+public interface IAiService
+{
+    event Action? OnFunctionInvoked;
+    event Action? OnFunctionCompleted;
+
+    Func>? OnWriteApprovalRequested { get; set; }
+    Func>? OnDeleteApprovalRequested { get; set; }
+    Func>? OnCommandApprovalRequested { get; set; }
+
+    Task ReinitializeAsync(MandoCodeConfig config);
+    Task RefreshSettingsAsync(MandoCodeConfig config);
+    Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default);
+    Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync();
+    IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default);
+    string? ExportHistoryJson();
+    int TryRestoreHistoryJson(string json);
+    Task EnterLearnModeAsync();
+    Task ClearHistoryAsync();
+    Task> GetHistoryAsync();
+}
diff --git a/src/MandoCode.Desktop/Services/ITranscriptHtml.cs b/src/MandoCode.Desktop/Services/ITranscriptHtml.cs
new file mode 100644
index 0000000..d4cd4a1
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ITranscriptHtml.cs
@@ -0,0 +1,16 @@
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// The transcript-fragment methods the request loop emits. 
+/// implements it; the streaming loop () depends on this
+/// slice rather than the concrete builder, whose BaseDocument reaches into WinUI-only
+/// ThemeManager — so the loop (and its tests) stay free of the Windows App SDK.
+/// 
+public interface ITranscriptHtml
+{
+    string AssistantCard(string markdown);
+    string Warn(string text);
+    string Error(string text);
+    string Dim(string text);
+    string TokenSummary(string text);
+}
diff --git a/src/MandoCode.Desktop/Services/ProjectDisplay.cs b/src/MandoCode.Desktop/Services/ProjectDisplay.cs
new file mode 100644
index 0000000..1aca9c7
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ProjectDisplay.cs
@@ -0,0 +1,22 @@
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Shared display formatting for the Snapshots and History panels. The project-folder leaf name
+/// and the "MMM d · h:mm tt" timestamp were duplicated verbatim on 
+/// and ; one home keeps the two panels visually identical.
+/// 
+public static class ProjectDisplay
+{
+    /// The project folder's leaf name, or a stand-in when the root is unknown/blank
+    /// (an older file that predates project tracking, or a conversation held outside any folder).
+    public static string ProjectLabel(string? projectRoot)
+    {
+        if (string.IsNullOrWhiteSpace(projectRoot)) return "Unknown project";
+        var name = System.IO.Path.GetFileName(
+            projectRoot.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
+        return string.IsNullOrEmpty(name) ? projectRoot! : name;
+    }
+
+    /// Local "MMM d · h:mm tt" label for a captured/closed timestamp.
+    public static string TimeLabel(DateTimeOffset when) => when.LocalDateTime.ToString("MMM d · h:mm tt");
+}
diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
index aa315e1..ff299f0 100644
--- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
+++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
@@ -31,19 +31,10 @@ public sealed class SessionArchiveEntry
     // ---- display helpers for the panel ----
 
     [System.Text.Json.Serialization.JsonIgnore]
-    public string TimeLabel => ClosedAt.LocalDateTime.ToString("MMM d · h:mm tt");
+    public string TimeLabel => ProjectDisplay.TimeLabel(ClosedAt);
 
     [System.Text.Json.Serialization.JsonIgnore]
-    public string ProjectLabel
-    {
-        get
-        {
-            if (string.IsNullOrWhiteSpace(ProjectRoot)) return "Unknown project";
-            var name = Path.GetFileName(
-                ProjectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
-            return string.IsNullOrEmpty(name) ? ProjectRoot : name;
-        }
-    }
+    public string ProjectLabel => ProjectDisplay.ProjectLabel(ProjectRoot);
 
     /// Card body: the first user message, or an honest stand-in when there wasn't one.
     [System.Text.Json.Serialization.JsonIgnore]
diff --git a/src/MandoCode.Desktop/Services/SessionManager.cs b/src/MandoCode.Desktop/Services/SessionManager.cs
index 5f63dca..a85fd97 100644
--- a/src/MandoCode.Desktop/Services/SessionManager.cs
+++ b/src/MandoCode.Desktop/Services/SessionManager.cs
@@ -63,14 +63,7 @@ public AgentSession CreateSession(string? projectRoot = null, string? persistKey
     /// label just distinguishes agents; the user can rename it. Reuses the lowest free number so
     /// closing "Agent 2" then opening a new one gives "Agent 2" again, not an ever-climbing count.
     /// 
-    private string NextAgentName()
-    {
-        for (var n = 1; ; n++)
-        {
-            var candidate = $"Agent {n}";
-            if (_sessions.All(s => s.Title != candidate)) return candidate;
-        }
-    }
+    private string NextAgentName() => AgentNaming.NextFreeName(_sessions.Select(s => s.Title));
 
     public void Activate(AgentSession session)
     {
diff --git a/src/MandoCode.Desktop/Services/ShellOpen.cs b/src/MandoCode.Desktop/Services/ShellOpen.cs
new file mode 100644
index 0000000..8b57a5b
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ShellOpen.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics;
+
+namespace MandoCode.Desktop.Services;
+
+/// 
+/// Opens a file, folder, or URL with the OS default handler (ShellExecute). Centralizes the
+///  dance that was repeated at every "open this in Explorer / the
+/// browser / its default app" call site. Returns the launch exception (null on success) so each
+/// caller can surface its own message; a dead link or missing handler never crashes the app.
+/// 
+public static class ShellOpen
+{
+    public static Exception? Try(string target)
+    {
+        try
+        {
+            Process.Start(new ProcessStartInfo { FileName = target, UseShellExecute = true });
+            return null;
+        }
+        catch (Exception ex)
+        {
+            return ex;
+        }
+    }
+}
diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
index b3e0e77..907832e 100644
--- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
+++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs
@@ -10,7 +10,7 @@ namespace MandoCode.Desktop.Services;
 /// of the CLI's MarkdownHtmlRenderer + OperationDisplayRenderer + diff panels, using
 /// the same underlying models (Markdig markdown, OperationDisplayEvent, DiffLine).
 /// 
-public sealed class TranscriptHtmlBuilder
+public sealed class TranscriptHtmlBuilder : ITranscriptHtml
 {
     private readonly MandoCodeConfig _config;
 
@@ -278,9 +278,20 @@ public string HelpCard(IEnumerable<(string Command, string Description)> rows)
         return sb.ToString();
     }
 
-    /// The transcript host page: styles + the append/clear JS the window calls.
-    /// Colors come from the active UiTheme; ThemeManager.BuildTranscriptScript re-points
-    /// the same CSS variables when the theme changes at runtime.
+    // The bulk of the transcript host page is static CSS and JS. It lives in
+    // Assets/web/transcript/ (shipped by the Assets\web\** content glob), read once and injected
+    // inline by BaseDocument below — so the page still renders in a single NavigateToString with no
+    // extra fetch and no flash of unstyled content. Only the theme-dependent  flags and
+    // :root variables remain in C#.
+    private static readonly Lazy TranscriptCss = new(() => ReadWebAsset("transcript.css"));
+    private static readonly Lazy TranscriptJs = new(() => ReadWebAsset("transcript.js"));
+
+    private static string ReadWebAsset(string fileName) => File.ReadAllText(
+        Path.Combine(AppContext.BaseDirectory, "Assets", "web", "transcript", fileName));
+
+    /// The transcript host page: the theme-dependent header and :root vars inline, with the
+    /// bulk static CSS and JS injected from Assets/web/transcript/. Colors come from the active
+    /// UiTheme; ThemeManager.BuildTranscriptScript re-points the same CSS variables at runtime.
     public static string BaseDocument(UiTheme theme) => $$"""
 
 
@@ -303,458 +314,7 @@ public static string BaseDocument(UiTheme theme) => $$"""
     --chat-bg-image: {{ThemeManager.ChatBackgroundCssValue()}};
     --chat-bg-opacity: {{ThemeManager.ChatBackgroundOpacityCss()}};
   }
-  * { box-sizing: border-box; }
-  body {
-    background: var(--bg); color: var(--fg);
-    font-family: "Segoe UI", sans-serif; font-size: 14px;
-    margin: 0; padding: 14px 18px 24px 18px; line-height: 1.5;
-  }
-  /* User-chosen chat background: a fixed full-bleed layer painted behind the log.
-     Only THIS layer fades with the appearance slider — text keeps full contrast,
-     and panels/code blocks keep their opaque theme backgrounds on top of it. */
-  #bg { position: fixed; inset: 0; z-index: -1; pointer-events: none;
-    background-image: var(--chat-bg-image); background-size: cover;
-    background-position: center; background-repeat: no-repeat;
-    opacity: var(--chat-bg-opacity); }
-  #log > * { margin-bottom: 8px; animation: rise 0.18s ease-out; }
-  @keyframes rise {
-    from { opacity: 0; transform: translateY(4px); }
-    to { opacity: 1; transform: none; }
-  }
-  /* E-ink / flat-motion themes: no fade-in, no hover transitions, no smooth scroll — the
-     transcript repaints instantly and stays still, the way an e-reader page does. The
-     attribute is set at build time and toggled live by ThemeManager.BuildTranscriptScript. */
-  html[data-flat] #log > * { animation: none; }
-  html[data-flat] *, html[data-flat] { transition: none !important; scroll-behavior: auto !important; }
-  /* E-ink background image: treat the (static) chat-background layer like a Kindle image —
-     grayscale + contrast + 1-bit Bayer ordered dithering into black/white halftone dots.
-     Applied ONLY to #bg (never the text) and ONLY under the flat/e-ink theme. The layer is
-     fixed and repaints once, so even this heavy filter costs nothing per frame. */
-  html[data-flat] #bg { filter: url(#eink); }
-  /* Color emoji is the loudest break in the paper illusion, so desaturate every emoji-bearing
-     surface to grayscale ink: the chrome (react ghost, reaction pills, picker) AND the inline
-     emoji in message text (.md) and user echoes. Scoped to the flat/e-ink theme only. Safe and
-     static — assistant turns are appended as COMPLETE blocks (ChatController flushes each turn
-     via AssistantCard; no token-by-token DOM streaming), so each subtree is filtered once on
-     append and never re-rasterized by later appends. Under e-ink every other glyph is already
-     ink-gray, so the only visible effect is draining the color out of emoji. */
-  html[data-flat] .react-ghost,
-  html[data-flat] .rx-pill,
-  html[data-flat] #rx-pop .rx,
-  html[data-flat] .md,
-  html[data-flat] .user-echo { filter: grayscale(1); }
-
-  /* ---- CRT picture-tube overlay (aperture-grille tube) ----------------------------------
-     Scoped to html[data-crt]. Drawn on two fixed, pointer-events:none pseudo-layers OVER the
-     transcript, so the "glass" sits in front of the text. EVERYTHING here is STATIC — no moving
-     scanline, no flicker (that is the continuous-repaint trap we keep avoiding); the tube look
-     is fixed gradients only, one paint. The set's native chrome outside the WebView is untouched,
-     exactly like a real TV where only the picture tube carries scanlines. */
-  html[data-crt] body {
-    /* phosphor bloom on every glyph — a tight bright core + a wider soft halo reads more
-       like real phosphor than one big blur (and keeps text legible). Static, so no per-frame
-       cost even though it rides the streaming-text repaint. */
-    text-shadow: 0 0 2px rgba(120, 210, 255, 0.55), 0 0 9px rgba(120, 210, 255, 0.42),
-                 0 0 18px rgba(120, 210, 255, 0.22);
-  }
-  html[data-crt] body::before {
-    content: ""; position: fixed; inset: 0; z-index: 9998; pointer-events: none;
-    background:
-      /* horizontal scanlines (4px period: 2px gap + 2px line) */
-      repeating-linear-gradient(to bottom,
-        rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
-        rgba(0,0,0,0.22) 2px, rgba(0,0,0,0.22) 4px),
-      /* aperture grille — faint vertical RGB stripes (the aperture-grille tell, not a dot mask) */
-      repeating-linear-gradient(to right,
-        rgba(255,0,64,0.05) 0, rgba(0,255,128,0.05) 1px,
-        rgba(64,128,255,0.05) 2px, rgba(0,0,0,0) 3px);
-  }
-  html[data-crt] body::after {
-    content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
-    background:
-      /* the two signature aperture-grille damper wires */
-      linear-gradient(to bottom,
-        transparent calc(33.3% - 1px), rgba(0,0,0,0.30) 33.3%, transparent calc(33.3% + 1px)),
-      linear-gradient(to bottom,
-        transparent calc(66.6% - 1px), rgba(0,0,0,0.30) 66.6%, transparent calc(66.6% + 1px)),
-      /* tube-edge vignette */
-      radial-gradient(ellipse 100% 100% at center, transparent 60%, rgba(0,0,0,0.55) 100%);
-  }
-  /* ---- Boxed messages (Appearance toggle, theme-agnostic) ---------------------------
-     Each prompt/response on its own card surface: hard message boundaries and skimmable
-     rhythm for long sessions, versus the default flat terminal look. Only theme variables,
-     so every palette works. Excluded under W98 — its bevelled message windows are bespoke. */
-  /* Frosted glass: cards are slightly translucent with a backdrop blur, so a chat
-     background image glows through without ever fighting the text (the blur is what
-     preserves contrast over busy wallpapers). Over a plain theme background the effect
-     degrades to near-solid — no image, no cost to readability. Blur is static compositing,
-     not per-frame work. */
-  html[data-cards]:not([data-win98]) .user-echo {
-    background: color-mix(in srgb, var(--panel) 82%, transparent);
-    backdrop-filter: blur(6px);
-    border: 1px solid var(--border); border-radius: 10px;
-    padding: 8px 12px; }
-  html[data-cards]:not([data-win98]) .assistant {
-    background: color-mix(in srgb, var(--panel) 82%, transparent);
-    backdrop-filter: blur(6px);
-    border: 1px solid var(--border); border-radius: 10px;
-    padding: 6px 12px 8px 12px; }
-  /* Cards sit on the panel color, so code wells inside switch to the bg color to stay
-     visually recessed (they normally use --panel against a --bg page). */
-  html[data-cards]:not([data-win98]) .md pre,
-  html[data-cards]:not([data-win98]) .md code { background: var(--bg); }
-
-  /* ---- Windows 98 chrome -----------------------------------------------------------
-     Scoped to html[data-win98]. The 3D language of 1998: silver surfaces, square corners,
-     two-tone bevels lit from the top-left (raised = chrome you can press, sunken = wells
-     that hold content), navy title-bar gradients, Tahoma, and none of the decoration the
-     era didn't have (radii, soft shadows). Colors come from the theme's CSS variables;
-     this block only reshapes geometry, bevels, and the title bars. All static — pairs
-     with the theme's FlatMotion, because nothing animated in 1998. */
-  html[data-win98] body { font-family: Tahoma, "MS Sans Serif", "Segoe UI", sans-serif;
-    /* THE desktop teal. Silver never filled a screen in 1998 — it sat in windows on this. */
-    background: #008080; padding: 12px 14px 20px 14px; }
-  /* Each MESSAGE is its own window on the desktop (not one giant expanding one): user
-     prompts are small silver windows; assistant responses are windows whose "MandoCode"
-     label becomes the navy title bar — the hover copy/react chips land on it like window
-     buttons. Status lines and tool ops sit directly on the teal like desktop icon labels,
-     with brightened colors (the theme's dark semantic hues are unreadable on teal).
-     (A user-chosen chat background image still paints over the teal via #bg — wallpaper.) */
-  html[data-win98] .user-echo { background: var(--bg); padding: 7px 12px;
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
-  html[data-win98] .assistant { background: var(--bg);
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
-  html[data-win98] .assistant-label {
-    background: linear-gradient(90deg, #000080, #1084D0); color: #FFFFFF;
-    padding: 3px 10px; margin-bottom: 0; font-weight: 700; }
-  html[data-win98] .assistant .md { padding: 2px 12px 8px 12px; }
-  html[data-win98] .line { color: #EAF6F4; }
-  html[data-win98] .line.info { color: #A8D8FF; }
-  html[data-win98] .line.success { color: #90EE90; }
-  html[data-win98] .line.warn { color: #FFE082; }
-  html[data-win98] .line.error { color: #FF9E8F; }
-  html[data-win98] .line.dim, html[data-win98] .op-meta, html[data-win98] .token-summary { color: #B8D8D4; }
-  html[data-win98] .op { color: #EAF6F4; }
-  html[data-win98] .op-path { color: #EAF6F4; }
-  html[data-win98] .op-head a.file-link { color: #AAD4FF; border-bottom-color: #AAD4FF; }
-  /* Op-head semantic colors (WebSearch/WebFetch/Write/Delete glyph classes) are theme-dark
-     hues built for silver — brighten them on the teal, same mapping as the .line variants. */
-  html[data-win98] .op-head.success { color: #90EE90; }
-  html[data-win98] .op-head.error, html[data-win98] .op-head.red { color: #FF9E8F; }
-  html[data-win98] .op-head.warn { color: #FFE082; }
-  html[data-win98] .op-head.info, html[data-win98] .op-head.sky { color: #A8D8FF; }
-  html[data-win98] .op-head.dim { color: #B8D8D4; }
-  /* Square EVERYTHING. */
-  html[data-win98] .panel, html[data-win98] .chip, html[data-win98] .tool-pill,
-  html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
-  html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
-  html[data-win98] .md pre, html[data-win98] .md code, html[data-win98] pre.mono-block,
-  html[data-win98] pre.raw, html[data-win98] .op-detail, html[data-win98] #rx-pop,
-  html[data-win98] .rx-pill, html[data-win98] #rx-pop .rx { border-radius: 0 !important; }
-  /* Raised bevel: anything button-like is a silver 3D control. */
-  html[data-win98] .copy-chip, html[data-win98] .react-ghost, html[data-win98] .expand-btn,
-  html[data-win98] .web-toggle, html[data-win98] .dv-btn, html[data-win98] .ue-toggle,
-  html[data-win98] .tool-pill, html[data-win98] .chip, html[data-win98] .rx-pill {
-    background: var(--bg); color: #000;
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
-  }
-  /* ...and presses in like one. */
-  html[data-win98] .copy-chip:active, html[data-win98] .expand-btn:active,
-  html[data-win98] .web-toggle:active, html[data-win98] .dv-btn:active,
-  html[data-win98] .ue-toggle:active, html[data-win98] .react-ghost:active {
-    border-color: #404040 #FFFFFF #FFFFFF #404040;
-  }
-  /* Panels are little windows: raised silver frame + navy title-bar gradient. */
-  html[data-win98] .panel {
-    background: var(--bg);
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF;
-  }
-  html[data-win98] .panel-header {
-    background: linear-gradient(90deg, #000080, #1084D0);
-    color: #FFFFFF; border-bottom: none;
-  }
-  html[data-win98] .panel-header a.file-link { color: #FFFFFF; border-bottom-color: #9CC2E5; }
-  /* Content wells are sunken white, like every 98 text box and list view. */
-  html[data-win98] .md pre, html[data-win98] pre.cmd, html[data-win98] pre.cmd-out,
-  html[data-win98] pre.diff, html[data-win98] pre.mono-block, html[data-win98] pre.raw,
-  html[data-win98] .op-detail {
-    background: var(--panel);
-    border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080;
-  }
-  html[data-win98] .md code { background: var(--panel); border: 1px solid #808080; }
-  html[data-win98] .md pre code { border: none; }
-  /* 1998 had no soft shadows. */
-  html[data-win98] #rx-pop { box-shadow: none; background: var(--bg);
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
-  html[data-win98] .chip .dot, html[data-win98] .tool-pill .tp-dot { box-shadow: none; }
-  /* Plan/help tables become 98 list views: sunken white body, RAISED column headers —
-     the iconic Explorer detail. Row separators in dialog-face gray. */
-  html[data-win98] table.plan { background: var(--panel);
-    border: 2px solid; border-color: #808080 #FFFFFF #FFFFFF #808080; }
-  html[data-win98] table.plan th { background: var(--bg); color: #000;
-    border: 1px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
-  html[data-win98] table.plan td { border-top: 1px solid #D4D0C8; }
-  /* Chunky classic scrollbars. */
-  html[data-win98] ::-webkit-scrollbar { width: 16px; height: 16px; }
-  html[data-win98] ::-webkit-scrollbar-track { background: #DFDFDF; }
-  html[data-win98] ::-webkit-scrollbar-thumb { background: var(--bg);
-    border: 2px solid; border-color: #FFFFFF #404040 #404040 #FFFFFF; }
-  html[data-win98] ::-webkit-scrollbar-corner { background: #DFDFDF; }
-
-  /* User prompts: gold marks the user's voice, at normal weight so an 8-line clamped
-     paste reads as text, not a block of emphasis. Only the sigil stays semibold. */
-  .user-echo { color: var(--gold); white-space: pre-wrap; margin-top: 14px; }
-  .ue-sigil { font-weight: 600; }
-  /* Long prompts clamp to ~8 lines (JS adds the class only when the echo is actually tall).
-     The fade is a mask on the text itself — not an overlay painted in a background color —
-     so it works over chat-background images and every theme. */
-  .user-echo.clamped { max-height: 11.5em; overflow: hidden;
-    -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent);
-    mask-image: linear-gradient(to bottom, black calc(100% - 2.2em), transparent); }
-  .ue-toggle { display: block; background: none; border: none; cursor: pointer;
-    color: var(--dim); font-size: 11px; font-family: "Segoe UI", sans-serif; padding: 1px 0; }
-  .ue-toggle:hover { color: var(--fg); }
-  .assistant { margin-top: 4px; position: relative; }
-  .assistant-label { color: var(--green); font-weight: 700; margin-bottom: 2px; }
-  .md p { margin: 6px 0; }
-  .md pre {
-    background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
-    padding: 10px 12px; overflow-x: auto; position: relative;
-    font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
-  }
-  .md code { font-family: "Cascadia Code", Consolas, monospace; background: var(--panel);
-    border-radius: 4px; padding: 1px 5px; font-size: 13px; }
-  .md pre code { background: none; padding: 0; }
-  .md table { border-collapse: collapse; margin: 8px 0; }
-  .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; }
-  a { color: var(--sky); }
-  .md h1, .md h2, .md h3 { color: var(--accent); margin: 12px 0 4px 0; }
-  .md ul, .md ol { margin: 4px 0; padding-left: 24px; }
-  .md blockquote { border-left: 3px solid var(--accent); margin: 6px 0; padding-left: 10px; color: var(--dim); }
-  .line { white-space: pre-wrap; }
-  .info { color: var(--sky); }
-  .success { color: var(--green); }
-  .warn { color: var(--gold); }
-  .error { color: var(--red); }
-  .dim { color: var(--dim); }
-  .sky { color: var(--sky); }
-  .red { color: var(--red); }
-  .token-summary { text-align: right; font-size: 12px; }
-
-  /* Status chips — compact pills for session/connection state. A CSS status dot
-     (crisp, theme-aware) replaces status emoji; state = ok | warn | err | neutral. */
-  /* Centered to match the tool pills: all system/status chrome sits centered, conversation stays left. */
-  .chip-row { margin: 2px 0; text-align: center; }
-  .chip { display: inline-flex; align-items: center; gap: 7px;
-    padding: 3px 12px; border-radius: 999px; font-size: 12.5px;
-    border: 1px solid var(--border); background: var(--panel);
-    /* Uniform floor so status pills line up — the "MCP / N connected" pill is the
-       widest of them, so shorter pills (model / ready) pad up to match. Longer
-       chips still grow past it. */
-    box-sizing: border-box; min-width: 190px; }
-  .chip .dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
-    background: var(--dim); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
-  .chip.ok .dot { background: var(--green);
-    box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 24%, transparent); }
-  .chip.warn .dot { background: var(--gold);
-    box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 24%, transparent); }
-  .chip.err .dot { background: var(--red);
-    box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 24%, transparent); }
-  .chip-val { color: var(--fg); font-weight: 600; }
-  .chip-key { color: var(--dim); }
-
-  /* Tool-call pills — STATIC (no animation, so they never cause continuous repaint). A rounded,
-     theme-colored chip with a monochrome glyph, matching the StatusChip family. */
-  /* Centered: tool pills are the assistant's machinery, not dialogue — centering (like Slack/Discord
-     system messages) keeps the left column a clean read and marks them as ambient activity.
-     display:flex + fit-content makes the chip block-level and shrink-wrapped so margin auto centers it. */
-  .tool-pill { display: flex; width: fit-content; align-items: center; gap: 8px; margin: 2px auto;
-    padding: 3px 12px; border-radius: 999px; font-size: 12px;
-    border: 1px solid var(--border); background: var(--panel); }
-  .tool-pill .tp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
-    background: var(--dim);
-    box-shadow: 0 0 0 3px color-mix(in srgb, var(--dim) 20%, transparent); }
-  .tool-pill .tp-label { color: var(--fg);
-    font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
-  .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
-    overflow: hidden; }
-  .panel.red-border { border-color: var(--red); }
-  .panel-header { padding: 6px 12px; font-weight: 600; border-bottom: 1px solid var(--border);
-    font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
-  .panel-footer { padding: 4px 12px 8px 12px; color: var(--dim); font-size: 12px; }
-  pre.cmd, pre.cmd-out, pre.diff, pre.mono-block, pre.raw {
-    margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre;
-    font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px;
-  }
-  pre.mono-block, pre.raw { background: var(--panel); border: 1px solid var(--border);
-    border-radius: 8px; white-space: pre-wrap; }
-  .d-add { color: var(--diffadd); display: block; }
-  .d-rem { color: var(--red); display: block; }
-  .d-ctx { color: var(--dim); display: block; }
-
-  /* Collapsible long panels: a big write/diff/output otherwise fills the screen and forces
-     endless scrolling, so panel-hosted blocks taller than ~22% of the window collapse to that
-     preview height by default. A matching Expand/Collapse button sits in the top-RIGHT and
-     bottom-RIGHT corners (JS adds them only when a block is actually tall) so it's reachable
-     whether you're at the top or, after expanding, down at the bottom. The header and footer
-     pad on the right to clear the buttons. Pure class flip on click — no animation loop. */
-  .collapsible-panel { position: relative; }
-  .collapsible-panel > .panel-header,
-  .collapsible-panel > .panel-footer {
-    padding-right: 84px;
-    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-  }
-  /* Reserve a bottom gutter so the bottom corner buttons never overlap the last line of a
-     footerless panel (e.g. command output). */
-  pre.collapsible { position: relative; padding-bottom: 34px; }
-  pre.collapsible.collapsed { max-height: 22vh; overflow-y: hidden; }
-  .collapse-fade { position: absolute; left: 0; right: 0; bottom: 0; height: 44px;
-    pointer-events: none; background: linear-gradient(to bottom, transparent, var(--panel)); }
-  .expand-btn { position: absolute; top: 6px; z-index: 3; cursor: pointer;
-    background: var(--bg); color: var(--dim); border: 1px solid var(--border);
-    border-radius: 6px; padding: 2px 9px; font-size: 11px;
-    font-family: "Segoe UI", sans-serif; opacity: 0.9; }
-  .expand-btn.left { left: 6px; }
-  .expand-btn.right { right: 6px; }
-  .expand-btn.bottom { top: auto; bottom: 6px; }
-  .expand-btn:hover { color: var(--fg); border-color: var(--accent); opacity: 1; }
-
-  /* Web fetch/search previews: noisy reference text, hidden by default behind an inline Expand
-     chip on the op line. Expanding reveals the detail box, which reuses the corner Collapse
-     button (.expand-btn.right) so it can be closed from the window itself. */
-  .web-toggle { margin-left: 8px; cursor: pointer; vertical-align: baseline;
-    background: var(--bg); color: var(--dim); border: 1px solid var(--border);
-    border-radius: 6px; padding: 1px 8px; font-size: 11px; font-family: "Segoe UI", sans-serif; }
-  .web-toggle:hover { color: var(--fg); border-color: var(--accent); }
-  .web-detail { position: relative; margin-top: 4px; }
-  .web-detail[hidden] { display: none; }
-  .web-detail > .op-detail { margin-top: 0; }
-  /* Action chips on USER-requested diff cards (Changes-tab clicks): Undo posts to the host,
-     Clear removes the card. Floated right in the header; the collapsible-panel header's
-     right padding keeps them clear of the corner Expand button. */
-  .dv-actions { float: right; display: inline-flex; gap: 6px; }
-  .dv-btn { background: var(--bg); color: var(--dim); border: 1px solid var(--border);
-    border-radius: 6px; padding: 1px 8px; font-size: 11px;
-    font-family: "Segoe UI", sans-serif; cursor: pointer; }
-  .dv-btn:hover { color: var(--fg); border-color: var(--accent); }
-  a.file-link { color: var(--sky); text-decoration: none;
-    border-bottom: 1px dotted color-mix(in srgb, var(--sky) 55%, transparent); cursor: pointer; }
-  a.file-link:hover { color: var(--accent); border-bottom-color: var(--accent); }
-  .op { margin: 2px 0; }
-  .op-head { font-weight: 600; }
-  .op-path { font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; }
-  .op-meta { color: var(--dim); font-size: 12px; }
-  .op-detail { margin-top: 4px; background: var(--panel); border: 1px solid var(--border);
-    border-radius: 8px; }
-  /* Prose tool output (web search/fetch): wrap to width, reading font, dimmed — reference material,
-     not a code block. Declared after pre.cmd-out so these win on shared properties. */
-  pre.op-prose { white-space: pre-wrap; word-break: break-word; overflow-x: hidden;
-    font-family: "Segoe UI", sans-serif; font-size: 12.5px; color: var(--dim); }
-  table.plan { border-collapse: collapse; width: 100%; }
-  table.plan th, table.plan td { border-top: 1px solid var(--border); padding: 5px 12px;
-    text-align: left; vertical-align: top; }
-  table.plan th { color: var(--dim); font-weight: 600; }
-  .nowrap { white-space: nowrap; }
-
-  /* Syntax highlighting: highlight.js token classes mapped onto the theme's CSS
-     variables, so code colors follow every theme (and survive live retheming). */
-  .hljs { background: transparent; color: var(--fg); }
-  .hljs-comment, .hljs-quote { color: var(--dim); font-style: italic; }
-  .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag { color: var(--accent); }
-  .hljs-string, .hljs-regexp, .hljs-addition { color: var(--green); }
-  .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-meta, .hljs-built_in { color: var(--gold); }
-  .hljs-title, .hljs-section, .hljs-name, .hljs-title.function_, .hljs-title.class_ { color: var(--sky); }
-  .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-type { color: var(--sky); }
-  .hljs-deletion { color: var(--red); }
-  .hljs-emphasis { font-style: italic; }
-  .hljs-strong { font-weight: bold; }
-
-  /* Copy chips — appear on hover over code blocks and assistant messages. Label is
-     CSS generated content so it never pollutes the copied innerText. */
-  .copy-chip { position: absolute; top: 6px; right: 6px; z-index: 1; opacity: 0;
-    transition: opacity 0.12s; background: var(--bg); color: var(--dim);
-    border: 1px solid var(--border); border-radius: 6px; padding: 2px 9px;
-    font-size: 11px; font-family: "Segoe UI", sans-serif; cursor: pointer; }
-  .copy-chip::before { content: "Copy"; }
-  .copy-chip.copied::before { content: "Copied ✓"; }
-  .copy-chip.copied { color: var(--green); border-color: var(--green); }
-  .md pre:hover .copy-chip, .assistant:hover > .copy-chip { opacity: 1; }
-  .copy-chip:hover { color: var(--fg); border-color: var(--accent); }
-
-  /* Reactions, Teams-style. A ghosted add-reaction button fades in on hover next to the
-     copy chip; clicking it opens a floating picker card (mirrors the input box's emoji
-     flyout). Chosen reactions sit under the message as pills — no space is reserved
-     until one exists. Delivery to the model: ChatController.SubmitAsync. */
-  .react-ghost { position: absolute; top: 6px; right: 56px; z-index: 1; opacity: 0;
-    transition: opacity 0.12s; background: var(--bg); color: var(--dim);
-    border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
-    font-size: 12px; cursor: pointer;
-    font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
-  .assistant:hover > .react-ghost { opacity: 0.55; }
-  .react-ghost:hover { opacity: 1 !important; border-color: var(--accent); color: var(--fg); }
-  /* The copy chip widens to "Copied ✓" for ~1.4s after a click; the ghost sits close
-     enough to collide, so it ducks out for the duration of the flash. */
-  .copy-chip.copied ~ .react-ghost { opacity: 0 !important; pointer-events: none; }
-  #rx-pop { position: absolute; z-index: 50; display: none; width: 316px;
-    background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
-    padding: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.45); }
-  #rx-pop .rx { background: none; border: 1px solid transparent; border-radius: 6px;
-    padding: 2px 5px; font-size: 17px; line-height: 22px; cursor: pointer;
-    font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
-  #rx-pop .rx:hover { background: var(--bg); border-color: var(--border); }
-  #rx-pop .rx.on { background: var(--bg); border-color: var(--accent); }
-  /* flex-wrap is the safety net: if emoji glyphs render wider than budgeted (font
-     version varies by Windows build), the row wraps inside the card instead of
-     bleeding past its border. */
-  #rx-pop .rx-quick { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; }
-  #rx-pop .rx-more-btn { margin-left: auto; background: none; border: none;
-    color: var(--dim); font-size: 12px; cursor: pointer; padding: 2px 6px; }
-  #rx-pop .rx-more-btn:hover { color: var(--fg); }
-  #rx-pop .rx-grid { display: none; flex-wrap: wrap; gap: 2px; margin-top: 6px;
-    padding-top: 6px; border-top: 1px solid var(--border); max-height: 156px;
-    overflow-y: auto; }
-  .rx-tray { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
-  .rx-pill { background: var(--panel); border: 1px solid var(--accent); border-radius: 999px;
-    padding: 1px 9px; font-size: 13px; line-height: 19px; cursor: pointer;
-    font-family: "Segoe UI Emoji", "Segoe UI", sans-serif; }
-  .rx-pill:hover { border-color: var(--dim); opacity: 0.85; }
-
-  /* Consecutive operation cards group into a collapsible run; it stays open while
-     the run is active and collapses once a non-operation block lands after it. */
-  details.op-group { margin: 2px 0; }
-  details.op-group summary { color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }
-  details.op-group summary:hover { color: var(--fg); }
-  details.op-group > .op { margin-left: 16px; }
-
-  /* Jump-to-bottom pill — shows when scrolled away from the live end of the chat. */
-  #jump-pill { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
-    display: none; z-index: 40; background: var(--panel); color: var(--fg);
-    border: 1px solid var(--accent); border-radius: 999px; padding: 6px 14px;
-    font-size: 12px; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
-
-  /* In-chat find bar (Ctrl+F while the transcript has focus). */
-  #findbar { position: fixed; top: 10px; right: 16px; z-index: 60; display: none;
-    align-items: center; gap: 6px; background: var(--panel);
-    border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
-    box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
-  #findbar input { background: var(--bg); color: var(--fg); border: 1px solid var(--border);
-    border-radius: 6px; padding: 3px 8px; font-size: 12px; width: 180px; outline: none; }
-  #findbar .find-count { color: var(--dim); font-size: 11px; min-width: 44px; text-align: center; }
-  #findbar button { background: none; border: none; color: var(--dim); cursor: pointer;
-    font-size: 12px; padding: 2px 6px; }
-  #findbar button:hover { color: var(--fg); }
-  mark.find-hit { background: var(--gold); color: #000; border-radius: 2px; }
-  mark.find-hit.find-current { background: var(--accent); color: #fff; }
-
-  /* Scrollbars — Chromium's stock chrome ignores the theme; restyle every scroll surface
-     (page, code blocks, reaction picker grid) to match it. */
-  ::-webkit-scrollbar { width: 10px; height: 10px; }
-  ::-webkit-scrollbar-track { background: transparent; }
-  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px;
-    border: 2px solid transparent; background-clip: padding-box; }
-  ::-webkit-scrollbar-thumb:hover { background-color: var(--dim); }
-  ::-webkit-scrollbar-corner { background: transparent; }
-  #rx-pop .rx-grid::-webkit-scrollbar { width: 7px; }
+{{TranscriptCss.Value}}
 
 
 
@@ -787,470 +347,7 @@ the run is active and collapses once a non-operation block lands after it. */
 
diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 6e8b256..4f149fd 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -16,7 +16,8 @@ namespace MandoCode.Desktop.ViewModels; /// public sealed partial class ChatController { - private readonly AIService _ai; + private readonly IAiService _ai; + private readonly ResponseStreamer _streamer; private readonly MandoCodeConfig _config; private readonly TokenTrackingService _tokenTracker; private readonly PlanHandoff _planHandoff; @@ -74,7 +75,11 @@ public void RemoveReaction(string cardId, string emoji) => /// Set by AgentSession: receives ("u"/"a", text) for every conversational turn, /// feeding the per-session ConversationLog that re-briefs the model after a restart. /// Only real dialogue is logged — /commands and !shell lines are not conversation. - public Action? ConversationLogger { get; set; } + public Action? ConversationLogger + { + get => _streamer.ConversationLogger; + set => _streamer.ConversationLogger = value; + } /// Arms a restored previous-session conversation to ride the next send as /// imported background — the automatic counterpart of importing a snapshot, used by @@ -137,7 +142,7 @@ private void NoteShellCommand(string cmd, bool failed, string output) public event Action? McpEditorRequested; public ChatController( - AIService ai, + IAiService ai, MandoCodeConfig config, TokenTrackingService tokenTracker, PlanHandoff planHandoff, @@ -181,6 +186,13 @@ public ChatController( _mcp = mcp; _snapshots = snapshots; + // The streamed-response loop, split out so it's testable with a fake IAiService. The 401 + // sign-in walkthrough is a UI wizard, so it rides in as a callback rather than a direct call. + _streamer = new ResponseStreamer(_ai, _transcript, _html, _busy, _tokenTracker, _config) + { + On401 = OfferCloudSigninAsync + }; + // ---- Same wiring as App.razor OnInitialized ---- // Every delegate below is SINGLE-ASSIGNMENT, not multicast. That's safe only because // _ai, _planHandoff, and _mcpGate are this tab's own instances (see AgentSession) — @@ -402,53 +414,24 @@ public async Task SubmitAsync(string input) var needsPlanning = _taskPlanner.RequiresPlanning(input); var processedInput = ProcessFileReferences(input); - // 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. + // Fold the invisible ride-alongs (imported recaps, emoji reactions, external workspace + // changes) and any planning nudge into the message the model sees. See + // RequestPreambleComposer for the exact framing. + processedInput = RequestPreambleComposer.Compose( + processedInput, + _armedContexts, + _pendingReactions.Select(r => (r.Emoji, r.Snippet)).ToList(), + _pendingWorkspaceNotes, + needsPlanning); + + // Ride-alongs are one-shot — clear what we just folded in so it isn't sent twice. if (_armedContexts.Count > 0) { - var noun = _armedContexts.Count == 1 ? "recap" : "recaps"; - processedInput = - $"[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; _armedContexts.Clear(); _armedSnapshotIds.Clear(); // a new batch can re-import the same snapshots next time } - - // Emoji reactions ride along the same way — factual feedback the model can steer - // on, clearly framed as metadata so it's never mistaken for typed text. - if (_pendingReactions.Count > 0) - { - var lines = string.Join("\n", _pendingReactions.Select(r => - $"- {r.Emoji} on your response beginning: “{r.Snippet}”")); - processedInput = - "[The user reacted to earlier responses with emoji — a feedback signal, not text they typed:]\n" + - lines + - "\n\n[Current request:]\n" + processedInput; - _pendingReactions.Clear(); - } - - // Workspace changes the model didn't make and can't see. Framed as facts (not - // instructions) with an explicit staleness warning, so the model re-reads rather - // than trusting its memory of file contents. - if (_pendingWorkspaceNotes.Count > 0) - { - var notes = string.Join("\n", _pendingWorkspaceNotes.Select(n => "- " + n)); - processedInput = - "[Workspace changes since your last turn, made outside this conversation. " + - "Your memory of affected file contents may be stale — re-read before relying on it:]\n" + - notes + - "\n\n[Current request:]\n" + processedInput; - _pendingWorkspaceNotes.Clear(); - } - - if (needsPlanning) - { - processedInput += "\n\n[system: this request looks multi-step. " + - "Call propose_plan now with the breakdown before doing any work.]"; - } + _pendingReactions.Clear(); + _pendingWorkspaceNotes.Clear(); await ProcessDirectRequestAsync(processedInput); } @@ -472,95 +455,17 @@ public void CancelActiveRequest() private async Task ProcessDirectRequestAsync(string input) { + // Reset the per-request operation tracking the function-call event handlers read. _recentReadCount = 0; _recentReadFiles.Clear(); _lastOperationType = null; _requestCts = new CancellationTokenSource(); var token = _requestCts.Token; - try { - var receivedFirstChunk = false; - var enumerator = _ai.ChatStreamAsync(input, token).GetAsyncEnumerator(token); - - try - { - _busy.Start("Thinking..."); - - if (await enumerator.MoveNextAsync()) - receivedFirstChunk = true; - - if (!receivedFirstChunk) - { - _busy.Stop(); - _transcript.Append(_html.Warn("No response from model. The request may have exceeded the model's context window.")); - _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window via /config set.")); - return; - } - - // Each element is one completed chat turn — auto-continuations and - // post-approval turns arrive as additional elements. Flush every turn - // to the transcript as it completes so its text lands next to the - // approval/diff cards it belongs with, instead of every turn coalescing - // into a single card after the final one. - var segments = new List(); - do - { - var segment = enumerator.Current.Trim(); - if (segment.Length > 0) - { - segments.Add(segment); - _transcript.Append(_html.AssistantCard(segment)); - ConversationLogger?.Invoke("a", segment); - } - } while (await enumerator.MoveNextAsync()); - - _busy.Stop(); - - if (segments.Count == 0) - { - _transcript.Append(_html.Warn("Model returned an empty response. The context may be too large for this model.")); - _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window.")); - } - else - { - var responseText = string.Join("\n\n", segments); - _lastAiResponse = responseText; - - if (Looks401(responseText)) - { - // 401 auto-recovery — same intent as the CLI's TryAutoSigninAfter401Async: - // offer the sign-in walkthrough right here instead of making the user - // type /setup and re-navigate to it. - await OfferCloudSigninAsync(); - } - - if (_config.EnableTokenTracking) - { - var lastOp = _tokenTracker.LastOperation; - if (lastOp != null && !lastOp.IsEstimate) - { - var tps = lastOp.TokensPerSecond.HasValue ? $": {lastOp.TokensPerSecond.Value:0.#} tok/s" : ""; - _transcript.Append(_html.TokenSummary( - $"[~{TokenTrackingService.FormatTokenCount(lastOp.PromptTokens)} in, " + - $"{TokenTrackingService.FormatTokenCount(lastOp.CompletionTokens)} out{tps}]")); - } - } - } - } - finally - { - await enumerator.DisposeAsync(); - } - } - catch (OperationCanceledException) - { - _transcript.Append(_html.Warn("Request cancelled.")); - } - catch (Exception ex) - { - _transcript.Append(_html.Error($"Error: {ex.Message}")); + var response = await _streamer.StreamAsync(input, token); + if (!string.IsNullOrEmpty(response)) _lastAiResponse = response; } finally { @@ -571,10 +476,6 @@ private async Task ProcessDirectRequestAsync(string input) } } - private static bool Looks401(string responseText) - => !string.IsNullOrEmpty(responseText) - && responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase); - // ============================================================ // @file references (port of ProcessFileReferences) // ============================================================ @@ -1020,7 +921,7 @@ private void ShowHelp() ("/config", "Adjust settings — guided wizard (/config set inline)"), ("/retry", "Retry Ollama connection"), ("/learn", "Learn about LLMs and local AI models"), - ("/music", "Play lofi/synthwave coding music"), + ("/music", "Play coding music (also /music-lofi, /music-synthwave for a specific genre)"), ("/music-stop", "Stop music playback"), ("/music-pause", "Pause/resume music"), ("/music-next", "Skip to next track"), @@ -1824,7 +1725,12 @@ public async Task> GetMcpStatusRowsAsync() var tools = await client.ListToolsAsync(); toolCount = tools.Count.ToString(); } - catch { } + catch (Exception ex) + { + // Connected but the tool listing failed — degrade to "?" in the UI, but this is + // an unexpected server fault worth a breadcrumb (only hit when the MCP page refreshes). + Services.CrashLog.Write($"MCP ListTools({name})", ex); + } status = $"connected · {toolCount} tool(s)"; } else if (_mcpManager.StartupErrors.TryGetValue(name, out var err)) diff --git a/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs new file mode 100644 index 0000000..2581911 --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs @@ -0,0 +1,67 @@ +namespace MandoCode.Desktop.ViewModels; + +/// +/// Assembles the invisible preamble that rides along with a user's message — imported snapshot +/// recaps, emoji reactions, workspace changes made outside the conversation, and a planning nudge. +/// Extracted from ChatController.SubmitAsync as a pure function so the exact framing (which +/// the model sees but the user never does) can be unit-tested. Each block that fires wraps the +/// running text with its own "[Current request:]" boundary, in the order armed → reactions → +/// workspace; the planning nudge is appended last. All three collections are framed as background +/// facts, never as text the user typed. +/// +public static class RequestPreambleComposer +{ + public static string Compose( + string request, + IReadOnlyList armedContexts, + IReadOnlyList<(string Emoji, string Snippet)> reactions, + IReadOnlyList workspaceNotes, + bool needsPlanning) + { + var result = request; + + // Imported snapshots ride along ONCE as background the model already knows — the user's + // echoed message stays their own text. Multiple imports accumulate, each a distinct recap. + if (armedContexts.Count > 0) + { + var noun = armedContexts.Count == 1 ? "recap" : "recaps"; + result = + $"[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" + result; + } + + // Emoji reactions: factual feedback the model can steer on, framed as metadata so it's + // never mistaken for typed text. + if (reactions.Count > 0) + { + var lines = string.Join("\n", reactions.Select(r => + $"- {r.Emoji} on your response beginning: “{r.Snippet}”")); + result = + "[The user reacted to earlier responses with emoji — a feedback signal, not text they typed:]\n" + + lines + + "\n\n[Current request:]\n" + result; + } + + // Workspace changes the model didn't make and can't see. Framed as facts with an explicit + // staleness warning, so the model re-reads rather than trusting its memory of file contents. + if (workspaceNotes.Count > 0) + { + var notes = string.Join("\n", workspaceNotes.Select(n => "- " + n)); + result = + "[Workspace changes since your last turn, made outside this conversation. " + + "Your memory of affected file contents may be stale — re-read before relying on it:]\n" + + notes + + "\n\n[Current request:]\n" + result; + } + + if (needsPlanning) + { + result += "\n\n[system: this request looks multi-step. " + + "Call propose_plan now with the breakdown before doing any work.]"; + } + + return result; + } +} diff --git a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs new file mode 100644 index 0000000..569264b --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs @@ -0,0 +1,137 @@ +using MandoCode.Models; +using MandoCode.Services; +using MandoCode.Desktop.Services; + +namespace MandoCode.Desktop.ViewModels; + +/// +/// Drives one streamed model response: pumps , flushes each +/// completed turn to the transcript as its own card, and handles the empty-response, 401, and +/// cancellation cases plus the token summary. Lifted out of ChatController so it can be tested +/// with a fake — it depends only on constructible, WinUI-free collaborators +/// (the request-lifecycle bits — the CancellationTokenSource, StateChanged, operation-field resets — +/// stay in ChatController). The 401 sign-in walkthrough is a UI wizard, so it arrives as the +/// callback rather than being called directly. +/// +public sealed class ResponseStreamer +{ + private readonly IAiService _ai; + private readonly TranscriptWriter _transcript; + private readonly ITranscriptHtml _html; + private readonly BusyStateService _busy; + private readonly TokenTrackingService _tokenTracker; + private readonly MandoCodeConfig _config; + + public ResponseStreamer( + IAiService ai, + TranscriptWriter transcript, + ITranscriptHtml html, + BusyStateService busy, + TokenTrackingService tokenTracker, + MandoCodeConfig config) + { + _ai = ai; + _transcript = transcript; + _html = html; + _busy = busy; + _tokenTracker = tokenTracker; + _config = config; + } + + /// Logs conversational turns ("a" for each assistant turn). Set by ChatController so the + /// same logger records both user and assistant turns. + public Action? ConversationLogger { get; set; } + + /// Invoked when a response looks like a cloud 401 — ChatController wires the sign-in + /// walkthrough here. Null in tests. + public Func? On401 { get; set; } + + /// Streams a request to completion and writes it to the transcript. Returns the joined + /// response text (empty when the model returned nothing, was cancelled, or errored) so the caller + /// can remember the last response. Never throws — cancellation and errors surface as transcript + /// lines, matching the original in-controller behavior. + public async Task StreamAsync(string input, CancellationToken token) + { + try + { + var enumerator = _ai.ChatStreamAsync(input, token).GetAsyncEnumerator(token); + try + { + _busy.Start("Thinking..."); + + if (!await enumerator.MoveNextAsync()) + { + _busy.Stop(); + _transcript.Append(_html.Warn("No response from model. The request may have exceeded the model's context window.")); + _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window via /config set.")); + return ""; + } + + // Each element is one completed chat turn — auto-continuations and post-approval turns + // arrive as additional elements. Flush every turn as it completes so its text lands + // next to the approval/diff cards it belongs with, instead of coalescing into one card. + var segments = new List(); + do + { + var segment = enumerator.Current.Trim(); + if (segment.Length > 0) + { + segments.Add(segment); + _transcript.Append(_html.AssistantCard(segment)); + ConversationLogger?.Invoke("a", segment); + } + } while (await enumerator.MoveNextAsync()); + + _busy.Stop(); + + if (segments.Count == 0) + { + _transcript.Append(_html.Warn("Model returned an empty response. The context may be too large for this model.")); + _transcript.Append(_html.Dim("Try a smaller request, or switch to a model with a larger context window.")); + return ""; + } + + var responseText = string.Join("\n\n", segments); + + if (Looks401(responseText) && On401 != null) + { + // 401 auto-recovery — same intent as the CLI's TryAutoSigninAfter401Async: + // offer the sign-in walkthrough inline instead of making the user type /setup. + await On401(); + } + + if (_config.EnableTokenTracking) + { + var lastOp = _tokenTracker.LastOperation; + if (lastOp != null && !lastOp.IsEstimate) + { + var tps = lastOp.TokensPerSecond.HasValue ? $": {lastOp.TokensPerSecond.Value:0.#} tok/s" : ""; + _transcript.Append(_html.TokenSummary( + $"[~{TokenTrackingService.FormatTokenCount(lastOp.PromptTokens)} in, " + + $"{TokenTrackingService.FormatTokenCount(lastOp.CompletionTokens)} out{tps}]")); + } + } + + return responseText; + } + finally + { + await enumerator.DisposeAsync(); + } + } + catch (OperationCanceledException) + { + _transcript.Append(_html.Warn("Request cancelled.")); + return ""; + } + catch (Exception ex) + { + _transcript.Append(_html.Error($"Error: {ex.Message}")); + return ""; + } + } + + private static bool Looks401(string responseText) + => !string.IsNullOrEmpty(responseText) + && responseText.Contains("401 Unauthorized", StringComparison.OrdinalIgnoreCase); +}