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