diff --git a/CHANGELOG.md b/CHANGELOG.md
index b87e138..b368410 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,26 @@ are visible until you actually open a second tab.
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.
+- **History cards say where you left off, not just how you started.** A card carried only the first
+ thing you said. Since rows are titled by agent name ("Agent 3") unless renamed, that opening line is
+ the only thing identifying a conversation — so rather than replacing it, cards now show both: the
+ opening message as the topic, and a dimmer **last ·** line with your most recent message. Both are
+ the user's own words (symmetric, and your instruction rather than a long formatted reply). The last
+ line is hidden for single-turn conversations, where it would just repeat the first. Existing rows are
+ backfilled from their conversation logs on the first History open — off the UI thread, one write —
+ so old and new cards look the same instead of only new ones carrying the line.
+- **History search now reads the conversations, not just their labels.** The search box previously
+ matched title/project/model and the 140-character preview, so "find where we worked out the divider
+ math" missed unless those words happened to open the conversation — while Snapshots search covered
+ the whole recap, making History the inconsistent one. It now searches each archived conversation's
+ full text and shows the matching line as a quoted snippet on the card, so a hit whose title and
+ preview don't contain the term still explains itself. Metadata matching stays instant and
+ synchronous; the body scan is debounced ~220ms and runs off the UI thread behind
+ `ConversationTextCache` (lazily loaded per session, revalidated on the log's last-write time), so
+ typing never waits on file IO and 60 logs aren't re-read per keystroke. Each scan carries a
+ generation stamp so a slower earlier scan can't overwrite a later keystroke's results, and queries
+ under two characters don't trigger one at all. Matching and snippet extraction live in
+ `Services/ConversationSearch.cs`, kept pure and unit tested (+24 tests).
- **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
@@ -64,12 +84,36 @@ are visible until you actually open a second tab.
- **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.
+- **Delete a whole project group at once.** Opening a project group in Snapshots or History reveals a
+ **Delete all *n*** button at the top of the group, clearing it in one action instead of a card at a
+ time. It lives in the group's content, not its header, so it only exists while the group is open —
+ never crowding the collapse chevron — and it can state the exact count. A single-item group doesn't
+ get one at all, since that card's own Delete already does the same job. It confirms first, and
+ because a group holds exactly what the panel is *showing*, deleting with a search active removes
+ only the matches, which the prompt says explicitly rather than claiming "all". Backed by batched
+ `RemoveAll` methods on both stores: one store-file write and one panel rebuild for the whole set,
+ where looping the single-item Remove did both once per item.
+- **Split view — 2 to 4 agents at once.** A **Split** button puts two agents side by side in a
+ resizable view; **Add pane** in the split bar, or **Add to split view** on a tab (its `⋯` menu or
+ right-click), grows it to three across or four as a 2×2. **Add pane** is a `SplitButton` — clicking
+ it panes the next agent not yet shown, its chevron picks a specific one from those still available
+ (the same shape as the terminal's shell picker), so a third pane is never an arbitrary guess.
+ Past three, columns alone leave each pane
+ too narrow for a transcript plus an input box, so four wraps instead of shrinking further. Every
+ divider is draggable and repartitions only the two panes either side of it, so adjusting one split
+ never nudges a third pane. The pane set is an explicit, remembered choice (never set by
+ plain-clicking a tab): clicking a paned agent's tab shows the split, clicking any other agent shows
+ it normally while the set waits, and dropping below two panes turns the split off and leaves you on
+ the agent that survived. The set and its divider positions persist across restarts, keyed by each
+ agent's durable persist-key so a project folder that's gone drops one pane rather than shifting all
+ of them. Panes are ordinary agent views moved between grid cells via `Grid.SetColumn`/`Grid.SetRow`
+ — never re-parented — so every WebView and its live transcript survives the switch; the row and
+ column tracks are rebuilt in code per pane count, and track definitions plus dividers are the only
+ things that change. Geometry and divider math live in `Services/PaneLayout.cs`, free of WinUI types
+ and unit tested. The split bar uses chips with `MenuFlyout` pickers rather than `ComboBox`es,
+ which sidesteps the `COMException 0x80070490` that rebuilding ComboBox item containers triggers.
+ Named *split view* rather than *compare* because comparing two models on one prompt is only one of
+ its uses — at three or four panes you're usually watching agents work in parallel, not comparing.
- **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.
@@ -79,7 +123,9 @@ are visible until you actually open a second tab.
- **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.
+ opens in the active agent's project folder. The terminal glyph at the left of the panel's tab
+ strip collapses the panel, matching the chevron on the far right — so the icon that opened the
+ terminal from the rail is also an icon that closes it.
- **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 --git a/README.md b/README.md
index c96a414..811def2 100644
--- a/README.md
+++ b/README.md
@@ -91,15 +91,38 @@ Closing the **last** agent is allowed: it leaves a clean empty state (showing th
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).
+### Split view (2–4 agents at once)
+
+The **Split** button puts two agents side by side in a resizable view, and further panes are added
+from the split bar's **Add pane** button or a tab's **Add to split view** (its `⋯` menu, or
+right-click). **Add pane** is a `SplitButton`: clicking it panes the next agent that isn't shown
+yet, while its chevron lists the agents still available so you can pick a specific one — the same
+shape as the terminal's shell picker. The layout follows the pane count: two side by side, three
+across, four as a 2×2 —
+past three, columns alone leave each pane too narrow for a transcript plus an input box. Every
+divider is draggable, and each one repartitions only the two panes either side of it, so adjusting
+one split never nudges a third pane.
+
+It's called *split view* rather than *compare* because comparing two models on the same prompt is
+only one of the things it's for: with three or four panes open you're usually watching agents work
+in parallel on different folders, not comparing their output.
+
+The pane set is an explicit, remembered choice — never set by plain-clicking a tab. Clicking a paned
+agent's tab shows the split; clicking any other agent shows it normally while the set waits.
+Dropping below two panes turns the split off and leaves you on the agent that survived. The set and
+its divider positions persist across restarts (in `workspace.json`, keyed by each agent's durable
+persist-key so a skipped project folder drops one pane rather than shifting all of them).
+
+Panes are ordinary agent views moved between grid cells with `Grid.SetColumn`/`Grid.SetRow` —
+**never re-parented**, so every WebView (and its live transcript) survives the switch, which is the
+whole reason the tab surface is built the way it is (see below). The row and column tracks are
+rebuilt in code per pane count, interleaving a divider track between adjacent panes; track
+definitions and dividers are the only things that change, so no agent view ever leaves the tree.
+The geometry and divider math live in `Services/PaneLayout.cs`, kept free of WinUI types so they're
+unit tested directly.
+
+The split bar uses chips with `MenuFlyout` pickers rather than `ComboBox`es on purpose: rebuilding
+ComboBox items as containers makes WinUI throw `COMException 0x80070490` on the next selection.
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
@@ -189,13 +212,24 @@ within 24 hours.
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
+- Split view — the **Split** button shows two agents side by side, and **Add pane**
+ (or a tab's **Add to split view**) grows that to three across or four as a 2×2, every
+ divider draggable; the pane set is a remembered, explicit choice, so clicking
+ other tabs navigates without disturbing it, and it survives a restart
- 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
+- History cards show both ends of a conversation — the opening message (what it was
+ about, since rows are titled by agent name) and a dimmer **last ·** line with your
+ most recent message (whether it's worth resuming)
+- Full-text history search — the search box reads each archived conversation's whole
+ text, not just its title and preview, and quotes the matching line on the card so
+ every hit explains itself. Debounced and off the UI thread behind a per-session
+ cache, so typing never waits on file IO
+- Bulk cleanup — opening a project group of two or more in History or Snapshots
+ reveals a **Delete all *n*** button at the top of it, clearing the whole group
+ after one confirmation, batched into a single store write rather than one per item
- 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
diff --git a/src/MandoCode.Desktop.Tests/ConversationSearchTests.cs b/src/MandoCode.Desktop.Tests/ConversationSearchTests.cs
new file mode 100644
index 0000000..30930d7
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/ConversationSearchTests.cs
@@ -0,0 +1,171 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+/// Full-text matching and snippet extraction behind History's search.
+public class ConversationSearchTests
+{
+ // ---- Flatten -----------------------------------------------------------
+
+ [Fact]
+ public void Flatten_joins_turn_text()
+ {
+ var turns = new[]
+ {
+ new ConversationTurn("u", "how do the dividers work"),
+ new ConversationTurn("a", "each one repartitions its two panes"),
+ };
+ var text = ConversationSearch.Flatten(turns);
+ Assert.Contains("how do the dividers work", text);
+ Assert.Contains("each one repartitions its two panes", text);
+ }
+
+ [Fact]
+ public void Flatten_drops_the_role_markers()
+ {
+ // Otherwise searching "a" or "u" would hit every conversation ever recorded.
+ var turns = new[] { new ConversationTurn("u", "hello"), new ConversationTurn("a", "hi") };
+ Assert.Equal("hello\nhi", ConversationSearch.Flatten(turns));
+ }
+
+ [Fact]
+ public void Flatten_of_nothing_is_empty()
+ {
+ Assert.Equal("", ConversationSearch.Flatten(Array.Empty()));
+ }
+
+ // ---- Snippet: matching -------------------------------------------------
+
+ [Fact]
+ public void Snippet_is_null_when_the_query_is_absent()
+ {
+ Assert.Null(ConversationSearch.Snippet("nothing relevant here", "dividers"));
+ }
+
+ [Fact]
+ public void Snippet_matches_case_insensitively()
+ {
+ Assert.NotNull(ConversationSearch.Snippet("The Divider Math", "divider math"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void Snippet_is_null_for_empty_text(string? text)
+ {
+ Assert.Null(ConversationSearch.Snippet(text, "anything"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Snippet_is_null_for_a_blank_query(string? query)
+ {
+ Assert.Null(ConversationSearch.Snippet("some conversation text", query));
+ }
+
+ // ---- Snippet: the window -----------------------------------------------
+
+ [Fact]
+ public void Snippet_returns_the_whole_text_unellipsised_when_it_fits()
+ {
+ // Nothing was truncated, so it should read as a complete quote.
+ var snippet = ConversationSearch.Snippet("divider math", "divider", radius: 60);
+ Assert.Equal("divider math", snippet);
+ }
+
+ [Fact]
+ public void Snippet_ellipsises_only_the_ends_it_actually_truncated()
+ {
+ var text = new string('a', 200) + " NEEDLE " + new string('b', 200);
+
+ var snippet = ConversationSearch.Snippet(text, "NEEDLE", radius: 10);
+ Assert.NotNull(snippet);
+ Assert.StartsWith("…", snippet);
+ Assert.EndsWith("…", snippet);
+ Assert.Contains("NEEDLE", snippet);
+
+ // A hit at the very start has nothing to its left to elide.
+ var atStart = ConversationSearch.Snippet("NEEDLE" + new string('b', 200), "NEEDLE", radius: 10);
+ Assert.NotNull(atStart);
+ Assert.False(atStart!.StartsWith("…"), "no left truncation, so no leading ellipsis");
+ Assert.EndsWith("…", atStart);
+ }
+
+ [Fact]
+ public void Snippet_keeps_context_either_side_of_the_hit()
+ {
+ var snippet = ConversationSearch.Snippet("before the NEEDLE and after", "NEEDLE", radius: 6);
+ Assert.NotNull(snippet);
+ Assert.Contains("the NEEDLE and", snippet);
+ }
+
+ [Fact]
+ public void Snippet_windows_the_first_hit()
+ {
+ var text = "first NEEDLE here" + new string('x', 500) + "second NEEDLE there";
+ var snippet = ConversationSearch.Snippet(text, "NEEDLE", radius: 8);
+ Assert.NotNull(snippet);
+ Assert.Contains("first", snippet);
+ Assert.DoesNotContain("second", snippet);
+ }
+
+ // ---- Snippet: single-line output ---------------------------------------
+
+ [Fact]
+ public void Snippet_collapses_newlines_so_it_renders_on_one_line()
+ {
+ var snippet = ConversationSearch.Snippet("line one\nNEEDLE\nline three", "NEEDLE");
+ Assert.NotNull(snippet);
+ Assert.DoesNotContain("\n", snippet);
+ Assert.DoesNotContain("\r", snippet);
+ Assert.Equal("line one NEEDLE line three", snippet);
+ }
+
+ [Fact]
+ public void Snippet_collapses_whitespace_runs()
+ {
+ var snippet = ConversationSearch.Snippet("lots of\t\tspace NEEDLE", "NEEDLE");
+ Assert.Equal("lots of space NEEDLE", snippet);
+ }
+
+ [Fact]
+ public void Snippet_does_not_start_or_end_with_stray_space()
+ {
+ // The window can cut mid-whitespace; that shouldn't show up as a padded quote.
+ var snippet = ConversationSearch.Snippet("aaaa NEEDLE bbbb", "NEEDLE", radius: 4);
+ Assert.NotNull(snippet);
+ var inner = snippet!.Trim('…');
+ Assert.Equal(inner.Trim(), inner);
+ }
+
+ // ---- IsSearchable ------------------------------------------------------
+
+ [Theory]
+ [InlineData("ab")]
+ [InlineData("divider")]
+ public void IsSearchable_accepts_queries_worth_scanning_for(string query)
+ {
+ Assert.True(ConversationSearch.IsSearchable(query));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("a")]
+ public void IsSearchable_rejects_queries_too_short_to_narrow_anything(string? query)
+ {
+ // One character would match nearly every conversation — not worth reading 60 log files.
+ Assert.False(ConversationSearch.IsSearchable(query));
+ }
+
+ [Fact]
+ public void IsSearchable_ignores_surrounding_whitespace()
+ {
+ Assert.False(ConversationSearch.IsSearchable(" a "));
+ Assert.True(ConversationSearch.IsSearchable(" ab "));
+ }
+}
diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
index 119dc2f..975d03f 100644
--- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
+++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
@@ -40,9 +40,14 @@
+
+
+
diff --git a/src/MandoCode.Desktop.Tests/PaneLayoutTests.cs b/src/MandoCode.Desktop.Tests/PaneLayoutTests.cs
new file mode 100644
index 0000000..50fadce
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/PaneLayoutTests.cs
@@ -0,0 +1,207 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+/// Geometry and divider math for the compare view's 2–4 pane grid.
+public class PaneLayoutTests
+{
+ // ---- Shape -------------------------------------------------------------
+
+ [Theory]
+ [InlineData(2, 1, 2)] // side by side
+ [InlineData(3, 1, 3)] // across
+ [InlineData(4, 2, 2)] // wraps rather than shrinking to a fourth of the width
+ public void Shape_matches_the_pane_count(int count, int rows, int cols)
+ {
+ Assert.Equal((rows, cols), PaneLayout.Shape(count));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ public void Shape_collapses_to_a_single_cell_below_two_panes(int count)
+ {
+ // Single view has to be one */* cell so pages and the empty state fill it.
+ Assert.Equal((1, 1), PaneLayout.Shape(count));
+ }
+
+ // ---- Cell --------------------------------------------------------------
+
+ [Fact]
+ public void Cell_lays_two_panes_out_in_one_row()
+ {
+ Assert.Equal((0, 0), PaneLayout.Cell(0, 2));
+ Assert.Equal((0, 1), PaneLayout.Cell(1, 2));
+ }
+
+ [Fact]
+ public void Cell_lays_three_panes_out_in_one_row()
+ {
+ Assert.Equal((0, 0), PaneLayout.Cell(0, 3));
+ Assert.Equal((0, 1), PaneLayout.Cell(1, 3));
+ Assert.Equal((0, 2), PaneLayout.Cell(2, 3));
+ }
+
+ [Fact]
+ public void Cell_wraps_four_panes_into_a_two_by_two()
+ {
+ Assert.Equal((0, 0), PaneLayout.Cell(0, 4));
+ Assert.Equal((0, 1), PaneLayout.Cell(1, 4));
+ Assert.Equal((1, 0), PaneLayout.Cell(2, 4));
+ Assert.Equal((1, 1), PaneLayout.Cell(3, 4));
+ }
+
+ [Fact]
+ public void Cell_assigns_every_pane_a_distinct_cell()
+ {
+ for (int count = 2; count <= PaneLayout.MaxPanes; count++)
+ {
+ var cells = Enumerable.Range(0, count).Select(i => PaneLayout.Cell(i, count)).ToList();
+ Assert.Equal(count, cells.Distinct().Count());
+ }
+ }
+
+ [Fact]
+ public void Cell_stays_inside_the_shape_it_reports()
+ {
+ for (int count = 2; count <= PaneLayout.MaxPanes; count++)
+ {
+ var (rows, cols) = PaneLayout.Shape(count);
+ for (int i = 0; i < count; i++)
+ {
+ var (row, col) = PaneLayout.Cell(i, count);
+ Assert.InRange(row, 0, rows - 1);
+ Assert.InRange(col, 0, cols - 1);
+ }
+ }
+ }
+
+ // ---- EqualFractions / Fit ---------------------------------------------
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void EqualFractions_splits_the_axis_evenly_and_sums_to_one(int count)
+ {
+ var f = PaneLayout.EqualFractions(count);
+ Assert.Equal(count, f.Count);
+ Assert.Equal(1.0, f.Sum(), 10);
+ Assert.All(f, v => Assert.Equal(1.0 / count, v, 10));
+ }
+
+ [Fact]
+ public void Fit_keeps_dragged_positions_when_the_track_count_is_unchanged()
+ {
+ // The whole point: visiting Settings and coming back must not re-centre the divider.
+ var dragged = new List { 0.7, 0.3 };
+ Assert.Same(dragged, PaneLayout.Fit(dragged, 2));
+ }
+
+ [Fact]
+ public void Fit_resets_to_an_even_split_when_the_track_count_changes()
+ {
+ var dragged = new List { 0.7, 0.3 };
+ var fitted = PaneLayout.Fit(dragged, 3);
+ Assert.Equal(3, fitted.Count);
+ Assert.All(fitted, v => Assert.Equal(1.0 / 3, v, 10));
+ }
+
+ [Fact]
+ public void Fit_replaces_a_null_or_degenerate_list()
+ {
+ Assert.Equal(2, PaneLayout.Fit(null, 2).Count);
+ // A list of zeroes would collapse every pane, so it counts as unusable.
+ Assert.Equal(1.0, PaneLayout.Fit(new List { 0, 0 }, 2).Sum(), 10);
+ }
+
+ // ---- Repartition -------------------------------------------------------
+
+ [Fact]
+ public void Repartition_moves_the_divider_to_the_pointer()
+ {
+ var f = new List { 0.5, 0.5 };
+ PaneLayout.Repartition(f, 0, 0.3);
+ Assert.Equal(0.3, f[0], 10);
+ Assert.Equal(0.7, f[1], 10);
+ }
+
+ [Fact]
+ public void Repartition_preserves_the_total()
+ {
+ var f = new List { 1.0 / 3, 1.0 / 3, 1.0 / 3 };
+ PaneLayout.Repartition(f, 1, 0.5);
+ Assert.Equal(1.0, f.Sum(), 10);
+ }
+
+ [Fact]
+ public void Repartition_leaves_non_adjacent_panes_untouched()
+ {
+ // Dragging one divider must not nudge a pane further along the axis.
+ var f = new List { 0.2, 0.4, 0.4 };
+ PaneLayout.Repartition(f, 1, 0.4);
+ Assert.Equal(0.2, f[0], 10); // pane 0 is not adjacent to divider 1
+ Assert.Equal(0.8, f[1] + f[2], 10); // the adjacent pair keeps its combined share
+ }
+
+ [Fact]
+ public void Repartition_measures_the_pointer_from_the_axis_start_not_the_pair()
+ {
+ // Divider 1 sits at 0.2 + f[1]; a pointer at 0.5 should leave pane 1 with 0.3.
+ var f = new List { 0.2, 0.4, 0.4 };
+ PaneLayout.Repartition(f, 1, 0.5);
+ Assert.Equal(0.3, f[1], 10);
+ Assert.Equal(0.5, f[2], 10);
+ }
+
+ [Fact]
+ public void Repartition_clamps_so_neither_side_collapses()
+ {
+ var f = new List { 0.5, 0.5 };
+
+ PaneLayout.Repartition(f, 0, -5.0); // dragged far past the left edge
+ Assert.Equal(PaneLayout.MinFraction, f[0], 10);
+ Assert.Equal(1.0, f.Sum(), 10);
+
+ f = new List { 0.5, 0.5 };
+ PaneLayout.Repartition(f, 0, 5.0); // and far past the right edge
+ Assert.Equal(PaneLayout.MinFraction, f[1], 10);
+ Assert.Equal(1.0, f.Sum(), 10);
+ }
+
+ [Fact]
+ public void Repartition_honours_the_minimum_for_a_middle_divider()
+ {
+ var f = new List { 1.0 / 3, 1.0 / 3, 1.0 / 3 };
+ PaneLayout.Repartition(f, 1, 5.0);
+ Assert.Equal(PaneLayout.MinFraction, f[2], 10);
+ Assert.Equal(1.0 / 3, f[0], 10);
+ Assert.Equal(1.0, f.Sum(), 10);
+ }
+
+ [Fact]
+ public void Repartition_halves_a_pair_too_small_for_the_minimum()
+ {
+ // [Min, pair - Min] is empty here: clamping into it would throw, and pinning one side to
+ // Min would drive the other negative. Both sides get half the pair instead.
+ var f = new List { 0.9, 0.05, 0.05 };
+ PaneLayout.Repartition(f, 1, 0.95);
+ Assert.Equal(0.05, f[1], 10);
+ Assert.Equal(0.05, f[2], 10);
+ Assert.Equal(1.0, f.Sum(), 10);
+ Assert.All(f, v => Assert.True(v >= 0, "no track may go negative"));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(1)] // divider 1 needs a track at index 2
+ [InlineData(99)]
+ public void Repartition_ignores_an_out_of_range_divider(int index)
+ {
+ var f = new List { 0.5, 0.5 };
+ PaneLayout.Repartition(f, index, 0.3);
+ Assert.Equal(new List { 0.5, 0.5 }, f);
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/TerminalPanel.xaml b/src/MandoCode.Desktop/Controls/TerminalPanel.xaml
index 70b5d84..0de0bd5 100644
--- a/src/MandoCode.Desktop/Controls/TerminalPanel.xaml
+++ b/src/MandoCode.Desktop/Controls/TerminalPanel.xaml
@@ -23,8 +23,14 @@
-
+
+
diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs
index e1d0bca..945e9e9 100644
--- a/src/MandoCode.Desktop/MainWindow.History.cs
+++ b/src/MandoCode.Desktop/MainWindow.History.cs
@@ -9,6 +9,7 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
@@ -40,8 +41,9 @@ private void ArchiveClosedSession(AgentSession session)
return;
}
- var preview = turns.FirstOrDefault(t => t.R == "u")?.T?.Trim();
- if (preview is { Length: > 140 }) preview = preview[..140].TrimEnd() + "…";
+ // What it was about (first) and where it stopped (last) — both the user's own words.
+ var said = turns.Where(t => t.R == "u").ToList();
+ var preview = TrimForCard(said.FirstOrDefault()?.T);
_archive.Add(new SessionArchiveEntry
{
@@ -52,9 +54,32 @@ private void ArchiveClosedSession(AgentSession session)
ClosedAt = DateTimeOffset.Now,
TurnCount = turns.Count,
Preview = preview,
+ LastMessage = LastMessageFor(said, preview),
});
}
+ /// Card lines are capped at this; shared by the first- and last-message previews so the
+ /// two can't drift apart.
+ private const int CardPreviewChars = 140;
+
+ private static string? TrimForCard(string? text)
+ {
+ var trimmed = text?.Trim();
+ if (string.IsNullOrEmpty(trimmed)) return null;
+ return trimmed.Length > CardPreviewChars
+ ? trimmed[..CardPreviewChars].TrimEnd() + "…"
+ : trimmed;
+ }
+
+ /// The "where you left off" line for a set of user turns. Returns "" — meaning
+ /// "computed, nothing to show" — when there are no user turns, or when the last one IS the first
+ /// one, since a single-turn conversation would otherwise print the same quote twice.
+ private static string LastMessageFor(IReadOnlyList userTurns, string? preview)
+ {
+ var last = TrimForCard(userTurns.LastOrDefault()?.T);
+ return last == null || last == preview ? "" : last;
+ }
+
private void OnArchiveChanged()
{
if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
@@ -82,6 +107,27 @@ private void OpenHistory()
MarkHistorySeen(); // opening the panel IS reading it — clear the unread badge
PopulateHistory();
ShowLeftPanel(HistoryPanel, snapshots: false);
+ _ = BackfillHistoryLastMessagesAsync();
+ }
+
+ private bool _historyBackfillStarted;
+
+ /// Fills in for conversations archived
+ /// before it was recorded, so old cards carry the same "where you left off" line as new ones.
+ /// Deferred to the first History open rather than startup (it's up to 60 log reads), run off the
+ /// UI thread, and persisted in one write. The store's Changed event repopulates the panel.
+ private async Task BackfillHistoryLastMessagesAsync()
+ {
+ if (_historyBackfillStarted) return;
+ _historyBackfillStarted = true;
+
+ await Task.Run(() => _archive.BackfillLastMessages(entry =>
+ {
+ var said = ConversationLog.Load(entry.Key).Where(t => t.R == "u").ToList();
+ // Recompute the first line the same way too: comparing against the STORED preview would
+ // miss a single-turn conversation whose preview was trimmed under an older rule.
+ return LastMessageFor(said, TrimForCard(said.FirstOrDefault()?.T));
+ }));
}
/// Current text in the history search box; empty means "show everything".
@@ -106,18 +152,86 @@ private void HistoryGroup_Collapsed(Microsoft.UI.Xaml.Controls.Expander sender,
SavePanelState();
}
+ // ---- full-text search across archived conversations -------------------------
+ // The metadata match (title/project/model/preview) is instant and stays synchronous. Conversation
+ // BODIES live in per-session log files, so those are scanned off the UI thread behind a debounce
+ // and folded in when they land — typing never waits on file IO.
+
+ private readonly ConversationTextCache _historyText = new();
+ // Fully qualified: both Microsoft.UI.Dispatching and Windows.System are imported here, and both
+ // define DispatcherQueueTimer (same reason MainWindow.Terminal.cs qualifies its timer).
+ private Microsoft.UI.Dispatching.DispatcherQueueTimer? _historySearchDebounce;
+ private int _historySearchGeneration;
+
+ /// Persist-key → snippet, for rows whose CONTENT matched the current query. Replaced
+ /// wholesale by each completed scan; never merged, or a stale snippet from a previous query
+ /// would be shown against the new one.
+ private Dictionary _historyContentHits = new(StringComparer.OrdinalIgnoreCase);
+
private void HistorySearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
_historyFilter = sender.Text?.Trim() ?? "";
+
+ // Show metadata hits straight away; content hits widen the list a moment later.
+ _historyContentHits = new(StringComparer.OrdinalIgnoreCase);
+ PopulateHistory();
+ QueueHistoryContentSearch();
+ }
+
+ /// (Re)arms the debounce. The Tick handler is attached ONCE at creation — re-attaching
+ /// per keystroke would stack handlers and fire one scan per character typed.
+ private void QueueHistoryContentSearch()
+ {
+ if (_historySearchDebounce == null)
+ {
+ _historySearchDebounce = _dispatcher.CreateTimer();
+ _historySearchDebounce.IsRepeating = false;
+ _historySearchDebounce.Interval = TimeSpan.FromMilliseconds(220);
+ _historySearchDebounce.Tick += (_, _) => _ = RunHistoryContentSearchAsync();
+ }
+
+ _historySearchDebounce.Stop();
+ if (!ConversationSearch.IsSearchable(_historyFilter)) return;
+ _historySearchDebounce.Start();
+ }
+
+ /// Scans every archived conversation's text for the current query on a background
+ /// thread. Results are stamped with a generation so a slower earlier scan can't overwrite a
+ /// later keystroke's answer.
+ private async Task RunHistoryContentSearchAsync()
+ {
+ var query = _historyFilter;
+ if (!ConversationSearch.IsSearchable(query)) return;
+
+ var generation = ++_historySearchGeneration;
+ var keys = _archive.Items.Select(e => e.Key).ToList();
+ var cache = _historyText;
+
+ var hits = await Task.Run(() =>
+ {
+ var found = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var key in keys)
+ if (ConversationSearch.Snippet(cache.TextFor(key), query) is { } snippet)
+ found[key] = snippet;
+ return found;
+ });
+
+ // Superseded — a newer keystroke started its own scan while this one was reading.
+ if (generation != _historySearchGeneration || query != _historyFilter) return;
+
+ _historyContentHits = hits;
PopulateHistory();
}
- private static bool Matches(SessionArchiveEntry s, string q) =>
+ /// Metadata match plus a content hit from the latest completed scan. Not static: the
+ /// content hits are per-window state.
+ private 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);
+ || (s.Preview?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
+ || _historyContentHits.ContainsKey(s.Key);
private void PopulateHistory()
{
@@ -128,6 +242,12 @@ private void PopulateHistory()
var q = _historyFilter;
var filtered = string.IsNullOrEmpty(q) ? all : all.Where(s => Matches(s, q)).ToList();
+ // Stamp the snippet onto EVERY row, not just the matches, so a snippet from a previous query
+ // can't linger on a row the new query matched by title. Read once by a OneTime x:Bind —
+ // ItemsSource is reassigned below, so the templates always re-bind.
+ foreach (var entry in all)
+ entry.MatchSnippet = _historyContentHits.TryGetValue(entry.Key, out var snippet) ? snippet : null;
+
// Group by project (freshest project first), newest-first within each, carrying remembered
// collapse state — same shape as the Snapshots panel.
var groups = filtered
@@ -191,6 +311,38 @@ 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
+ _historyText.Forget(new[] { entry.Key }); // its searchable text is gone too
+ PopulateHistory();
+ }
+
+ /// Deletes every conversation in one project group at once. The group holds exactly what
+ /// the panel is SHOWING, so with a search active this deletes only the matches — the prompt says
+ /// so rather than claiming "all". One batched store call, so the panel rebuilds once.
+ private async void HistoryDeleteGroup_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not HistoryGroup group || group.Count == 0) return;
+
+ // Detach before the await: an agent closing mid-dialog repopulates the panel and replaces
+ // the group objects. Keys stay valid, and RemoveAll ignores any that are already gone.
+ var keys = group.Select(s => s.Key).ToList();
+ var project = group.Project;
+ var noun = keys.Count == 1 ? "conversation" : "conversations";
+ var scope = string.IsNullOrEmpty(_historyFilter) ? "" : $" matching “{_historyFilter}”";
+
+ var dialog = new ContentDialog
+ {
+ Title = $"Delete {noun}",
+ Content = $"Delete {keys.Count} {noun}{scope} in “{project}”? "
+ + "Their transcripts and memory are removed from disk and this can't be undone.",
+ PrimaryButtonText = $"Delete {keys.Count}",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = Content.XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ _archive.RemoveAll(keys, deleteFiles: true);
+ _historyText.Forget(keys);
PopulateHistory();
}
@@ -325,6 +477,32 @@ private void WireHeader(ChatTabEntry entry)
var menu = new MenuFlyout();
+ // Right-click anywhere on the tab opens the SAME menu instance rather than a second one
+ // attached as ContextFlyout — one MenuFlyout can't be parented in two places.
+ entry.Header.RightTapped += (_, e) =>
+ {
+ e.Handled = true;
+ menu.ShowAt(entry.Header, new FlyoutShowOptions { Position = e.GetPosition(entry.Header) });
+ };
+
+ // Split-view membership: the one item whose meaning depends on state, so its text and
+ // enabled-ness are refreshed each time the menu opens. Deferred to the next dispatcher tick
+ // like every other split mutation — it restructures the visual tree.
+ var splitItem = new MenuFlyoutItem { Icon = new FontIcon { Glyph = "" } }; // split panes
+ splitItem.Click += (_, _) => DispatcherQueue.TryEnqueue(() =>
+ {
+ if (!_tabs.Contains(entry)) return;
+ if (_splitPanes.Any(p => ReferenceEquals(p, entry))) RemovePane(entry);
+ else AddPane(entry);
+ });
+ menu.Opening += (_, _) =>
+ {
+ var paned = _splitPanes.Any(p => ReferenceEquals(p, entry));
+ splitItem.Text = paned ? "Remove from split view" : "Add to split view";
+ // Adding needs another agent to compare against and a free pane slot.
+ splitItem.IsEnabled = paned || (_tabs.Count >= 2 && _splitPanes.Count < MaxSplitPanes);
+ };
+
var rename = new MenuFlyoutItem { Text = "Rename…", Icon = new FontIcon { Glyph = "" } };
rename.Click += (_, _) => _ = RenameTabAsync(entry);
@@ -337,6 +515,8 @@ private void WireHeader(ChatTabEntry entry)
var close = new MenuFlyoutItem { Text = "Close agent", Icon = new FontIcon { Glyph = "" } };
close.Click += (_, _) => CloseTab(entry);
+ menu.Items.Add(splitItem);
+ menu.Items.Add(new MenuFlyoutSeparator());
menu.Items.Add(rename);
menu.Items.Add(snapshot);
menu.Items.Add(export);
@@ -380,7 +560,7 @@ 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
+ // Selecting a tab NEVER changes the pane set — 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);
diff --git a/src/MandoCode.Desktop/MainWindow.Navigation.cs b/src/MandoCode.Desktop/MainWindow.Navigation.cs
index 4c74797..675da2b 100644
--- a/src/MandoCode.Desktop/MainWindow.Navigation.cs
+++ b/src/MandoCode.Desktop/MainWindow.Navigation.cs
@@ -64,7 +64,7 @@ private void SwitchPage(string page)
// 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).
+ // mode 2–4 views show at once (the pane set, _splitPanes, in pane order).
ApplyPaneLayout();
// The empty-state background shows only on the chat page with no agents left.
diff --git a/src/MandoCode.Desktop/MainWindow.Snapshots.cs b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
index 39f9af1..0468801 100644
--- a/src/MandoCode.Desktop/MainWindow.Snapshots.cs
+++ b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
@@ -239,4 +239,33 @@ private void SnapshotDelete_Click(object sender, RoutedEventArgs e)
PopulateSnapshots();
}
+ /// Deletes every snapshot in one project group at once. The group holds exactly what the
+ /// panel is SHOWING, so with a search active this deletes only the matches — the prompt says so
+ /// rather than claiming "all". One batched store call, so the panel rebuilds once.
+ private async void SnapshotDeleteGroup_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not SnapshotGroup group || group.Count == 0) return;
+
+ // Detach before the await: a capture landing mid-dialog repopulates the panel and replaces
+ // the group objects. RemoveAll matches on Id, so a stale copy still removes the right rows.
+ var targets = group.ToList();
+ var project = group.Project;
+ var noun = targets.Count == 1 ? "snapshot" : "snapshots";
+ var scope = string.IsNullOrEmpty(_snapshotFilter) ? "" : $" matching “{_snapshotFilter}”";
+
+ var dialog = new ContentDialog
+ {
+ Title = $"Delete {noun}",
+ Content = $"Delete {targets.Count} {noun}{scope} in “{project}”? This can't be undone.",
+ PrimaryButtonText = $"Delete {targets.Count}",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = Content.XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ _snapshotStore.RemoveAll(targets);
+ PopulateSnapshots();
+ }
+
}
diff --git a/src/MandoCode.Desktop/MainWindow.Split.cs b/src/MandoCode.Desktop/MainWindow.Split.cs
index c42b553..bab512e 100644
--- a/src/MandoCode.Desktop/MainWindow.Split.cs
+++ b/src/MandoCode.Desktop/MainWindow.Split.cs
@@ -22,199 +22,524 @@ 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.
+ // Split view — 2 to 4 agents at once. The PANE SET (_splitPanes, in pane order)
+ // is a remembered, explicit choice: set only by the Split button, the split bar's chips, and the
+ // tab menu's "Add to split view" — NEVER by plain-clicking a tab. The split is shown whenever the
+ // active tab (_selected) is one of the paned agents; clicking any other tab shows that agent
+ // normally while the set waits, and clicking a paned tab brings the split back. Panes are
+ // ordinary tab views moved between grid cells by 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;
+ // Grid geometry and the divider math live in PaneLayout (pure, unit-tested); this file owns the
+ // visual-tree side — building tracks, creating dividers, moving views between cells.
+ private const int MaxSplitPanes = PaneLayout.MaxPanes;
- /// 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);
+ private readonly List _splitPanes = new();
- /// 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).
+ // Divider positions as star fractions — one entry per pane COLUMN and per pane ROW. Reset to
+ // equal when the layout shape changes; otherwise preserved across page visits and restarts.
+ private List _colFractions = new();
+ private List _rowFractions = new();
+
+ // Dividers are rebuilt with the tracks; held so the next rebuild can remove the old ones.
+ private readonly List _paneGrips = new();
+
+ private bool _syncingSplitBar;
+ private bool _draggingPaneGrip;
+
+ /// A valid pane set is configured: 2–4 distinct agents, all still open.
+ private bool SplitConfigured =>
+ _splitPanes.Count >= 2
+ && _splitPanes.Count <= MaxSplitPanes
+ && _splitPanes.All(p => _tabs.Contains(p))
+ && _splitPanes.Distinct().Count() == _splitPanes.Count;
+
+ /// The split is actually being shown right now: a set exists, we're on the chat page,
+ /// and the active tab is one of the paned agents (clicking any other agent shows it single).
private bool SplitActive =>
- HasComparePair && _currentPage == "chat" && _selected != null
- && (ReferenceEquals(_selected, _compareA) || ReferenceEquals(_selected, _compareB));
+ SplitConfigured && _currentPage == "chat" && _selected != null
+ && _splitPanes.Any(p => ReferenceEquals(p, _selected));
private void SplitButton_Click(object sender, RoutedEventArgs e)
{
- if (HasComparePair)
+ if (SplitConfigured)
{
- // Toggle: showing the split → turn compare off; pair configured but viewing another
+ // Toggle: showing the split → turn the split off; set configured but viewing another
// agent → jump back into the split.
if (SplitActive) ExitSplit();
- else if (_compareA != null) SelectTab(_compareA);
+ else SelectTab(_splitPanes[0]);
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
+ var other = _tabs.FirstOrDefault(t => !ReferenceEquals(t, _selected));
+ if (other == null) return;
+
+ _splitPanes.Clear();
+ _splitPanes.Add(_selected);
+ _splitPanes.Add(other);
+ ResetPaneFractions();
+ RefreshSplitBar();
+ SwitchPage("chat"); // _selected is in the set → ApplyPaneLayout shows the split
RefreshSplitButton();
+ SaveWorkspace();
}
private void ExitSplit_Click(object sender, RoutedEventArgs e) => ExitSplit();
private void ExitSplit()
{
- _compareA = null;
- _compareB = null;
+ _splitPanes.Clear();
+ ResetPaneFractions();
ApplyPaneLayout();
RefreshSplitButton();
+ SaveWorkspace();
}
- /// 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.
+ /// Primary click: pane the next agent that isn't shown yet. The chevron's menu
+ /// (built by ) picks a specific one instead.
+ private void AddPane_Click(SplitButton sender, SplitButtonClickEventArgs args)
+ {
+ var next = _tabs.FirstOrDefault(t => !_splitPanes.Contains(t));
+ if (next != null) AddPane(next);
+ }
+
+ /// Appends an agent as a new pane, up to . Called by the
+ /// split bar's Add button and by a tab's "Add to split view" — including from single view, where
+ /// the active agent takes the first slot so there's something to compare against.
+ private void AddPane(ChatTabEntry entry)
+ {
+ if (_splitPanes.Count >= MaxSplitPanes) return;
+ if (_splitPanes.Any(p => ReferenceEquals(p, entry))) return;
+
+ if (_splitPanes.Count == 0)
+ {
+ var partner = _selected != null && !ReferenceEquals(_selected, entry)
+ ? _selected
+ : _tabs.FirstOrDefault(t => !ReferenceEquals(t, entry));
+ if (partner == null) return; // only one agent open — nothing to compare it with
+ _splitPanes.Add(partner);
+ }
+
+ _splitPanes.Add(entry);
+ ResetPaneFractions();
+ RefreshSplitBar();
+ SelectTab(entry); // the new pane becomes active, so the split stays on screen
+ RefreshSplitButton();
+ SaveWorkspace();
+ }
+
+ /// Drops a pane. Falling to a single pane isn't a layout — it turns the split off and
+ /// leaves you on the agent that survived.
+ private void RemovePane(ChatTabEntry entry)
+ {
+ var idx = _splitPanes.FindIndex(p => ReferenceEquals(p, entry));
+ if (idx < 0) return;
+ _splitPanes.RemoveAt(idx);
+
+ if (_splitPanes.Count < 2)
+ {
+ var survivor = _splitPanes.FirstOrDefault();
+ _splitPanes.Clear();
+ ResetPaneFractions();
+ if (survivor != null) SelectTab(survivor);
+ else ApplyPaneLayout();
+ RefreshSplitButton();
+ SaveWorkspace();
+ return;
+ }
+
+ ResetPaneFractions();
+ RefreshSplitBar();
+ // Closing the pane you were focused on hands the focus to a pane that's still shown.
+ if (ReferenceEquals(_selected, entry))
+ SelectTab(_splitPanes[Math.Min(idx, _splitPanes.Count - 1)]);
+ else
+ ApplyPaneLayout();
+ RefreshSplitButton();
+ SaveWorkspace();
+ }
+
+ /// Re-points one pane at a different agent. Choosing an agent that already occupies
+ /// another pane swaps the two, which is what the old two-combo bar did.
+ private void SetPane(int paneIndex, ChatTabEntry entry)
+ {
+ if (paneIndex < 0 || paneIndex >= _splitPanes.Count) return;
+ if (!_tabs.Contains(entry)) return;
+ if (ReferenceEquals(_splitPanes[paneIndex], entry)) return;
+
+ var existing = _splitPanes.FindIndex(p => ReferenceEquals(p, entry));
+ if (existing >= 0) _splitPanes[existing] = _splitPanes[paneIndex];
+ _splitPanes[paneIndex] = entry;
+
+ RefreshSplitBar();
+ SelectTab(entry); // keep the split on screen
+ RefreshSplitButton();
+ SaveWorkspace();
+ }
+
+ /// Places the visible agent view(s) into pane cells and sizes the tracks. Single view
+ /// collapses to one */* cell, so pages and the empty state fill it without knowing about panes.
+ /// Setting Grid.Row/Grid.Column does NOT reparent, so WebViews are untouched.
private void ApplyPaneLayout()
{
var split = SplitActive;
var showingChat = _currentPage == "chat";
+ int count = split ? _splitPanes.Count : 1;
+ var (rows, cols) = PaneLayout.Shape(count);
+
+ BuildPaneTracks(rows, cols);
foreach (var tab in _tabs)
{
- bool inPair = ReferenceEquals(tab, _compareA) || ReferenceEquals(tab, _compareB);
- var visible = showingChat && (split ? inPair : ReferenceEquals(tab, _selected));
+ int pane = split ? _splitPanes.FindIndex(p => ReferenceEquals(p, tab)) : -1;
+ var visible = showingChat && (split ? pane >= 0 : ReferenceEquals(tab, _selected));
tab.View.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
- Grid.SetColumn(tab.View, split && ReferenceEquals(tab, _compareB) ? 2 : 0);
+
+ // Tracks interleave a divider between panes, so pane (r,c) lives at row 2r / column 2c.
+ var (row, col) = pane >= 0 ? PaneLayout.Cell(pane, count) : (0, 0);
+ Grid.SetRow(tab.View, row * 2);
+ Grid.SetColumn(tab.View, col * 2);
+ }
+
+ SplitBar.Visibility = split ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ /// Rebuilds TabHost's tracks for a pane grid of the given shape and recreates the
+ /// dividers. Track layout is pane, divider, pane, … — 2c-1 columns and 2r-1 rows. Only track
+ /// definitions and divider elements change here; agent views are never removed from the tree,
+ /// so no WebView is torn down.
+ private void BuildPaneTracks(int rows, int cols)
+ {
+ // Single view must NOT touch the fraction lists: they're the remembered divider positions,
+ // and they have to survive visiting Settings or clicking a non-paned agent and coming back.
+ bool paned = rows * cols > 1;
+ if (paned) EnsureFractions(rows, cols);
+
+ foreach (var grip in _paneGrips) TabHost.Children.Remove(grip);
+ _paneGrips.Clear();
+
+ TabHost.ColumnDefinitions.Clear();
+ for (int c = 0; c < cols; c++)
+ {
+ TabHost.ColumnDefinitions.Add(new ColumnDefinition
+ {
+ Width = new GridLength(paned ? _colFractions[c] : 1, GridUnitType.Star)
+ });
+ if (c < cols - 1)
+ TabHost.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ }
+
+ TabHost.RowDefinitions.Clear();
+ for (int r = 0; r < rows; r++)
+ {
+ TabHost.RowDefinitions.Add(new RowDefinition
+ {
+ Height = new GridLength(paned ? _rowFractions[r] : 1, GridUnitType.Star)
+ });
+ if (r < rows - 1)
+ TabHost.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
}
- if (split)
+ // A column divider spans every row and vice versa, so the 2×2 keeps a single cross of
+ // dividers rather than four independent stubs.
+ int trackRows = Math.Max(1, rows * 2 - 1);
+ int trackCols = Math.Max(1, cols * 2 - 1);
+ for (int c = 0; c < cols - 1; c++) AddPaneGrip(vertical: true, c, 2 * c + 1, trackRows);
+ for (int r = 0; r < rows - 1; r++) AddPaneGrip(vertical: false, r, 2 * r + 1, trackCols);
+ }
+
+ /// Creates one divider. goes in Tag — it's the fraction-list
+ /// slot the drag repartitions.
+ private void AddPaneGrip(bool vertical, int index, int track, int span)
+ {
+ var grip = new Controls.ResizeGrip
+ {
+ // A Vertical grip bar resizes horizontally (↔) — it's the one that sits between columns.
+ GripOrientation = vertical ? Orientation.Vertical : Orientation.Horizontal,
+ Background = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"],
+ Tag = index,
+ };
+
+ if (vertical)
{
- 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;
+ grip.Width = 6;
+ grip.VerticalAlignment = VerticalAlignment.Stretch;
+ Grid.SetColumn(grip, track);
+ Grid.SetRow(grip, 0);
+ Grid.SetRowSpan(grip, span);
+ grip.PointerPressed += PaneGrip_PointerPressed;
+ grip.PointerMoved += PaneColumnGrip_PointerMoved;
+ grip.PointerReleased += PaneGrip_PointerReleased;
}
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;
+ grip.Height = 6;
+ grip.HorizontalAlignment = HorizontalAlignment.Stretch;
+ Grid.SetRow(grip, track);
+ Grid.SetColumn(grip, 0);
+ Grid.SetColumnSpan(grip, span);
+ grip.PointerPressed += PaneGrip_PointerPressed;
+ grip.PointerMoved += PaneRowGrip_PointerMoved;
+ grip.PointerReleased += PaneGrip_PointerReleased;
}
+
+ TabHost.Children.Add(grip);
+ _paneGrips.Add(grip);
+ }
+
+ /// Keeps the fraction lists matching the layout shape. A shape change (pane added or
+ /// removed) resets to equal splits; an unchanged shape keeps whatever the user dragged.
+ private void EnsureFractions(int rows, int cols)
+ {
+ _colFractions = PaneLayout.Fit(_colFractions, cols);
+ _rowFractions = PaneLayout.Fit(_rowFractions, rows);
}
- /// 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()
+ private void ResetPaneFractions()
{
- _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;
+ _colFractions.Clear();
+ _rowFractions.Clear();
}
- // 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)
+ /// Rebuilds one chip per pane. Each chip's dropdown re-points that pane; its × drops
+ /// the pane. Chips are MenuFlyout-based and deliberately NOT ComboBoxes: adding containers
+ /// directly as ComboBox items and rebuilding them makes WinUI throw COMException 0x80070490
+ /// "Element not found" on the next selection, which is what the old two-picker bar had to work
+ /// around.
+ private void RefreshSplitBar()
{
- if (_syncingSplitCombos) return;
- var idx = SplitLeftCombo.SelectedIndex;
- if (idx < 0 || idx >= _tabs.Count) return;
- var entry = _tabs[idx];
- DispatcherQueue.TryEnqueue(() =>
+ if (_syncingSplitBar) return;
+ _syncingSplitBar = true;
+ try
{
- 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
- });
+ PaneChips.Children.Clear();
+ for (int i = 0; i < _splitPanes.Count; i++)
+ PaneChips.Children.Add(BuildPaneChip(i, _splitPanes[i]));
+
+ // Add-pane picker: only agents that aren't already shown — an agent can't occupy two
+ // panes, so listing one would be a no-op. Deferred a tick like every other split
+ // mutation (it restructures the visual tree and rebuilds this bar).
+ AddPaneMenu.Items.Clear();
+ foreach (var tab in _tabs.Where(t => !_splitPanes.Contains(t)))
+ {
+ var target = tab;
+ var item = new MenuFlyoutItem { Text = target.View.Session.Title };
+ item.Click += (_, _) => DispatcherQueue.TryEnqueue(() => AddPane(target));
+ AddPaneMenu.Items.Add(item);
+ }
+
+ AddPaneButton.IsEnabled = _splitPanes.Count < MaxSplitPanes && AddPaneMenu.Items.Count > 0;
+
+ var (rows, cols) = PaneLayout.Shape(_splitPanes.Count);
+ PaneLayoutHint.Text = _splitPanes.Count < 2 ? ""
+ : rows == 1 ? $"{cols} across"
+ : $"{rows}×{cols} grid";
+ }
+ finally { _syncingSplitBar = false; }
}
- private void SplitRightCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ /// One pane chip: position, agent name, a picker, and a remove button. Both menu
+ /// actions are deferred to the next dispatcher tick — the reaction restructures the visual tree
+ /// (moves a ChatTabView between grid cells) and rebuilds this bar, neither of which is legal
+ /// from inside a flyout's click handler.
+ private Border BuildPaneChip(int index, ChatTabEntry pane)
{
- if (_syncingSplitCombos) return;
- var idx = SplitRightCombo.SelectedIndex;
- if (idx < 0 || idx >= _tabs.Count) return;
- var entry = _tabs[idx];
- DispatcherQueue.TryEnqueue(() =>
+ var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["MandoBorderBrush"];
+ var dim = (SolidColorBrush)Application.Current.Resources["MandoDimBrush"];
+ var isActive = ReferenceEquals(pane, _selected);
+
+ var ordinal = new TextBlock
{
- 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
- });
+ Text = (index + 1).ToString(),
+ FontSize = 10,
+ Opacity = 0.5,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ var label = new TextBlock
+ {
+ Text = pane.View.Session.Title,
+ FontSize = 12,
+ MaxWidth = 150,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = isActive ? accent : dim,
+ };
+
+ var picker = new Button
+ {
+ Padding = new Thickness(2),
+ Background = new SolidColorBrush(Colors.Transparent),
+ BorderThickness = new Thickness(0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Content = new FontIcon { Glyph = "", FontSize = 9 }, // ChevronDown
+ };
+ ToolTipService.SetToolTip(picker, "Show a different agent in this pane");
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(picker, $"Pane {index + 1} agent");
+
+ var menu = new MenuFlyout();
+ foreach (var tab in _tabs)
+ {
+ var target = tab;
+ var item = new MenuFlyoutItem { Text = target.View.Session.Title };
+ if (ReferenceEquals(target, pane))
+ item.Icon = new FontIcon { Glyph = "" }; // check — the current occupant
+ item.Click += (_, _) => DispatcherQueue.TryEnqueue(() => SetPane(index, target));
+ menu.Items.Add(item);
+ }
+ picker.Flyout = menu;
+
+ var remove = new Button
+ {
+ Padding = new Thickness(2),
+ Background = new SolidColorBrush(Colors.Transparent),
+ BorderThickness = new Thickness(0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Content = new FontIcon { Glyph = "", FontSize = 9 }, // close
+ };
+ ToolTipService.SetToolTip(remove, "Remove this pane");
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(remove, $"Remove pane {index + 1}");
+ remove.Click += (_, _) => DispatcherQueue.TryEnqueue(() => RemovePane(pane));
+
+ var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+ row.Children.Add(ordinal);
+ row.Children.Add(label);
+ row.Children.Add(picker);
+ row.Children.Add(remove);
+
+ return new Border
+ {
+ Child = row,
+ Padding = new Thickness(9, 3, 5, 3),
+ CornerRadius = new CornerRadius(12),
+ BorderThickness = new Thickness(1),
+ // The active pane is outlined, matching how the tab strip marks the selected agent.
+ BorderBrush = isActive ? accent : border,
+ Background = new SolidColorBrush(Colors.Transparent),
+ };
}
- /// 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.
+ /// Keeps the pane set valid after the agent set changes. Panes whose agent was
+ /// closed drop out; falling below two panes turns the split off entirely.
private void ValidateSplit()
{
- if (_compareA == null && _compareB == null) return; // no compare configured
- if (!HasComparePair)
+ if (_splitPanes.Count == 0) return; // no split configured
+
+ _splitPanes.RemoveAll(p => !_tabs.Contains(p));
+ if (_splitPanes.Count < 2)
{
- _compareA = null;
- _compareB = null;
+ _splitPanes.Clear();
+ ResetPaneFractions();
ApplyPaneLayout();
RefreshSplitButton();
return;
}
- RefreshSplitCombos();
+ RefreshSplitBar();
+ ApplyPaneLayout();
+ RefreshSplitButton();
+ }
+
+ /// Re-establishes a saved pane set once the tabs exist. Panes are matched by
+ /// persist-key, not index, so tabs skipped at restore (project folder gone) simply drop out of
+ /// the set instead of shifting every other pane.
+ private void RestoreSplitLayout(WorkspaceShape shape)
+ {
+ if (shape.SplitPanes is not { Count: >= 2 }) return;
+
+ _splitPanes.Clear();
+ foreach (var key in shape.SplitPanes)
+ {
+ if (_splitPanes.Count >= MaxSplitPanes) break;
+ var tab = _tabs.FirstOrDefault(t => t.View.Session.PersistKey == key);
+ if (tab != null && !_splitPanes.Contains(tab)) _splitPanes.Add(tab);
+ }
+ if (_splitPanes.Count < 2)
+ {
+ _splitPanes.Clear();
+ return;
+ }
+
+ // Saved divider positions only apply if they still describe this shape.
+ var (rows, cols) = PaneLayout.Shape(_splitPanes.Count);
+ if (shape.PaneColumnFractions is { } cf && cf.Count == cols && cf.Sum() > 0)
+ _colFractions = new List(cf);
+ if (shape.PaneRowFractions is { } rf && rf.Count == rows && rf.Sum() > 0)
+ _rowFractions = new List(rf);
+
+ RefreshSplitBar();
ApplyPaneLayout();
RefreshSplitButton();
}
private void RefreshSplitButton()
{
- SplitButton.IsEnabled = HasComparePair || _tabs.Count >= 2;
+ SplitButton.IsEnabled = SplitConfigured || _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;
+ // Accent whenever a split is configured — even while viewing a non-paned agent — so
+ // it reads as "split view is on; click a paned tab (or me) to see it."
+ SplitButtonIcon.Foreground = SplitConfigured ? accent : normal;
}
- // ---- divider drag: repartition the two panes' star widths by pointer X over TabHost ----
- private void PaneSplitter_PointerPressed(object sender, PointerRoutedEventArgs e)
+ // ---- divider drag ----------------------------------------------------------
+ // Each divider repartitions ONLY the two panes either side of it: their combined fraction is
+ // held constant, so dragging one divider never nudges a pane further along the axis.
+
+ private void PaneGrip_PointerPressed(object sender, PointerRoutedEventArgs e)
{
- _draggingPane = true;
+ _draggingPaneGrip = true;
((UIElement)sender).CapturePointer(e.Pointer);
}
- private void PaneSplitter_PointerMoved(object sender, PointerRoutedEventArgs e)
+ private void PaneColumnGrip_PointerMoved(object sender, PointerRoutedEventArgs e)
{
- if (!_draggingPane) return;
- var w = TabHost.ActualWidth;
+ if (!_draggingPaneGrip) return;
+ if (sender is not FrameworkElement { Tag: int i }) return;
+ double 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);
+
+ PaneLayout.Repartition(_colFractions, i, e.GetCurrentPoint(TabHost).Position.X / w);
+ ApplyPaneTrackSizes();
+ }
+
+ private void PaneRowGrip_PointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (!_draggingPaneGrip) return;
+ if (sender is not FrameworkElement { Tag: int i }) return;
+ double h = TabHost.ActualHeight;
+ if (h <= 0) return;
+
+ PaneLayout.Repartition(_rowFractions, i, e.GetCurrentPoint(TabHost).Position.Y / h);
+ ApplyPaneTrackSizes();
}
- private void PaneSplitter_PointerReleased(object sender, PointerRoutedEventArgs e)
+ private void PaneGrip_PointerReleased(object sender, PointerRoutedEventArgs e)
{
- if (!_draggingPane) return;
- _draggingPane = false;
+ if (!_draggingPaneGrip) return;
+ _draggingPaneGrip = false;
((UIElement)sender).ReleasePointerCapture(e.Pointer);
+ SaveWorkspace(); // divider positions are part of the remembered layout
+ }
+
+ /// Pushes the current fractions onto the existing tracks — no track rebuild and no
+ /// divider recreation, so it's cheap enough to run on every pointer move.
+ private void ApplyPaneTrackSizes()
+ {
+ for (int c = 0; c < _colFractions.Count; c++)
+ {
+ int track = c * 2;
+ if (track < TabHost.ColumnDefinitions.Count)
+ TabHost.ColumnDefinitions[track].Width = new GridLength(_colFractions[c], GridUnitType.Star);
+ }
+ for (int r = 0; r < _rowFractions.Count; r++)
+ {
+ int track = r * 2;
+ if (track < TabHost.RowDefinitions.Count)
+ TabHost.RowDefinitions[track].Height = new GridLength(_rowFractions[r], GridUnitType.Star);
+ }
}
/// Paints the custom chat background image behind the empty state, so closing every
@@ -293,6 +618,8 @@ private void RefreshTabStrip()
RefreshNavIcons();
RefreshSplitButton();
+ // Chips outline the active pane and carry agent titles, so they follow selection and renames.
+ if (SplitConfigured) RefreshSplitBar();
LayoutTabStrip();
}
diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs
index 93e037d..8c09a04 100644
--- a/src/MandoCode.Desktop/MainWindow.Tabs.cs
+++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs
@@ -88,7 +88,7 @@ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = n
SelectTab(entry);
if (_snapshotsPanelOpen) PopulateSnapshots(); // an agent exists now → re-enable Import
- if (HasComparePair) RefreshSplitCombos(); // include the new agent in the pane pickers
+ if (SplitConfigured) RefreshSplitBar(); // offer 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
index 8ae9d72..20092eb 100644
--- a/src/MandoCode.Desktop/MainWindow.Terminal.cs
+++ b/src/MandoCode.Desktop/MainWindow.Terminal.cs
@@ -239,7 +239,16 @@ private void SaveWorkspace()
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));
+
+ // The split layout rides along: paned agents (by persist-key) plus the divider positions.
+ // Null when no split is configured, which restores as plain single view.
+ var panes = SplitConfigured
+ ? _splitPanes.Select(p => p.View.Session.PersistKey).ToList()
+ : null;
+ WorkspaceState.Save(new WorkspaceShape(
+ tabs, active, panes,
+ panes == null ? null : new List(_colFractions),
+ panes == null ? null : new List(_rowFractions)));
}
}
diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
index 0c3ed99..cb2ce79 100644
--- a/src/MandoCode.Desktop/MainWindow.ViewModels.cs
+++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
@@ -52,6 +52,16 @@ public SnapshotGroup(string project, IEnumerable items
/// 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;
+
+ /// Label for the group's "delete everything shown here" button. Computed here rather
+ /// than assembled in XAML so the count is exact; a OneTime binding is always current because the
+ /// groups are rebuilt on every panel populate.
+ public string DeleteAllLabel => $"Delete all {Count}";
+
+ /// The group action only earns its space once there's more than one item — with a single
+ /// card, that card's own Delete button already does the same job. Bound as Visibility rather than
+ /// a bool because x:Bind does no implicit bool-to-Visibility conversion.
+ public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
}
/// A project's closed conversations, as one collapsible group in the History panel —
@@ -64,6 +74,26 @@ public HistoryGroup(string project, IEnumerable it
public string Project { get; }
public bool IsExpanded { get; set; } = true;
+
+ /// See .
+ public string DeleteAllLabel => $"Delete all {Count}";
+
+ /// See .
+ public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
+}
+
+///
+/// x:Bind function-binding helpers. These exist so bool/string→ logic can
+/// stay OUT of the persisted service models: `SessionArchiveStore.cs` and friends are compiled into
+/// the WinUI-free test project, so a Visibility property on them would break that build. Cheaper
+/// than a converter registered in resources, and readable at the binding site.
+///
+public static class Vis
+{
+ public static Visibility When(bool condition) => condition ? Visibility.Visible : Visibility.Collapsed;
+
+ public static Visibility WhenText(string? text) =>
+ string.IsNullOrEmpty(text) ? Visibility.Collapsed : Visibility.Visible;
}
/// Row model for diff lines shown in the approval overlay.
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index 444dcc3..a12f4d5 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -1,4 +1,4 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -310,58 +329,100 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -443,10 +504,11 @@
scroller, so they never scroll out of reach when many agents are open. -->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -498,25 +602,19 @@
Settings and MCP are declared here and take the full area when selected; agent views
are added at runtime. Exactly one child is Visible; the rest stay loaded and Collapsed. -->
-
+
-
-
-
+
-
-
-
+
+
+
+
-
+
@@ -743,7 +846,8 @@
-
+
@@ -902,7 +1006,8 @@
-
+
@@ -1040,7 +1145,8 @@
-
+
diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs
index 9d0d437..53e11e2 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml.cs
+++ b/src/MandoCode.Desktop/MainWindow.xaml.cs
@@ -105,6 +105,9 @@ public MainWindow()
CreateChatTab(t.ProjectRoot, t.Title, t.Model, t.Key);
if (_tabs.Count == 0) CreateChatTab();
else SelectTab(_tabs[Math.Clamp(shape.ActiveIndex, 0, _tabs.Count - 1)]);
+ // After the tabs exist and one is selected — the pane set is matched by persist-key
+ // and only shows if the restored active agent is one of its panes.
+ RestoreSplitLayout(shape);
}
else
{
diff --git a/src/MandoCode.Desktop/Services/ConversationLog.cs b/src/MandoCode.Desktop/Services/ConversationLog.cs
index c358b32..c003056 100644
--- a/src/MandoCode.Desktop/Services/ConversationLog.cs
+++ b/src/MandoCode.Desktop/Services/ConversationLog.cs
@@ -87,6 +87,19 @@ public static IReadOnlyList Load(string key)
}
}
+ /// Last write time of a session's log, or null when there isn't one. Used by
+ /// to revalidate cached text, so a session that was reopened
+ /// and closed again is re-read instead of searched against a stale copy.
+ public static DateTime? LastWriteUtc(string key)
+ {
+ try
+ {
+ var path = PathFor(key);
+ return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : null;
+ }
+ catch { return null; }
+ }
+
public static void Delete(string key)
{
try
diff --git a/src/MandoCode.Desktop/Services/ConversationSearch.cs b/src/MandoCode.Desktop/Services/ConversationSearch.cs
new file mode 100644
index 0000000..107fc5d
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ConversationSearch.cs
@@ -0,0 +1,72 @@
+using System.Text;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// Full-text matching over an archived conversation's plain-text log, and the snippet that explains
+/// a hit. Deliberately pure — text in, match out — so it's unit tested directly; the caller owns the
+/// file reads () and the caching ().
+///
+public static class ConversationSearch
+{
+ /// Characters of context kept either side of a hit. Sized for the History card, which is
+ /// two lines wide in a docked panel.
+ public const int SnippetRadius = 60;
+
+ /// Queries shorter than this aren't worth reading every log for — a single character
+ /// matches nearly every conversation, so the result wouldn't narrow anything.
+ public const int MinQueryLength = 2;
+
+ /// One searchable blob per conversation: the turn texts joined by newlines. Roles are
+ /// dropped on purpose — searching for "a" or "u" shouldn't match every turn marker.
+ public static string Flatten(IEnumerable turns) =>
+ string.Join("\n", turns.Select(t => t.T));
+
+ ///
+ /// A one-line window around the first occurrence of , ellipsised at
+ /// whichever end was truncated, or null when there's no match. Newlines and whitespace runs
+ /// collapse to single spaces so a snippet always renders as one tidy line on the card rather
+ /// than reproducing the log's own wrapping.
+ ///
+ public static string? Snippet(string? text, string? query, int radius = SnippetRadius)
+ {
+ if (string.IsNullOrEmpty(text) || string.IsNullOrWhiteSpace(query)) return null;
+
+ var at = text.IndexOf(query, StringComparison.OrdinalIgnoreCase);
+ if (at < 0) return null;
+
+ var start = Math.Max(0, at - radius);
+ var end = Math.Min(text.Length, at + query.Length + radius);
+
+ var window = CollapseWhitespace(text[start..end]);
+ if (window.Length == 0) return null;
+
+ // Ellipses mark real truncation only, so a short conversation reads as a complete quote.
+ var prefix = start > 0 ? "…" : "";
+ var suffix = end < text.Length ? "…" : "";
+ return prefix + window + suffix;
+ }
+
+ /// True when is long enough to justify scanning the logs.
+ public static bool IsSearchable(string? query) =>
+ !string.IsNullOrWhiteSpace(query) && query.Trim().Length >= MinQueryLength;
+
+ private static string CollapseWhitespace(string value)
+ {
+ var sb = new StringBuilder(value.Length);
+ var pendingSpace = false;
+
+ foreach (var ch in value)
+ {
+ if (char.IsWhiteSpace(ch))
+ {
+ pendingSpace = sb.Length > 0; // never lead with a space
+ continue;
+ }
+ if (pendingSpace) { sb.Append(' '); pendingSpace = false; }
+ sb.Append(ch);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/ConversationTextCache.cs b/src/MandoCode.Desktop/Services/ConversationTextCache.cs
new file mode 100644
index 0000000..7445658
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ConversationTextCache.cs
@@ -0,0 +1,44 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Lazily-loaded cache of archived conversations' searchable text, so History's full-text search
+/// reads each log from disk once instead of once per keystroke (the archive caps at 60 sessions, so
+/// an un-cached search would be up to 60 file reads per character typed).
+///
+/// The search runs on a background thread, so every member is lock-guarded. Entries revalidate
+/// against the log file's last-write time rather than living forever: a session reopened and closed
+/// again gets re-read instead of matched against stale text.
+///
+/// Bounded in practice by the archive cap; drops rows the user deleted so the
+/// dictionary doesn't accumulate keys whose files are gone.
+///
+public sealed class ConversationTextCache
+{
+ private readonly object _lock = new();
+ private readonly Dictionary _cache =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// The conversation's searchable text, loading it on first use. Safe from any thread.
+ public string TextFor(string key)
+ {
+ var stamp = ConversationLog.LastWriteUtc(key);
+
+ lock (_lock)
+ if (_cache.TryGetValue(key, out var hit) && hit.Stamp == stamp)
+ return hit.Text;
+
+ // Read and parse OUTSIDE the lock — one slow log shouldn't serialise every other key's
+ // lookup. A duplicate concurrent load is harmless: both produce the same text.
+ var text = ConversationSearch.Flatten(ConversationLog.Load(key));
+
+ lock (_lock) _cache[key] = (stamp, text);
+ return text;
+ }
+
+ /// Drops cached text for keys that are going away (a deleted row or group).
+ public void Forget(IEnumerable keys)
+ {
+ lock (_lock)
+ foreach (var key in keys) _cache.Remove(key);
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/PaneLayout.cs b/src/MandoCode.Desktop/Services/PaneLayout.cs
new file mode 100644
index 0000000..aaf532d
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/PaneLayout.cs
@@ -0,0 +1,72 @@
+namespace MandoCode.Desktop.Services;
+
+///
+/// Pure geometry for the split view's pane grid — how many rows and columns a given pane count
+/// occupies, which cell each pane lands in, and where a dragged divider leaves the track sizes.
+/// Deliberately free of WinUI types so it can be unit tested; MainWindow.Split.cs owns the
+/// visual-tree side (building tracks, creating dividers, moving views between cells).
+///
+public static class PaneLayout
+{
+ /// Past four panes a transcript plus its input box stops being usable at any split.
+ public const int MaxPanes = 4;
+
+ /// No pane may be dragged below this share of its axis.
+ public const double MinFraction = 0.15;
+
+ /// Grid shape for a pane count: 2 side by side, 3 across, 4 as a 2×2. Three is the most
+ /// that stays readable in a single row, so four wraps rather than shrinking further.
+ public static (int Rows, int Cols) Shape(int count) => count switch
+ {
+ 2 => (1, 2),
+ 3 => (1, 3),
+ 4 => (2, 2),
+ _ => (1, 1),
+ };
+
+ /// Pane index → its cell in the pane grid, filled row-major.
+ public static (int Row, int Col) Cell(int index, int count)
+ {
+ var (_, cols) = Shape(count);
+ return (index / cols, index % cols);
+ }
+
+ /// An even split across tracks.
+ public static List EqualFractions(int count)
+ {
+ if (count < 1) count = 1;
+ return Enumerable.Repeat(1.0 / count, count).ToList();
+ }
+
+ /// Fractions valid for tracks: keeps the current list when it
+ /// already describes that many tracks (so a user's dragged positions survive), otherwise starts
+ /// over from an even split.
+ public static List Fit(List? current, int count) =>
+ current is { } c && c.Count == count && c.Sum() > 0 ? c : EqualFractions(count);
+
+ ///
+ /// Moves the divider at to (0–1
+ /// along the axis, measured from the pane area's leading edge). The two adjacent panes' COMBINED
+ /// share is held constant, so dragging one divider never nudges a pane further along the axis.
+ /// Clamped so neither side of the divider drops below .
+ ///
+ public static void Repartition(IList fractions, int index, double pointerFraction)
+ {
+ if (index < 0 || index + 1 >= fractions.Count) return;
+
+ double before = 0;
+ for (int i = 0; i < index; i++) before += fractions[i];
+
+ double pair = fractions[index] + fractions[index + 1];
+
+ // When the pair is too small to honour the minimum on BOTH sides, [Min, pair - Min] is an
+ // empty range: clamping into it would throw, and pinning one side to Min would drive the
+ // other negative. Halving is the most balanced feasible answer and keeps both non-negative.
+ double first = pair <= 2 * MinFraction
+ ? pair / 2
+ : Math.Clamp(pointerFraction - before, MinFraction, pair - MinFraction);
+
+ fractions[index] = first;
+ fractions[index + 1] = pair - first;
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
index ff299f0..74d857e 100644
--- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
+++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs
@@ -25,9 +25,22 @@ public sealed class SessionArchiveEntry
/// 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.
+ /// First thing the user said, trimmed — the line that says what this conversation was
+ /// ABOUT. Paired with , which says where it stopped.
public string? Preview { get; init; }
+ ///
+ /// Last thing the user said, trimmed — "where you left off", the line that answers *should I
+ /// resume this*. The user's turn rather than the agent's: it's symmetric with
+ /// , and it's your own instruction rather than a long formatted reply.
+ ///
+ /// Empty string means "computed, nothing worth showing" — a single-turn conversation (where it
+ /// would just repeat ) or one with no user turns. That's deliberately
+ /// DISTINCT from null, which means "archived before this field existed and still needs
+ /// backfilling"; settable for exactly that backfill.
+ ///
+ public string? LastMessage { get; set; }
+
// ---- display helpers for the panel ----
[System.Text.Json.Serialization.JsonIgnore]
@@ -40,6 +53,16 @@ public sealed class SessionArchiveEntry
[System.Text.Json.Serialization.JsonIgnore]
public string PreviewOrPlaceholder =>
string.IsNullOrWhiteSpace(Preview) ? "(no message text captured)" : Preview!;
+
+ ///
+ /// Why this row matched a full-text search: a window around the hit INSIDE the conversation.
+ /// Set by the History panel on every populate (null when the search matched on metadata alone,
+ /// or when there's no search) and never persisted. A row can match on text the title and preview
+ /// don't contain, so without this the hit would look arbitrary. Mutable and display-only — the
+ /// rest of this type is immutable index data.
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string? MatchSnippet { get; set; }
}
///
@@ -141,6 +164,60 @@ public void Remove(string key, bool deleteFiles)
Changed?.Invoke();
}
+ ///
+ /// One-time migration for rows archived before
+ /// existed, so old and new cards look the same instead of only new ones carrying a last line.
+ /// does the per-session file read and MUST return "" (not null) when
+ /// there's nothing to show, otherwise the row is retried on every launch. Persists once for the
+ /// whole batch. Safe to call from a background thread — is documented as
+ /// possibly arriving off the UI thread.
+ ///
+ public int BackfillLastMessages(Func resolve)
+ {
+ List pending;
+ lock (_lock) pending = _items.Where(e => e.LastMessage == null).ToList();
+ if (pending.Count == 0) return 0;
+
+ var filled = 0;
+ foreach (var entry in pending)
+ {
+ var value = resolve(entry);
+ if (value == null) continue; // resolve failed outright — leave it for next time
+ entry.LastMessage = value;
+ filled++;
+ }
+ if (filled == 0) return 0;
+
+ Persist();
+ Changed?.Invoke();
+ return filled;
+ }
+
+ ///
+ /// Removes a batch of rows in ONE pass — a single and a single
+ /// for the whole set. Looping would rewrite the index
+ /// file and rebuild the History panel once per row, which is what makes clearing a whole project
+ /// group visibly slow. Keys not in the index are ignored, so a caller working from a stale group
+ /// snapshot is safe. Returns how many rows actually went.
+ ///
+ public int RemoveAll(IEnumerable keys, bool deleteFiles)
+ {
+ var targets = new HashSet(keys, StringComparer.OrdinalIgnoreCase);
+ if (targets.Count == 0) return 0;
+
+ int removed;
+ lock (_lock) removed = _items.RemoveAll(e => targets.Contains(e.Key));
+ if (removed == 0) return 0;
+
+ // Files are deleted outside the lock — same order as Remove, and file IO shouldn't block
+ // another thread filing a closed session.
+ if (deleteFiles) foreach (var key in targets) DeleteFiles(key);
+
+ Persist();
+ Changed?.Invoke();
+ return removed;
+ }
+
private static void DeleteFiles(string key)
{
TranscriptJournal.Delete(key);
diff --git a/src/MandoCode.Desktop/Services/SnapshotStore.cs b/src/MandoCode.Desktop/Services/SnapshotStore.cs
index d974c42..dfa1e15 100644
--- a/src/MandoCode.Desktop/Services/SnapshotStore.cs
+++ b/src/MandoCode.Desktop/Services/SnapshotStore.cs
@@ -93,4 +93,26 @@ public void Remove(ContextSnapshot snapshot)
Persist();
Changed?.Invoke();
}
+
+ ///
+ /// Removes a batch in ONE pass — a single and a single
+ /// for the whole set. Looping would rewrite the
+ /// store file and rebuild the panel once per snapshot, which is what makes deleting a whole
+ /// project group visibly slow. Matched by (unique per
+ /// snapshot) rather than reference, so a caller holding a deserialized copy still works.
+ /// Returns how many were actually present.
+ ///
+ public int RemoveAll(IEnumerable snapshots)
+ {
+ var ids = snapshots.Select(s => s.Id).ToHashSet();
+ if (ids.Count == 0) return 0;
+
+ int removed;
+ lock (_lock) removed = _items.RemoveAll(s => ids.Contains(s.Id));
+ if (removed == 0) return 0;
+
+ Persist();
+ Changed?.Invoke();
+ return removed;
+ }
}
diff --git a/src/MandoCode.Desktop/Services/WorkspaceState.cs b/src/MandoCode.Desktop/Services/WorkspaceState.cs
index b699445..ff6f36b 100644
--- a/src/MandoCode.Desktop/Services/WorkspaceState.cs
+++ b/src/MandoCode.Desktop/Services/WorkspaceState.cs
@@ -7,8 +7,18 @@ namespace MandoCode.Desktop.Services;
/// journal (null in files written before journaling existed).
public sealed record WorkspaceTabState(string Title, string ProjectRoot, string? Model, string? Key = null);
-/// The workspace's shape: which tabs were open and which was active.
-public sealed record WorkspaceShape(List Tabs, int ActiveIndex);
+/// The workspace's shape: which tabs were open, which was active, and the split
+/// layout. holds the paned agents' durable persist-keys in
+/// pane order — keys, not indexes, because restore skips tabs whose project folder is gone,
+/// which would shift every index. /
+/// are the divider positions. All three are null in files
+/// written before multi-pane split view existed, which restores as "no split configured".
+public sealed record WorkspaceShape(
+ List Tabs,
+ int ActiveIndex,
+ List? SplitPanes = null,
+ List? PaneColumnFractions = null,
+ List? PaneRowFractions = null);
///
/// Persists the workspace SHAPE — open tabs (title, project folder, model) and the active