From 603c889e1b290cfaa3a4b0b7d9f0bf0d75897d38 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Fri, 24 Jul 2026 18:49:25 -0700 Subject: [PATCH] Add multi-pane split view, full-text history search, and panel bulk actions Split view now shows 2-4 agents at once rather than exactly two: two side by side, three across, four as a 2x2 (past three, columns alone leave each pane too narrow for a transcript plus an input box). Every divider drags and repartitions only its two adjacent panes, so adjusting one split never nudges a third. Panes are added from the split bar's "Add pane" picker or a tab's "Add to split view"; the pane set and divider positions survive a restart, keyed by each agent's persist-key so a missing project folder drops one pane rather than shifting all of them. Panes still move between grid cells via Grid.SetColumn/SetRow and are never re-parented, so no WebView2 is torn down. Renames the feature from "Compare" to "Split view" throughout, UI and code (HasCompareSet -> SplitConfigured, _comparePanes -> _splitPanes), because comparing two models on one prompt is only one of its uses -- at three or four panes you are usually watching agents work in parallel. The split bar's two ComboBox pickers become MenuFlyout chips, which sidesteps the COMException 0x80070490 that rebuilding ComboBox item containers triggers. History and Snapshots gain per-project bulk delete: opening a group of two or more reveals "Delete all n" at the top of it. It confirms first, states the count, and says so explicitly when a search is filtering the group, since a group holds only what the panel is showing. Batched RemoveAll methods on both stores do one file write and one panel rebuild for the whole set. History search now reads each archived conversation's full text instead of only its title and 140-character preview, and quotes the matching line so a hit whose labels lack the term still explains itself. Metadata matching stays instant; the body scan is debounced and runs off the UI thread behind a per-session cache revalidated on the log's last-write time. History cards also show where a conversation left off alongside how it started, with existing rows backfilled from their logs on first open. The terminal glyph in the terminal panel's tab strip now collapses the panel, matching the chevron on the far right. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 60 +- README.md | 58 +- .../ConversationSearchTests.cs | 171 ++++++ .../MandoCode.Desktop.Tests.csproj | 5 + .../PaneLayoutTests.cs | 207 +++++++ .../Controls/TerminalPanel.xaml | 10 +- src/MandoCode.Desktop/MainWindow.History.cs | 190 +++++- .../MainWindow.Navigation.cs | 2 +- src/MandoCode.Desktop/MainWindow.Snapshots.cs | 29 + src/MandoCode.Desktop/MainWindow.Split.cs | 551 ++++++++++++++---- src/MandoCode.Desktop/MainWindow.Tabs.cs | 2 +- src/MandoCode.Desktop/MainWindow.Terminal.cs | 11 +- .../MainWindow.ViewModels.cs | 30 + src/MandoCode.Desktop/MainWindow.xaml | 402 ++++++++----- src/MandoCode.Desktop/MainWindow.xaml.cs | 3 + .../Services/ConversationLog.cs | 13 + .../Services/ConversationSearch.cs | 72 +++ .../Services/ConversationTextCache.cs | 44 ++ src/MandoCode.Desktop/Services/PaneLayout.cs | 72 +++ .../Services/SessionArchiveStore.cs | 79 ++- .../Services/SnapshotStore.cs | 22 + .../Services/WorkspaceState.cs | 14 +- 22 files changed, 1755 insertions(+), 292 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/ConversationSearchTests.cs create mode 100644 src/MandoCode.Desktop.Tests/PaneLayoutTests.cs create mode 100644 src/MandoCode.Desktop/Services/ConversationSearch.cs create mode 100644 src/MandoCode.Desktop/Services/ConversationTextCache.cs create mode 100644 src/MandoCode.Desktop/Services/PaneLayout.cs 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