Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,63 @@ 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
the thing you see.
- **Collapsible project groups, in both panels.** Each project group in Snapshots and History is an
`Expander` you can fold — the answer to "10–100 projects." Which groups you've collapsed is
remembered across launches (`PanelState` → `panel-state.json`).
- **Compare view — two agents side by side.** A **Split** button pairs two agents into a resizable
side-by-side view. The pair is an explicit, remembered choice (set by the button or the compare
bar's pickers, never by clicking a tab): clicking a paired agent's tab shows the split, clicking
any other agent shows it normally while the pair waits. The panes are ordinary agent views moved
between grid columns via `Grid.SetColumn` — never re-parented — so both WebViews and their live
transcripts survive the switch.
- **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.
Expand All @@ -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
Expand Down
58 changes: 46 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions src/MandoCode.Desktop.Tests/ConversationSearchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>Full-text matching and snippet extraction behind History's search.</summary>
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<ConversationTurn>()));
}

// ---- 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 "));
}
}
Loading
Loading