diff --git a/CHANGELOG.md b/CHANGELOG.md index daeec30..24f8fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,48 @@ are visible until you actually open a second tab. button (cloud models first, `cloud`/`local` badges, current one preselected) instead of a full-screen modal. It opens instantly with a loading spinner while the model list is fetched, and shows connection/empty-list errors inline. The typed `/model` command still uses the overlay wizard. +- **History panel — reopen a closed conversation.** A new rail icon (with a count badge) opens a + docked panel, sharing the Snapshots column, that lists every conversation you've closed — title, + project, model, when, turn count, and the first thing you said. **Open** brings one back as a + fresh tab through the existing restore cascade: the transcript replays and, when the model can + take it, the full memory rehydrates. **Delete** forgets one for good. Search filters by title, + project, model, or that first message. The archive is app-wide, persisted, and capped at the + newest 60 — evicting an old row deletes its journals so the on-disk stores stay bounded. +- **Snapshots panel — grouping, search, and a cleaner import.** Snapshot cards now group by the + project they were taken in (freshest project first), a search box filters by title/recap/model/ + project, and Import closes the panel and focuses the chat so the "context armed" confirmation is + the thing you see. +- **Collapsible project groups, in both panels.** Each project group in Snapshots and History is an + `Expander` you can fold — the answer to "10–100 projects." Which groups you've collapsed is + remembered across launches (`PanelState` → `panel-state.json`). +- **Compare view — two agents side by side.** A **Split** button pairs two agents into a resizable + side-by-side view. The pair is an explicit, remembered choice (set by the button or the compare + bar's pickers, never by clicking a tab): clicking a paired agent's tab shows the split, clicking + any other agent shows it normally while the pair waits. The panes are ordinary agent views moved + between grid columns via `Grid.SetColumn` — never re-parented — so both WebViews and their live + transcripts survive the switch. +- **AI-named snapshots.** Saving a snapshot without a name now asks the summarizer for a short, + descriptive title from the recap; uniqueness against existing titles is then guaranteed in code + (`SnapshotNaming`), so two snapshots can't share a name. +- **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. +- **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. ### Changed +- **Closing the last agent is allowed.** The app no longer forces at least one agent open — closing + the final one leaves an empty state (with the chat background) and a one-click New agent. Settings, + MCP, and snapshot Import disable while no agent is open and re-enable when one exists. +- **"Take snapshot" goes straight to the picker.** The manual capture (tab `⋯` menu) skips the + "snapshot available?" notification bar and opens the name + summarizer-model picker directly — a + model switch keeps the bar, since snapshotting isn't a foregone conclusion there. +- **Closing a tab archives it; `/clear` still forgets.** Closing used to delete a conversation's + journals outright ("closed tab = conversation gone"). Now it files the conversation into the + History archive instead, so it can be reopened later; only `/clear` (and eviction past the + archive cap) deletes the files. A session that never had a real turn is still dropped on close — + there's nothing to reopen. "Cleared means cleared" is unchanged; only *closing* softens from + "gone" to "recoverable." - **`/model` is an agent-local switch** and no longer writes to disk; the model button in each agent's header opens the same picker. `/setup` and the Settings page still set the app-wide default, because they configure the app rather than one agent. @@ -131,8 +171,9 @@ are visible until you actually open a second tab. 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. -- **Snapshots are session-scoped**, in memory only — they vanish on app close. Persisting them to - disk is a possible follow-up (it would need a store to name and garbage-collect). +- **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. ## [0.1.0] — 2026-07-07 diff --git a/README.md b/README.md index 4822b6e..dce0e76 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,26 @@ 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` | +| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore` | 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 -carries Rename, Take snapshot, Export transcript, and Close — Close is greyed on the last remaining -agent (Settings and MCP need one to act on). The model in each header opens a quick-switch dropdown -(cloud first, `cloud`/`local` badges) rather than a full-screen picker. +carries Rename, Take snapshot, Export transcript, and Close. The model in each header opens a +quick-switch dropdown (cloud first, `cloud`/`local` badges) rather than a full-screen picker. + +Closing the **last** agent is allowed: it leaves a clean empty state (showing the chat background) +with a one-click way to start a new agent. Actions that need an agent to act on — the Settings and +MCP pages, and snapshot Import — disable while none is open, then re-enable when you open one. + +### Compare view (two agents side by side) + +The **Split** button pairs two agents into a resizable side-by-side view for comparing what each is +producing. The pair is an explicit, remembered choice — set only by the Split button and the +compare-bar pickers, never by clicking a tab. Clicking a paired agent's tab shows the split; +clicking any other agent shows it normally while the pair waits. The two panes are ordinary agent +views moved between grid columns with `Grid.SetColumn` — **never re-parented**, so both WebViews (and +their live transcripts) survive the switch, which is the whole reason the tab surface is built the +way it is (see below). The three approval services are per-agent for **correctness**, not tidiness. Shared, they break in ways that are invisible until a second tab exists: `WinUiApprovalService` holds the @@ -117,18 +130,33 @@ a casing difference. ### Context snapshots -Switching a model clears the conversation (a different model mid-history is a different -conversation). The instant before it clears, the outgoing conversation is captured as a -`ContextSnapshot` — origin model, timestamp, a deterministic recap, and the full history. The -recap comes from `HistorySummarizer`, a port of the harness's own (private) compaction summary fed -by the public `AIService.GetHistoryAsync()` — so no submodule change. The **Snapshots** rail icon -opens a global panel (the `SnapshotStore` is app-wide, one list for every tab); **Import** arms a -snapshot's recap to ride along, invisibly, with the active agent's next message — carrying context -into any model. `Take snapshot` on a tab's `⋯` menu captures on demand without switching. - -The snapshot keeps the full history so a richer LLM summary can be generated later (the model -reserves `AiRecap`, the `Tag` flips `Light`→`AI`); that "Enhance" action waits on a small no-tools -completion seam added at the next harness pin roll. Snapshots are session-scoped and in memory only. +A snapshot is a portable, AI-written recap of a conversation — save the gist of one agent's +context and carry it into another model or a fresh agent. Snapshots are offered when switching a +model would clear the conversation, and on demand via `Take snapshot` (tab `⋯` menu), which goes +straight to the save step. The recap is generated by `SnapshotEnhancer` (a bare, tool-less Ollama +kernel, map-reduce over the full history so nothing is truncated) using a summarizer model you +pick; a snapshot is therefore always born with a real recap — there is no "light"/un-enhanced state. +Leave the name blank and the summarizer proposes a short title, which is then made unique against +existing titles in code (`SnapshotNaming`) — an LLM can't be trusted to guarantee that itself. + +The **Snapshots** rail icon opens a global panel (the `SnapshotStore` is app-wide, one list for +every tab, **persisted** to `snapshots.json`). Cards **group by project** and are **searchable**, +and each project group is a collapsible `Expander` whose fold state is remembered +(`PanelState` → `panel-state.json`). **Import** arms a snapshot's recap to ride along, invisibly, +with the active agent's next message — carrying context into any model. The rail badge is an +**unread count** (snapshots captured since you last opened the panel), not a running total, and +clears when you open it. + +### Session history (reopen closed conversations) + +Closing an agent no longer discards its conversation — it **archives** it. `SessionArchiveStore` +keeps an app-wide index (`sessions.json`) of closed conversations; the transcript, model memory, and +conversation-log journals stay on disk (see [docs/session-persistence.md](docs/session-persistence.md)). +The **History** rail panel lists them (grouped by project, searchable, collapsible), and **Open** +reopens one as a fresh tab on its original persist-key so the normal restore cascade replays the +transcript and — when the model supports it — rehydrates the full memory. `/clear` still forgets a +conversation for good; only *closing* softened from "gone" to "recoverable." The archive is capped +(newest 60); evicting a row deletes its journals so the on-disk stores stay bounded. ### Why the tab strip isn't a `TabView` @@ -158,11 +186,22 @@ within 24 hours. - Agent tabs — `+` opens another agent (`Agent 1`, `Agent 2`, …) with its own conversation, project folder, model, and settings; an approval waiting in a background agent badges its tab and the toast names it. Each tab's `⋯` menu: - Rename, Take snapshot, Export transcript, Close (greyed on the last agent). The - header model opens a quick-switch dropdown (cloud first, `cloud`/`local` badges) -- Context snapshots — the conversation is captured the instant a model switch would - clear it (and on demand via `⋯` → Take snapshot); a global left-rail panel lists - every tab's snapshots and Import carries one into the active agent's next message + Rename, Take snapshot, Export transcript, Close. The header model opens a + quick-switch dropdown (cloud first, `cloud`/`local` badges). Closing the last + agent is allowed and leaves an empty state that shows the chat background +- Compare view — the **Split** button shows two agents side by side in a resizable + split for comparing their output; the compared pair is a remembered, explicit + choice, so clicking other tabs navigates without disturbing it +- Session history — closing an agent archives its conversation instead of deleting + it; the **History** panel reopens any past conversation as a new tab (with its + transcript, and full memory when the model supports it), grouped by project and + searchable. `/clear` still forgets for good +- Context snapshots — save an AI-written recap of a conversation (summarized by a + model you pick) and Import it into another model or a fresh agent; a global + left-rail panel lists them, **persisted**, grouped by project, searchable, with + 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 - Settings — the whole config as a native form (toggles, sliders, number boxes, grouped Appearance/Connection/Generation/Behavior/Limits/Integrations); every @@ -176,6 +215,7 @@ within 24 hours. progress, model picker, cloud-auth check + sign-in walkthrough - `/model`, `/force-skill`, `/music-playlist` — pickers - 401 auto-recovery — a cloud 401 offers the `ollama signin` walkthrough inline +- 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). diff --git a/docs/session-persistence.md b/docs/session-persistence.md index 9135caa..44b8143 100644 --- a/docs/session-persistence.md +++ b/docs/session-persistence.md @@ -24,6 +24,8 @@ Each tier ships independently and degrades gracefully into the one below it. |------|---------------|-------|-----------| | 1 | Workspace shape: tabs, titles, folders, models, active tab | `workspace.json` | Saved on every structural change + close; restored at launch | | 1 | Snapshots | `snapshots.json` | Rewritten on add/remove; loaded at construction | +| 1 | Closed-conversation index (History) | `sessions.json` | Rewritten on close/reopen/delete; points at the retained per-key journals below | +| 1 | Panel UI prefs: collapsed groups + per-panel "last seen" unread marks | `panel-state.json` | Rewritten on fold/unfold and when a panel is opened | | 2 | The visible transcript | `transcripts/.jsonl` | Append-on-write journal of every HTML block; replayed into the WebView on restore | | 3 | The model's memory | `histories/.json` | `AIService.ExportHistoryJson()` at every turn end (write-then-rename); `TryRestoreHistoryJson()` on restore | | 3 fallback | A plain-text tail of the dialogue | `conversations/.jsonl` | Armed as imported background on the next send when full fidelity can't apply | @@ -63,18 +65,43 @@ both sides of the concept line: was cleared, there is nothing to carry). If the verbatim import fails, the offer stays up and the snapshot path remains as salvage. -## Where snapshots are left off (future building) +## The History archive — reopening closed conversations -Snapshots persist across launches now, they record their project root, and IDs survive — but -the panel hasn't caught up: **no grouping by project, no search, and the import UX is -unchanged.** Those are polish items waiting for the snapshot library to grow now that it's -durable. Nothing broken, just room. +The per-key journals turned out to support more than restoring the tabs open at close: they back a +**History panel** that reopens *any* conversation you've closed. This required one deliberate change +to the retention model. -Other known headroom, in rough order of value: +Closing a tab used to delete its journals outright — "closed tab = conversation gone." That made +the memory/knowledge split lopsided: the only way context survived was to still be open at launch. +Now closing **archives** instead: + +- `SessionArchiveStore` keeps an app-wide index (`sessions.json`) of closed conversations — the + cheap metadata (title, project, model, closed-at, turn count, first message), not the heavy + parts. The transcript/log/history journals it points at are the same per-key stores a live tab + uses; they simply aren't deleted on close anymore. +- **Reopen** recreates a tab on the archived persist-key and lets the normal restore cascade run — + so a reopened conversation replays its transcript and, when the model can take it, rehydrates its + full memory. The row leaves the archive (it's live again) and re-files itself on the next close. +- The archive is capped at the newest 60; evicting a row deletes its journals, so the on-disk + stores stay bounded even for someone who never runs `/clear`. +- The startup orphan sweep now keeps *archived* keys alongside *open* ones — only genuinely + orphaned journals (crash leftovers, pruned folders) are swept. + +The design rule held: `/clear` still forgets (deletes the files, never archives). Only the meaning +of *closing* softened from "gone" to "recoverable." A session that never had a real turn is dropped +on close regardless — there's nothing worth reopening. + +Both panels grew the same shape at the same time: cards **group by project**, a **search** box +filters them, and each project group is a **collapsible** `Expander` whose fold state persists +(`panel-state.json`). Their rail badges became **unread counts** — items newer than the last time +you opened that panel — which clear on open and whose "last seen" marks also persist. Snapshots +gained **AI-generated titles** (unique-checked in code) when saved unnamed, and Import now gets out +of the way so the chat's "context armed" confirmation is what you see. + +## Future building + +In rough order of value: -- **Session history browser** — the per-key journals already on disk would support a - "reopen any past conversation as a new tab" picker (Claude Code's `/resume` equivalent), - not just restoring the tabs that were open at close. - **Summarize-at-restore upgrade** — the tail-brief fallback could run `HistorySummarizer` over the stored dialogue instead of excerpting it, trading an LLM call for better coverage of long sessions. diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 87da7ed..aa3e567 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -21,6 +21,16 @@ + + + + + + + + diff --git a/src/MandoCode.Desktop.Tests/SessionArchiveEntryTests.cs b/src/MandoCode.Desktop.Tests/SessionArchiveEntryTests.cs new file mode 100644 index 0000000..eb1962b --- /dev/null +++ b/src/MandoCode.Desktop.Tests/SessionArchiveEntryTests.cs @@ -0,0 +1,68 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Locks the pure display derivations used by the History and Snapshots panels — the project-label +/// path parsing (whose null / trailing-slash / root-only cases are the kind that silently regress) +/// and the preview placeholder. No store is constructed: these are property getters on the data +/// records, so there's no filesystem contact. +/// +public sealed class SessionArchiveEntryTests +{ + private static SessionArchiveEntry Entry(string project = @"C:\work\mando", string? preview = "hi") => + new() + { + Key = "k1", + Title = "Agent 1", + ProjectRoot = project, + Model = "opus", + ClosedAt = DateTimeOffset.Now, + TurnCount = 3, + Preview = preview, + }; + + [Fact] + public void ProjectLabel_UsesLeafName() + => Assert.Equal("mando", Entry(@"C:\work\mando").ProjectLabel); + + [Fact] + public void ProjectLabel_IgnoresTrailingSeparator() + => Assert.Equal("mando", Entry(@"C:\work\mando\").ProjectLabel); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ProjectLabel_FallsBackWhenBlank(string project) + => Assert.Equal("Unknown project", Entry(project).ProjectLabel); + + [Fact] + public void PreviewOrPlaceholder_UsesPreviewWhenPresent() + => Assert.Equal("hi", Entry(preview: "hi").PreviewOrPlaceholder); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void PreviewOrPlaceholder_PlaceholderWhenBlank(string? preview) + => Assert.Equal("(no message text captured)", Entry(preview: preview).PreviewOrPlaceholder); + + // The same project-label rule backs snapshot grouping — a null root (snapshots taken before + // project tracking existed) must land in one stable bucket, not throw. + [Fact] + public void Snapshot_ProjectLabel_HandlesNullRoot() + { + var snap = new ContextSnapshot + { + Id = 1, + CapturedAt = DateTimeOffset.Now, + OriginModel = "opus", + SummarizerModel = "opus", + Recap = "…", + MessageCount = 2, + ProjectRoot = null, + }; + Assert.Equal("Unknown project", snap.ProjectLabel); + } +} diff --git a/src/MandoCode.Desktop.Tests/SnapshotNamingTests.cs b/src/MandoCode.Desktop.Tests/SnapshotNamingTests.cs new file mode 100644 index 0000000..29d1c2c --- /dev/null +++ b/src/MandoCode.Desktop.Tests/SnapshotNamingTests.cs @@ -0,0 +1,59 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The deterministic guards around LLM-suggested snapshot titles: cleaning the model's raw output +/// (which loves to add quotes, a "Title:" preface, or a second line of reasoning) and enforcing +/// uniqueness the model can't be trusted to. +/// +public sealed class SnapshotNamingTests +{ + [Theory] + [InlineData("\"Auth Refactor\"", "Auth Refactor")] // surrounding quotes + [InlineData("Auth Refactor.", "Auth Refactor")] // trailing period + [InlineData("`Auth Refactor`", "Auth Refactor")] // backticks + [InlineData(" Auth Refactor ", "Auth Refactor")] // collapsed whitespace + public void Clean_StripsDecoration(string raw, string expected) + => Assert.Equal(expected, SnapshotNaming.Clean(raw)); + + [Fact] + public void Clean_KeepsFirstLineOnly() + => Assert.Equal("Auth Refactor", SnapshotNaming.Clean("Auth Refactor\nreasoning here")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\"\"")] + public void Clean_ReturnsNullWhenNothingUsable(string? raw) + => Assert.Null(SnapshotNaming.Clean(raw)); + + [Fact] + public void Clean_CapsLength() + { + var result = SnapshotNaming.Clean(new string('a', 200))!; + Assert.True(result.Length <= 61); // 60 chars + the ellipsis + Assert.EndsWith("…", result); + } + + [Fact] + public void MakeUnique_LeavesDistinctNameUntouched() + => Assert.Equal("Auth Refactor", SnapshotNaming.MakeUnique("Auth Refactor", new[] { "Other Thing" })); + + [Fact] + public void MakeUnique_AppendsCounterOnClash() + => Assert.Equal("Auth Refactor (2)", SnapshotNaming.MakeUnique("Auth Refactor", new[] { "Auth Refactor" })); + + [Fact] + public void MakeUnique_SkipsToFirstFreeCounter() + { + var taken = new[] { "Auth Refactor", "Auth Refactor (2)", "Auth Refactor (3)" }; + Assert.Equal("Auth Refactor (4)", SnapshotNaming.MakeUnique("Auth Refactor", taken)); + } + + [Fact] + public void MakeUnique_ClashIsCaseInsensitive() + => Assert.Equal("Auth Refactor (2)", SnapshotNaming.MakeUnique("Auth Refactor", new[] { "auth refactor" })); +} diff --git a/src/MandoCode.Desktop/App.xaml.cs b/src/MandoCode.Desktop/App.xaml.cs index 30d0ab1..a5d9e27 100644 --- a/src/MandoCode.Desktop/App.xaml.cs +++ b/src/MandoCode.Desktop/App.xaml.cs @@ -27,6 +27,22 @@ public App() // is constructed (see Program.cs in MandoCode for the full rationale). AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(10)); + // 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 */ } + }; + Services = BuildServices(); } @@ -86,6 +102,11 @@ private static ServiceProvider BuildServices() // re-import a conversation captured by another. Session-scoped, not persisted. services.AddSingleton(); + // App-wide index of closed conversations, so a tab you close can be reopened from the + // History panel instead of being lost. Persisted; the journals it points at are the same + // per-key stores a live tab uses. + services.AddSingleton(); + // ---- Coordinators + session registry ---- services.AddSingleton(provider => new ConfigCoordinator(provider.GetRequiredService())); services.AddSingleton(provider => new McpCoordinator(provider.GetRequiredService())); diff --git a/src/MandoCode.Desktop/Assets/images/mandocode-desktop-icon.png b/src/MandoCode.Desktop/Assets/images/mandocode-desktop-icon.png new file mode 100644 index 0000000..7bfa7fb Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/mandocode-desktop-icon.png differ diff --git a/src/MandoCode.Desktop/Assets/images/mandocode-desktop.ico b/src/MandoCode.Desktop/Assets/images/mandocode-desktop.ico new file mode 100644 index 0000000..f112cb3 Binary files /dev/null and b/src/MandoCode.Desktop/Assets/images/mandocode-desktop.ico differ diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index cfb890f..a30dc6f 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -430,7 +430,7 @@ - Stage 2: the user accepted the notification, so expand into the full name + model - /// picker (reused). It hangs at the top until they create or dismiss. - private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e) + /// 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) { - var offer = _controller.PendingOffer; - if (offer == null) return; - SnapshotOfferSubtitle.Text = $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — " - + "name it (optional), pick a model, and create."; + + "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. @@ -546,10 +553,17 @@ private void SnapshotNotifyCreate_Click(object sender, RoutedEventArgs e) SnapshotOfferContent.IsHitTestVisible = true; SnapshotNotifyBar.Visibility = Visibility.Collapsed; SnapshotOfferCard.Visibility = Visibility.Visible; - SlideSnapshotOfferIn(); // re-drop for the taller card + 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() { diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index 0452534..53342b3 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -46,6 +46,22 @@ + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -212,7 +398,8 @@ CoreWebView2 — and the transcript DOM is the only copy of that conversation. --> - + @@ -252,24 +439,113 @@ - - + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 329af07..6611e00 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -37,6 +37,35 @@ 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 { @@ -119,20 +148,25 @@ public sealed partial class MainWindow : Window { private readonly SessionManager _sessions; private readonly SnapshotStore _snapshotStore; // app-wide context snapshots + private readonly SessionArchiveStore _archive; // app-wide index of closed conversations private readonly SkillCoordinator _skillCoordinator; // app-wide global-skills manager private readonly ConfigCoordinator _configs; // owns the app-wide MCP server list (defaults) private readonly TranscriptHtmlBuilder _html; // app-global, stateless formatter private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher; + + // Snapshots and History share the one docked column left of the content (Grid.Column 1) and are + // mutually exclusive — opening one swaps out the other without re-sliding the column. private bool _snapshotsPanelOpen; + private bool _historyPanelOpen; - // Slide animation state for the snapshots panel. The column width is tweened per-frame off + // Slide animation state for the docked left column. The width is tweened per-frame off // CompositionTarget.Rendering so the panel glides in/out instead of snapping. Width is always // held in pixels during and after the tween (never star) so an interrupted open/close can read // the current width and continue smoothly from wherever it is. private readonly Stopwatch _snapAnimClock = new(); private EventHandler? _snapAnimHandler; private double _snapAnimFrom, _snapAnimTo; - private bool _snapAnimHideOnDone; + private FrameworkElement? _snapAnimHide; // panel to collapse when a close tween completes private const double SnapAnimDurationMs = 220; /// @@ -170,10 +204,19 @@ public MainWindow() _html = services.GetRequiredService(); _sessions = services.GetRequiredService(); _snapshotStore = services.GetRequiredService(); + _archive = services.GetRequiredService(); _skillCoordinator = services.GetRequiredService(); _configs = services.GetRequiredService(); // Changed can fire on a background thread (a capture during a model switch). _snapshotStore.Changed += () => OnUi(OnSnapshotsChanged); + _archive.Changed += () => OnUi(OnArchiveChanged); + + // Remembered fold state + "last seen" watermarks for both panels (persisted UI preference). + var panelState = PanelState.Load(); + foreach (var p in panelState.CollapsedSnapshotGroups) _collapsedSnapshotGroups.Add(p); + foreach (var p in panelState.CollapsedHistoryGroups) _collapsedHistoryGroups.Add(p); + _snapshotsSeenAt = panelState.SnapshotsSeenAt; + _historySeenAt = panelState.HistorySeenAt; // The first agent. Its whole service graph — AIService, approvals, transcript, token // tracking — belongs to it alone, so opening a second tab can't disturb it. @@ -194,13 +237,23 @@ public MainWindow() } // Journals whose sessions no longer exist (tabs closed during a crash, pruned - // folders) have nothing to replay into — clean them up. - TranscriptJournal.Sweep(_tabs.Select(t => t.View.Session.PersistKey)); - ConversationLog.Sweep(_tabs.Select(t => t.View.Session.PersistKey)); - SessionHistoryStore.Sweep(_tabs.Select(t => t.View.Session.PersistKey)); + // folders) have nothing to replay into — clean them up. Archived (closed-but-recoverable) + // sessions are kept: their files back the History panel, so their keys join the keep-set. + var liveKeys = _tabs.Select(t => t.View.Session.PersistKey).Concat(_archive.Keys).ToList(); + TranscriptJournal.Sweep(liveKeys); + ConversationLog.Sweep(liveKeys); + SessionHistoryStore.Sweep(liveKeys); // Size the window; defer WebView2 + harness init until the tree is loaded. AppWindow.Resize(new Windows.Graphics.SizeInt32(1180, 840)); + // Title-bar icon. The exe already embeds the same .ico (taskbar/Alt-Tab/Explorer via + // ); this sets the little glyph in the window's own title bar. Best-effort. + try + { + var icon = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "images", "mandocode-desktop.ico"); + if (File.Exists(icon)) AppWindow.SetIcon(icon); + } + catch { /* a missing/locked icon must never stop the window from opening */ } Root.Loaded += Root_Loaded; Closed += MainWindow_Closed; @@ -405,6 +458,11 @@ private void Root_Loaded(object sender, RoutedEventArgs e) // 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) @@ -608,6 +666,8 @@ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = n 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; } @@ -633,6 +693,10 @@ private void NavAppearance_Click(object sender, RoutedEventArgs e) 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"; @@ -648,11 +712,15 @@ private void SwitchPage(string page) else if (page == "skills") SlideInPage(SkillsPage, SkillsPageTransform); else if (page == "appearance") SlideInPage(AppearancePage, AppearancePageTransform); - // Every agent view stays loaded; only the selected one shows, and only on the chat page. - // Collapsing rather than removing is what keeps each WebView2's transcript alive. - foreach (var tab in _tabs) - tab.View.Visibility = showingChat && ReferenceEquals(tab, _selected) - ? Visibility.Visible : Visibility.Collapsed; + // 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 @@ -726,7 +794,13 @@ private void RefreshNavIcons() 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"); } @@ -737,42 +811,59 @@ private void RefreshNavIcons() private void NavSnapshots_Click(object sender, RoutedEventArgs e) { - if (_snapshotsPanelOpen) CloseSnapshots(); + if (_snapshotsPanelOpen) CloseLeftPanel(); else OpenSnapshots(); } - private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseSnapshots(); + private void CloseSnapshots_Click(object sender, RoutedEventArgs e) => CloseLeftPanel(); private void OpenSnapshots() { - _snapshotsPanelOpen = true; - SnapshotsPanel.Visibility = Visibility.Visible; + 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); - AnimateSnapshotsColumn(target, hideOnDone: false); + AnimateLeftColumn(target, hideOnDone: null); } - private void CloseSnapshots() + private void CloseLeftPanel() { + var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel + : _historyPanelOpen ? HistoryPanel : null; _snapshotsPanelOpen = false; + _historyPanelOpen = false; RefreshNavIcons(); - AnimateSnapshotsColumn(0, hideOnDone: true); + AnimateLeftColumn(0, hideOnDone: toHide); } - /// Tweens the snapshots column width to with an ease-out curve, + /// 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. - private void AnimateSnapshotsColumn(double toPx, bool hideOnDone) + /// 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; - _snapAnimHideOnDone = hideOnDone; + _snapAnimHide = hideOnDone; _snapAnimClock.Restart(); _snapAnimHandler = (_, _) => @@ -787,7 +878,7 @@ private void AnimateSnapshotsColumn(double toPx, bool hideOnDone) CompositionTarget.Rendering -= _snapAnimHandler; _snapAnimHandler = null; _snapAnimClock.Stop(); - if (_snapAnimHideOnDone) SnapshotsPanel.Visibility = Visibility.Collapsed; + if (_snapAnimHide != null) _snapAnimHide.Visibility = Visibility.Collapsed; } }; CompositionTarget.Rendering += _snapAnimHandler; @@ -795,35 +886,133 @@ private void AnimateSnapshotsColumn(double toPx, bool hideOnDone) 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(); - if (_snapshotsPanelOpen) PopulateSnapshots(); } + /// 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 items = _snapshotStore.Items; // newest-first copy of the shared store - SnapshotsList.ItemsSource = items; - var empty = items.Count == 0; - SnapshotsEmpty.Visibility = empty ? Visibility.Visible : Visibility.Collapsed; - SnapshotsScroller.Visibility = empty ? Visibility.Collapsed : Visibility.Visible; + 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() { - var n = _snapshotStore.Count; + // 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; + 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) @@ -833,6 +1022,179 @@ private void SnapshotDelete_Click(object sender, RoutedEventArgs e) 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) { @@ -982,9 +1344,8 @@ private void WireHeader(ChatTabEntry entry) menu.Items.Add(new MenuFlyoutSeparator()); menu.Items.Add(close); - // The last remaining agent can't be closed (Settings and MCP need one to act on), so grey - // the item rather than leave a dead button. Re-evaluated each time the menu opens. - menu.Opening += (_, _) => close.IsEnabled = _tabs.Count > 1; + // 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; } @@ -1020,6 +1381,8 @@ private async Task RenameTabAsync(ChatTabEntry entry) /// 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(); @@ -1066,9 +1429,6 @@ private void CloseTab(ChatTabEntry entry) var index = _tabs.IndexOf(entry); if (index < 0) return; - // The last agent stays: Settings and MCP have no agent to act on without one. - if (_tabs.Count == 1) return; - _tabs.RemoveAt(index); TabStrip.Children.Remove(entry.Header); @@ -1077,12 +1437,22 @@ private void CloseTab(ChatTabEntry entry) entry.View.Shutdown(); TabHost.Children.Remove(entry.View); _sessions.CloseSession(entry.View.Session); - TranscriptJournal.Delete(entry.View.Session.PersistKey); // closed tab = conversation gone - ConversationLog.Delete(entry.View.Session.PersistKey); - SessionHistoryStore.Delete(entry.View.Session.PersistKey); + 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; @@ -1090,9 +1460,246 @@ private void CloseTab(ChatTabEntry entry) _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"]; @@ -1137,6 +1744,7 @@ private void RefreshTabStrip() } RefreshNavIcons(); + RefreshSplitButton(); LayoutTabStrip(); } diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj index 4b6ca98..642bad6 100644 --- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj +++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj @@ -17,6 +17,9 @@ 0.1.0 Armando Fernandez (DevMando) MandoCode Desktop — the MandoCode AI coding agent with a native WinUI 3 interface. + + Assets\images\mandocode-desktop.ico @@ -30,6 +33,10 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/MandoCode.Desktop/Services/ContextSnapshot.cs b/src/MandoCode.Desktop/Services/ContextSnapshot.cs index 0ef5784..5bf2fde 100644 --- a/src/MandoCode.Desktop/Services/ContextSnapshot.cs +++ b/src/MandoCode.Desktop/Services/ContextSnapshot.cs @@ -43,4 +43,18 @@ public sealed class ContextSnapshot [System.Text.Json.Serialization.JsonIgnore] public string TimeLabel => CapturedAt.LocalDateTime.ToString("MMM d · h:mm tt"); + + /// 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; + } + } } diff --git a/src/MandoCode.Desktop/Services/PanelState.cs b/src/MandoCode.Desktop/Services/PanelState.cs new file mode 100644 index 0000000..f893e77 --- /dev/null +++ b/src/MandoCode.Desktop/Services/PanelState.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace MandoCode.Desktop.Services; + +/// Per-panel UI memory: which project groups are folded shut (by project label; empty means +/// all expanded), and when the user last opened each panel — the "seen" watermark that makes the +/// rail badge an unread count ("new since you last looked") rather than a running total. A null +/// watermark means never opened, so everything currently there counts as new. +public sealed record PanelStateShape( + List CollapsedSnapshotGroups, + List CollapsedHistoryGroups, + DateTimeOffset? SnapshotsSeenAt = null, + DateTimeOffset? HistorySeenAt = null); + +/// +/// Persists per-panel UI preference — the fold state of the Snapshots and History project groups — +/// so a group you collapse stays collapsed across launches. A window-level preference like +/// Appearance, it lives outside the shared agent config. Best-effort on both ends, same as +/// : a missing/corrupt file just means everything starts expanded. +/// +public static class PanelState +{ + private static string StorePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MandoCode.Desktop", "panel-state.json"); + + public static PanelStateShape Load() + { + try + { + if (File.Exists(StorePath)) + { + var shape = JsonSerializer.Deserialize(File.ReadAllText(StorePath)); + if (shape != null) + return new PanelStateShape( + shape.CollapsedSnapshotGroups ?? new(), + shape.CollapsedHistoryGroups ?? new(), + shape.SnapshotsSeenAt, + shape.HistorySeenAt); + } + } + catch { /* corrupt/unreadable — start with everything expanded */ } + return new PanelStateShape(new(), new()); + } + + public static void Save(PanelStateShape shape) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); + File.WriteAllText(StorePath, JsonSerializer.Serialize(shape)); + } + catch { /* best-effort */ } + } +} diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs new file mode 100644 index 0000000..aa315e1 --- /dev/null +++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs @@ -0,0 +1,171 @@ +using System.Text.Json; + +namespace MandoCode.Desktop.Services; + +/// +/// One closed conversation, recoverable from the History panel. Pure data — the heavy parts +/// (transcript HTML, model memory) stay in their own per-key stores; this is just the index row +/// that lets the user find and reopen them. +/// +public sealed class SessionArchiveEntry +{ + /// The session's durable persist-key — the join back to its transcript journal, + /// conversation log, and history JSON on disk. Reopening recreates a tab on this key so the + /// existing restore cascade rehydrates it. + public required string Key { get; init; } + + public required string Title { get; init; } + public required string ProjectRoot { get; init; } + + /// Model the conversation last ran on (null if never set), re-selected on reopen. + public string? Model { get; init; } + + public required DateTimeOffset ClosedAt { get; init; } + + /// User+assistant turns recorded for this session — a cheap "how big was this". + public required int TurnCount { get; init; } + + /// First thing the user said, trimmed — the line that makes a row recognizable. + public string? Preview { get; init; } + + // ---- display helpers for the panel ---- + + [System.Text.Json.Serialization.JsonIgnore] + public string TimeLabel => ClosedAt.LocalDateTime.ToString("MMM d · h:mm tt"); + + [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; + } + } + + /// Card body: the first user message, or an honest stand-in when there wasn't one. + [System.Text.Json.Serialization.JsonIgnore] + public string PreviewOrPlaceholder => + string.IsNullOrWhiteSpace(Preview) ? "(no message text captured)" : Preview!; +} + +/// +/// App-wide index of CLOSED conversations, so a tab you closed can be reopened later rather than +/// being gone for good. This is the retention half of a deliberate split: +/// +/// • Closing a tab ARCHIVES it — the journals stay on disk and a row lands here. +/// • /clear still FORGETS — it deletes the journals and never archives (see AgentSession). +/// +/// "Cleared means cleared" survives; only the meaning of *closing* softens from "gone" to +/// "recoverable". Persisted to sessions.json and rewritten on every change, exactly like +/// . A retention cap bounds the archive: evicting a row also deletes its +/// journal/log/history files, so the on-disk stores can't grow without limit. +/// +/// may fire on a background thread; subscribers marshal to the UI themselves. +/// +public sealed class SessionArchiveStore +{ + /// Newest N closed sessions are kept; older rows are evicted with their files. + private const int MaxEntries = 60; + + private static string StorePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MandoCode.Desktop", "sessions.json"); + + private readonly object _lock = new(); + private readonly List _items = new(); + + public SessionArchiveStore() + { + try + { + if (!File.Exists(StorePath)) return; + var loaded = JsonSerializer.Deserialize>(File.ReadAllText(StorePath)); + if (loaded != null) _items.AddRange(loaded); + } + catch { /* corrupt/unreadable index — start empty rather than crash the app */ } + } + + /// Raised after any add/remove. May arrive on a background thread. + public event Action? Changed; + + /// A point-in-time copy, newest first. + public IReadOnlyList Items + { + get { lock (_lock) return _items.ToList(); } + } + + public int Count + { + get { lock (_lock) return _items.Count; } + } + + /// Persist-keys of every archived session — folded into the startup orphan sweep's + /// keep-set so an archived conversation's files aren't mistaken for a crash leftover. + public IReadOnlyList Keys + { + get { lock (_lock) return _items.Select(e => e.Key).ToList(); } + } + + public bool Contains(string key) + { + lock (_lock) return _items.Any(e => string.Equals(e.Key, key, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Files a closed session. Newest-first; a re-closed session (reopened from the archive, then + /// closed again) replaces its old row rather than duplicating it. Evicts past the cap, deleting + /// the evicted sessions' on-disk files so nothing is orphaned. + /// + public void Add(SessionArchiveEntry entry) + { + List evicted = new(); + lock (_lock) + { + _items.RemoveAll(e => string.Equals(e.Key, entry.Key, StringComparison.OrdinalIgnoreCase)); + _items.Insert(0, entry); + while (_items.Count > MaxEntries) + { + evicted.Add(_items[^1]); + _items.RemoveAt(_items.Count - 1); + } + } + foreach (var e in evicted) DeleteFiles(e.Key); + Persist(); + Changed?.Invoke(); + } + + /// Removes a row and deletes its files — the History panel's Delete, and the path a + /// reopened session takes out of the archive (its files stay; only the index row goes). + public void Remove(string key, bool deleteFiles) + { + bool removed; + lock (_lock) + removed = _items.RemoveAll(e => string.Equals(e.Key, key, StringComparison.OrdinalIgnoreCase)) > 0; + if (!removed) return; + if (deleteFiles) DeleteFiles(key); + Persist(); + Changed?.Invoke(); + } + + private static void DeleteFiles(string key) + { + TranscriptJournal.Delete(key); + ConversationLog.Delete(key); + SessionHistoryStore.Delete(key); + } + + private void Persist() + { + try + { + List copy; + lock (_lock) copy = _items.ToList(); + Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); + File.WriteAllText(StorePath, JsonSerializer.Serialize(copy)); + } + catch { /* persistence is best-effort; the in-memory index is still correct */ } + } +} diff --git a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs index f2b98ce..bfa32b6 100644 --- a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs +++ b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs @@ -98,17 +98,55 @@ public static async Task SummarizeAsync( return await SummarizeOneAsync(chat, kernel, ReducePrompt, string.Join("\n\n", partials), ct); } + private const string NamePrompt = + "Give this saved conversation a short title so it's recognizable in a list later. 3 to 6 " + + "words, Title Case, naming the actual subject (a feature, file, bug, topic, or decision) — " + + "not generic filler like \"Coding Session\" or \"Conversation Summary\". Output ONLY the " + + "title: no quotes, no trailing punctuation, no explanation."; + + /// Suggests a short, human-recognizable title for a snapshot from its recap. Best-effort: + /// returns null (caller falls back to the origin model as the card title) on any failure or an + /// unusable result. is passed to the model to discourage near-duplicates; + /// the caller still enforces true uniqueness deterministically — an LLM can't be trusted to. + public static async Task SuggestNameAsync( + string endpoint, string model, string recap, IReadOnlyCollection avoid, CancellationToken ct = default) + { + try + { + if (string.IsNullOrWhiteSpace(recap)) return null; + + var kernel = Kernel.CreateBuilder() + .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) + .Build(); + var chat = kernel.GetRequiredService(); + + var instruction = NamePrompt; + if (avoid.Count > 0) + instruction += " These titles are already taken, so pick something clearly different: " + + string.Join("; ", avoid.Take(40)) + "."; + + // A touch of warmth so titles aren't all phrased alike, but still grounded in the recap. + var raw = await SummarizeOneAsync(chat, kernel, instruction, recap, ct, temperature: 0.4f); + return SnapshotNaming.Clean(raw); + } + catch + { + return null; + } + } + /// One chat round: system instruction + the text to summarize. Stateless — a fresh /// history each call, so nothing leaks between chunks. private static async Task SummarizeOneAsync( - IChatCompletionService chat, Kernel kernel, string instruction, string text, CancellationToken ct) + IChatCompletionService chat, Kernel kernel, string instruction, string text, CancellationToken ct, + float temperature = 0.2f) { var history = new ChatHistory(); history.AddSystemMessage(instruction); history.AddUserMessage(text); - // Low temperature — a recap should be faithful, not creative. - var settings = new OllamaPromptExecutionSettings { Temperature = 0.2f }; + // Low temperature by default — a recap should be faithful, not creative. Naming nudges higher. + var settings = new OllamaPromptExecutionSettings { Temperature = temperature }; var result = await chat.GetChatMessageContentAsync(history, settings, kernel, ct); return result.Content?.Trim() ?? ""; diff --git a/src/MandoCode.Desktop/Services/SnapshotNaming.cs b/src/MandoCode.Desktop/Services/SnapshotNaming.cs new file mode 100644 index 0000000..70292f4 --- /dev/null +++ b/src/MandoCode.Desktop/Services/SnapshotNaming.cs @@ -0,0 +1,44 @@ +namespace MandoCode.Desktop.Services; + +/// +/// The deterministic half of snapshot auto-naming: turning a model's raw title output into a bare +/// label, and guaranteeing a title doesn't collide with ones already in use. Pure string logic, no +/// LLM or UI — the call that asks a model for a title lives in ; an +/// LLM can't be trusted to keep names clean or unique, so that's enforced here. +/// +public static class SnapshotNaming +{ + /// Tidies a model-produced title into a bare label: first line only, surrounding quotes + /// and trailing punctuation stripped, whitespace collapsed, length-capped. Null if nothing usable + /// survives (the caller then falls back to a non-AI title). + public static string? Clean(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var name = raw.Trim(); + // Models sometimes preface ("Title: X") or add a line of reasoning — keep the first line only. + var newline = name.IndexOfAny(new[] { '\r', '\n' }); + if (newline >= 0) name = name[..newline].Trim(); + + name = name.Trim('"', '\'', '`', ' ', '.', ':', '-', '*'); + name = string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + if (name.Length == 0) return null; + if (name.Length > 60) name = name[..60].TrimEnd() + "…"; + return name; + } + + /// Returns if no existing title matches it (case-insensitive), + /// else the first free "name (2)", "name (3)", … — so an auto-generated title can never collide + /// with one already on a card. + public static string MakeUnique(string name, IReadOnlyCollection taken) + { + bool Clashes(string s) => taken.Any(t => string.Equals(t, s, StringComparison.OrdinalIgnoreCase)); + if (!Clashes(name)) return name; + for (var n = 2; ; n++) + { + var candidate = $"{name} ({n})"; + if (!Clashes(candidate)) return candidate; + } + } +} diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index b2beed8..6e8b256 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -1291,7 +1291,10 @@ private async Task ApplyModelSwitchAsync(string modelTag) /// The outgoing conversation buffered on a model switch (or the live one, for a manual /// snapshot), held only until the user creates a snapshot from it or ignores the offer. Pure /// opt-in: it is NOT auto-saved, and is discarded on the next switch or when the app closes. - public sealed record PendingSnapshot(string OriginModel, string RawHistory, int MessageCount); + /// is true when the offer came from the "Take snapshot" tab + /// action rather than a model switch — the user already decided, so the UI can skip the + /// "snapshot available?" notification bar and open the name+model picker directly. + public sealed record PendingSnapshot(string OriginModel, string RawHistory, int MessageCount, bool IsManual = false); private PendingSnapshot? _pending; @@ -1304,14 +1307,14 @@ public sealed record PendingSnapshot(string OriginModel, string RawHistory, int /// Buffers the outgoing conversation before a switch clears it. Returns null (nothing /// buffered) when there's nothing worth keeping. Never throws — must not block the switch. - private async Task BufferConversationAsync(string originModel) + private async Task BufferConversationAsync(string originModel, bool isManual = false) { try { var history = await _ai.GetHistoryAsync(); if (!HistorySummarizer.HasContent(history)) return null; // Full (untruncated) — this is only ever fed to the summarizer, never stored on a snapshot. - return new PendingSnapshot(originModel, HistorySummarizer.Full(history), history.Count - 1); + return new PendingSnapshot(originModel, HistorySummarizer.Full(history), history.Count - 1, isManual); } catch { @@ -1324,7 +1327,7 @@ public sealed record PendingSnapshot(string OriginModel, string RawHistory, int public async Task OfferManualSnapshotAsync() { _pendingCarryJson = null; // nothing was cleared — "keep memory" doesn't apply here - _pending = await BufferConversationAsync(ModelName); + _pending = await BufferConversationAsync(ModelName, isManual: true); if (_pending == null) { _transcript.Append(_html.StatusChip("Nothing to snapshot", "start a conversation first", "warn")); @@ -1352,10 +1355,23 @@ public async Task OfferManualSnapshotAsync() if (string.IsNullOrWhiteSpace(recap)) return "The model returned an empty recap."; - _snapshots.Add(pending.OriginModel, summarizerModel, recap, pending.MessageCount, name, ProjectRootPath); + // No name typed → let the model title it from the recap, then GUARANTEE the title is + // unique against what's already in the store (the model is told the taken names to + // avoid near-misses, but code enforces it). A typed name is kept exactly as entered. + var finalName = name?.Trim(); + if (string.IsNullOrWhiteSpace(finalName)) + { + var taken = _snapshots.Items.Select(s => s.DisplayTitle).ToList(); + var suggested = await SnapshotEnhancer.SuggestNameAsync( + _config.OllamaEndpoint, summarizerModel, recap, taken); + if (!string.IsNullOrWhiteSpace(suggested)) + finalName = SnapshotNaming.MakeUnique(suggested!, taken); + } + + _snapshots.Add(pending.OriginModel, summarizerModel, recap, pending.MessageCount, finalName, ProjectRootPath); _pending = null; SnapshotOfferChanged?.Invoke(); - var label = string.IsNullOrWhiteSpace(name) ? $"summarized by {summarizerModel}" : $"\"{name.Trim()}\""; + var label = string.IsNullOrWhiteSpace(finalName) ? $"summarized by {summarizerModel}" : $"\"{finalName}\""; _transcript.Append(_html.StatusChip("Snapshot saved", label, "ok")); return null; }