diff --git a/CHANGELOG.md b/CHANGELOG.md
index b368410..0f42915 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -140,6 +140,46 @@ are visible until you actually open a second tab.
`SkillCoordinator` fans skill changes out to every open agent, mirroring `McpCoordinator`.
- **Branded app icon** across the exe, taskbar, and window title bar, plus a lightweight
unhandled-exception logger (`crash.log`) to speed up diagnosing native/COM failures.
+- **Notes - a jot pad with a prompt attached.** A **Notes** rail panel for writing things down without
+ leaving the app. **New** creates a plain text file under `~/.mandocode/notes` (beside the config file
+ the CLI shares) and opens an editor docked next to the chat: autosave on a 1.2s debounce plus Ctrl+S,
+ rename in place, Show in Explorer. Notes are **app-wide**, the same call as snapshots and session
+ history - a note is something you want to write down *now*, often between projects or before an agent
+ is even open, so nothing here needs one. What survives of "which project was this about" is a plain
+ SUBFOLDER: a new note is filed under the active agent's folder name when there is one, and sits loose
+ at the top when there isn't. Grouping therefore costs no metadata and cannot drift - you re-file a
+ note by dragging it in Explorer.
+ - **The filesystem is the store.** No notes index, no JSON, which is also why the pad lives in
+ `~/.mandocode` rather than LocalAppData: these are your files, meant to be greppable, syncable, and
+ openable in any editor. Discovery walks one folder plus its immediate subfolders (one level only -
+ a jot pad with a hierarchy is a filing system, and search is the better answer to "where did I put
+ it"). In exchange no row can point at a file that isn't there: a note written in Notepad shows up,
+ one deleted outside the app disappears. Search matches note BODIES and quotes the matching line.
+ - **A prompt bar under both surfaces.** Chat-shaped, but the document above it is your note rather
+ than a transcript: replies land in the bar's own strip and reach a note only through **Insert** (at
+ the cursor, replacing the selection if there is one) or **Replace note**. On an open note the
+ question carries the LIVE editor buffer, so the model always sees the note as it is right now -
+ including keystrokes autosave hasn't written yet - and only the current message carries it, so a
+ long thread doesn't ship stale copies. On the list the question is about the pad: every note's
+ title and first line plus the full text of whatever the search box is matching, with the bar
+ stating what it was given (`12 notes listed - 3 read in full`), because a capped read that looks
+ total is the one thing an "ask about all my notes" box must not do.
+ - **The assistant has no tools, by design.** `NoteAssistant` builds a bare Ollama kernel with no
+ plugins, filters, or tools - the same shape as `SnapshotEnhancer`. With no file access, "nothing
+ writes your note but you" is true by construction rather than by policy, so no approval machinery
+ is needed: the only route from a reply into a note is a button you pressed. Its model comes from
+ the chip under the prompt (defaulting to the app-wide default) and is remembered; the thread is
+ per-note, in memory, and cleared when you switch notes - notes aren't conversations.
+ - **The editor is not the only writer, and doesn't assume it is.** These are plain files, so Notepad,
+ VS Code, a sync client, or git can change one under you. A `FileSystemWatcher` compares the file
+ against what the editor last wrote: identical means the write was ours, changed-while-clean is
+ adopted silently, and changed-while-you-were-typing raises a conflict you resolve - *use the
+ version on disk* or *keep what I typed*. A note deleted from under unsaved edits offers to save it
+ back. No path silently discards typing.
+ - `NoteText` owns the newline round trip: a WinUI `TextBox` normalizes every newline to a bare CR, so
+ writing `Editor.Text` straight back out would turn a Notepad-authored CRLF note into one endless
+ line - and comparing the loaded file text against `Editor.Text` made merely OPENING a note look
+ like an edit, which autosaved untouched files. Both are covered by tests.
### Changed
- **Closing the last agent is allowed.** The app no longer forces at least one agent open — closing
diff --git a/README.md b/README.md
index 811def2..aa6a301 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ graph; `SessionManager` owns the set of them. The split matters:
| `AIService` (its conversation, its model), `ChatController`, `TaskPlannerService` | The `MandoCodeConfig` on disk — the **defaults** a new agent starts on |
| `MandoCodeConfig` clone, `ProjectRootAccessor`, `SkillLoader`, `FileAutocompleteProvider` | `McpClientManager` (one set of server processes) |
| `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager` (static), `TranscriptHtmlBuilder`, `SpinnerService` |
-| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `UiUpdateCheckService` |
+| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `NoteStore`, `UiUpdateCheckService` |
Tabs default to `Agent 1`, `Agent 2`, … (the folder shows in the header); the number reuses the
lowest free slot, and a rename or folder change never overwrites it. Each tab's `⋯` options menu
@@ -181,6 +181,67 @@ transcript and — when the model supports it — rehydrates the full memory. `/
conversation for good; only *closing* softened from "gone" to "recoverable." The archive is capped
(newest 60); evicting a row deletes its journals so the on-disk stores stay bounded.
+### Notes (the jot pad)
+
+The **Notes** rail panel is an always-there scratchpad: plain text files under
+`~\.mandocode\notes`, beside the config file the CLI shares. **New** creates one and opens an editor docked next to the
+chat — autosaved on a 1.2s debounce (plus Ctrl+S, leaving the note, closing the panel, closing the
+window), renameable in place, with **Show in Explorer**.
+
+Notes are **app-wide**, the same call as snapshots and session history. A note is something you want
+to write down *now*, which is often between projects or before an agent is even open, so nothing here
+needs one. What survives of "which project was this about" is a plain **subfolder**: a new note is
+filed under the active agent's folder name when there is one, and sits loose at the top when there
+isn't. Grouping therefore costs no metadata, can't drift, and is corrected by dragging files around
+in Explorer.
+
+**The filesystem is the store** — no index, no JSON, which is also why the pad lives in
+`~\.mandocode` rather than LocalAppData: these are your files, meant to be greppable, syncable, and
+openable in any editor. Discovery walks one folder plus its immediate subfolders (one level only — a
+jot pad with a hierarchy is a filing system, and the search box is a better answer to "where did I
+put it"). In exchange, no row can ever point at a file that isn't there: a note added in Notepad
+shows up, and one deleted outside the app disappears. Search matches note **bodies** and quotes the
+matching line on the card; only search and the assistant see a body truncated, capped at
+`NoteStore.MaxTextBytes` (128 KB).
+
+#### The prompt bar
+
+Both surfaces — the open note and the list — carry a prompt at the bottom. It's chat-shaped, but the
+document above it is your note rather than a transcript, so replies land in the bar's own strip and
+reach a note only through **Insert** (at the cursor, replacing the selection if there is one) or
+**Replace note**. With text selected, the question is about the selection.
+
+The note's text travels **in the message**, taken from the live editor buffer — so the model always
+sees the note as it is right now, including keystrokes autosave hasn't written yet, with nothing to
+fall out of sync. Only the current message carries the note; earlier turns in the thread keep just
+their words, so a long back-and-forth doesn't ship five stale copies. On the list, the question is
+about the pad: every note's title and first line, plus the full text of whatever the search box is
+currently matching, and the bar states what it was given (`12 notes listed · 3 read in full`) because
+a capped read that looks total is the one thing an "ask about all my notes" box must not do.
+
+`Services/NoteAssistant.cs` builds a bare Ollama kernel with **no plugins, filters, or tools** — the
+same shape as `SnapshotEnhancer`. That's the design, not an optimization: with no file tools, "nothing
+writes your note but you" is true by construction rather than by policy, and no approval machinery is
+needed because the only route from a reply into a note is a button you pressed. It also isn't an
+`AIService` agent, because those exist to change your files (the opposite of what a notepad wants)
+and are scoped to a project root the pad deliberately sits outside of. Its model is picked from the
+chip under the prompt, defaults to the app-wide default model, and is remembered; the thread is
+per-note, in memory, and cleared when you switch notes — notes aren't conversations.
+
+The editor is not the only thing that writes these files, and doesn't assume it is. A
+`FileSystemWatcher` compares the file against what the editor last wrote: identical means the write
+was ours; changed-while-clean is adopted silently; changed-while-you-were-typing raises a conflict you
+resolve — *use the version on disk* or *keep what I typed*. A note deleted from under unsaved edits
+offers to save it back. `Services/NoteText.cs` handles the newline round trip, because a WinUI
+`TextBox` normalizes every newline to a bare CR — write that straight back out and a Notepad-authored
+CRLF note becomes one endless line. Both of those are why `NoteStore`, `NoteEntry`, and `NoteText` are
+WinUI-free with an injectable pad root: the tests drive them against real temp folders.
+
+Notes deliberately lack the **Delete all *n*** group button that Snapshots and History carry: those
+are artifacts the app generated, a note is something you wrote by hand, and one button that deletes a
+folder's worth of writing is a different class of risk.
+
+
### Why the tab strip isn't a `TabView`
WinUI's `TabView` hosts only the selected item's content, which detaches the previous tab and
@@ -234,6 +295,20 @@ within 24 hours.
model you pick) and Import it into another model or a fresh agent; a global
left-rail panel lists them, **persisted**, grouped by project, searchable, with
collapsible groups. Unnamed snapshots get an auto-generated, unique title
+- Notes — a **Notes** rail panel that jots into the project itself: **New** writes
+ a plain text file under `~\.mandocode\notes` and opens an editor beside the chat
+ (autosave, rename in place, Show in Explorer). App-wide like snapshots, so a note never
+ needs an agent open; new notes are filed under the folder you're working in when there is
+ one, which is what the panel groups by. The filesystem is the store — no index, so a note
+ written in Notepad appears and one deleted outside the app is gone. Searchable across note
+ bodies with the matching line quoted
+- A prompt bar under both notes surfaces — ask about the open note (it's sent the live
+ buffer, so the model always sees what you see) or about the whole pad, with the bar
+ stating what it was given. Replies land in their own strip and reach a note only via
+ **Insert** / **Replace note**; the assistant has no tools at all, so "nothing writes your
+ note but you" is structural. External edits are adopted into the open editor, while an
+ external change landing on unsaved typing raises a conflict you resolve — never a silent
+ overwrite
- Rail badges on History and Snapshots are unread counts that clear when you open
the panel (persisted), not running totals
- Integrated terminal — a sliding panel (Ctrl+` / Ctrl+Shift+` to maximize) running a
diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
index 975d03f..5753791 100644
--- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
+++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
@@ -46,6 +46,16 @@
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop.Tests/NoteStoreTests.cs b/src/MandoCode.Desktop.Tests/NoteStoreTests.cs
new file mode 100644
index 0000000..ff7cf55
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/NoteStoreTests.cs
@@ -0,0 +1,379 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+///
+/// Tests for the jot pad against real temp folders. Notes are the one store in the app that IS the
+/// filesystem — there's no JSON index to assert against, so these verify the actual claim: what's in
+/// the pad folder is what the panel shows, and grouping is a subfolder rather than metadata.
+///
+public sealed class NoteStoreTests : IDisposable
+{
+ private readonly string _root;
+ private readonly NoteStore _store;
+
+ public NoteStoreTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "mandocode-pad-" + Guid.NewGuid().ToString("N"));
+ _store = new NoteStore(_root);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch { }
+ }
+
+ /// Writes a note directly, as Notepad or a sync client would — bypassing the store.
+ private string WriteNote(string relative, string content)
+ {
+ var path = Path.Combine(_root, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ // ---- discovery ----
+
+ [Fact]
+ public void Discover_finds_a_note_written_outside_the_app()
+ {
+ WriteNote("ideas.txt", "first line\nsecond line");
+
+ var note = Assert.Single(_store.Discover());
+
+ Assert.Equal("ideas.txt", note.FileName);
+ Assert.Equal("ideas", note.Title);
+ Assert.Equal("", note.Group);
+ Assert.Equal("Unfiled", note.GroupLabel);
+ Assert.Equal("first line", note.Preview);
+ }
+
+ [Fact]
+ public void Discover_is_empty_when_the_pad_folder_does_not_exist_yet()
+ {
+ // Nothing is pre-created — a fresh install has no pad until the first note.
+ Assert.False(Directory.Exists(_root));
+ Assert.Empty(_store.Discover());
+ }
+
+ [Fact]
+ public void Discover_reads_one_level_of_subfolders_as_groups()
+ {
+ WriteNote("loose.txt", "unfiled");
+ WriteNote(Path.Combine("MandoCode.Desktop", "panel.txt"), "filed under a project");
+
+ var notes = _store.Discover();
+
+ Assert.Equal(2, notes.Count);
+ Assert.Equal("MandoCode.Desktop", notes.Single(n => n.FileName == "panel.txt").Group);
+ Assert.Equal("", notes.Single(n => n.FileName == "loose.txt").Group);
+ }
+
+ [Fact]
+ public void Discover_does_not_recurse_past_one_level()
+ {
+ // A jot pad with a folder hierarchy is a filing system; search is the better answer.
+ WriteNote(Path.Combine("project", "deep", "buried.txt"), "too deep");
+
+ Assert.Empty(_store.Discover());
+ }
+
+ [Fact]
+ public void Discover_skips_dot_folders_and_non_note_files()
+ {
+ WriteNote("keep.txt", "text note");
+ WriteNote("keep.md", "# markdown note");
+ WriteNote("skip.png", "not a note");
+ WriteNote(Path.Combine(".git", "config.txt"), "somebody else's business");
+
+ var names = _store.Discover().Select(n => n.FileName).OrderBy(n => n).ToList();
+
+ Assert.Equal(new[] { "keep.md", "keep.txt" }, names);
+ }
+
+ [Fact]
+ public void Discover_returns_newest_first_across_groups()
+ {
+ var old = WriteNote(Path.Combine("proj", "old.txt"), "old");
+ var recent = WriteNote("new.txt", "new");
+ File.SetLastWriteTime(old, DateTime.Now.AddDays(-3));
+ File.SetLastWriteTime(recent, DateTime.Now);
+
+ Assert.Equal(new[] { "new.txt", "old.txt" }, _store.Discover().Select(n => n.FileName).ToArray());
+ }
+
+ [Fact]
+ public void Discover_caps_how_much_of_a_note_is_read()
+ {
+ WriteNote("big.txt", new string('x', NoteStore.MaxTextBytes + 5_000));
+
+ var note = Assert.Single(_store.Discover());
+
+ // The row still reports the true size; only the searchable/assistant-visible body is capped.
+ Assert.Equal(NoteStore.MaxTextBytes, note.Text.Length);
+ Assert.Equal(NoteStore.MaxTextBytes + 5_000, note.Bytes);
+ }
+
+ // ---- create ----
+
+ [Fact]
+ public void Create_makes_the_pad_folder_on_first_use()
+ {
+ var note = _store.Create();
+
+ Assert.True(File.Exists(note.Path));
+ Assert.Equal("Untitled.txt", note.FileName);
+ Assert.Equal(_root, Path.GetDirectoryName(note.Path));
+ Assert.Equal("", File.ReadAllText(note.Path));
+ Assert.Equal("", note.Group);
+ }
+
+ [Fact]
+ public void Create_files_a_note_under_its_group_folder()
+ {
+ var note = _store.Create(group: "MandoCode.Desktop");
+
+ Assert.Equal("MandoCode.Desktop", note.Group);
+ Assert.Equal(Path.Combine(_root, "MandoCode.Desktop"), Path.GetDirectoryName(note.Path));
+ Assert.True(File.Exists(note.Path));
+ }
+
+ [Fact]
+ public void Create_sanitizes_a_group_that_cannot_be_a_folder_name()
+ {
+ var note = _store.Create(group: "weird:name/here");
+
+ Assert.Equal("weird name here", note.Group);
+ Assert.True(File.Exists(note.Path));
+ }
+
+ [Fact]
+ public void Create_uniquifies_within_its_folder_instead_of_overwriting()
+ {
+ var first = _store.Create();
+ var second = _store.Create();
+ var third = _store.Create(title: "Untitled");
+ // Same name in a different group is a different file, so it keeps the plain name.
+ var grouped = _store.Create(group: "proj");
+
+ Assert.Equal("Untitled.txt", first.FileName);
+ Assert.Equal("Untitled 2.txt", second.FileName);
+ Assert.Equal("Untitled 3.txt", third.FileName);
+ Assert.Equal("Untitled.txt", grouped.FileName);
+ }
+
+ [Fact]
+ public void Create_falls_back_to_a_default_name_for_an_unusable_title()
+ {
+ Assert.Equal("Untitled.txt", _store.Create(title: " /// ").FileName);
+ }
+
+ [Fact]
+ public void Create_raises_Changed()
+ {
+ var fired = 0;
+ _store.Changed += () => fired++;
+
+ _store.Create();
+
+ Assert.Equal(1, fired);
+ }
+
+ // ---- rename ----
+
+ [Fact]
+ public void Rename_keeps_the_note_in_its_group_and_keeps_its_text()
+ {
+ var note = _store.Create(group: "proj");
+ File.WriteAllText(note.Path, "body text");
+
+ var moved = _store.Rename(note, "Q3 rollout");
+
+ Assert.NotNull(moved);
+ Assert.Equal("Q3 rollout.txt", moved!.FileName);
+ Assert.Equal("proj", moved.Group);
+ Assert.Equal(Path.Combine(_root, "proj"), Path.GetDirectoryName(moved.Path));
+ Assert.False(File.Exists(note.Path));
+ Assert.Equal("body text", File.ReadAllText(moved.Path));
+ }
+
+ [Fact]
+ public void Rename_sanitizes_a_title_that_cannot_be_a_file_name()
+ {
+ var note = _store.Create();
+
+ var moved = _store.Rename(note, "ideas: rollout/plan?");
+
+ Assert.Equal("ideas rollout plan.txt", moved!.FileName);
+ }
+
+ [Fact]
+ public void Rename_keeps_the_extension()
+ {
+ WriteNote("thoughts.md", "# hi");
+ var note = Assert.Single(_store.Discover());
+
+ var moved = _store.Rename(note, "thoughts v2");
+
+ Assert.Equal("thoughts v2.md", moved!.FileName);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("Untitled")] // same title — a no-op, not a rename to "Untitled 2"
+ public void Rename_declines_a_pointless_title(string title)
+ {
+ var note = _store.Create();
+
+ Assert.Null(_store.Rename(note, title));
+ Assert.True(File.Exists(note.Path));
+ }
+
+ // ---- delete / reread ----
+
+ [Fact]
+ public void Delete_removes_the_file()
+ {
+ var note = _store.Create();
+
+ Assert.True(_store.Delete(note));
+ Assert.False(File.Exists(note.Path));
+ }
+
+ [Fact]
+ public void Delete_of_an_already_gone_note_still_succeeds()
+ {
+ var note = _store.Create();
+ File.Delete(note.Path);
+
+ Assert.True(_store.Delete(note));
+ }
+
+ [Fact]
+ public void Reread_picks_up_an_edit_made_outside_the_app()
+ {
+ var note = _store.Create();
+ File.WriteAllText(note.Path, "written in VS Code\nand this");
+
+ var fresh = _store.Reread(note);
+
+ Assert.NotNull(fresh);
+ Assert.Equal("written in VS Code", fresh!.Preview);
+ Assert.Equal(File.ReadAllText(note.Path).Length, (int)fresh.Bytes);
+ }
+
+ [Fact]
+ public void Reread_reports_a_deleted_note_as_gone()
+ {
+ var note = _store.Create();
+ File.Delete(note.Path);
+
+ Assert.Null(_store.Reread(note));
+ }
+
+ // ---- pure helpers ----
+
+ [Theory]
+ [InlineData("plain", "plain")]
+ [InlineData("ac:d\"e/f\\g|h?i*j", "a b c d e f g h i j")]
+ [InlineData(" spaced out ", "spaced out")]
+ [InlineData("...hidden", "hidden")] // a leading dot hides the file (and dot-dirs are skipped)
+ [InlineData("", "")]
+ [InlineData("////", "")]
+ public void SanitizeTitle_produces_a_legal_name(string input, string expected)
+ {
+ Assert.Equal(expected, NoteStore.SanitizeTitle(input));
+ }
+
+ [Fact]
+ public void UniquePath_collides_case_insensitively()
+ {
+ // Windows won't hold both "ideas.txt" and "Ideas.txt"; silently overwriting one with the other
+ // is the worst outcome a notes app can have.
+ WriteNote("ideas.txt", "x");
+
+ var path = NoteStore.UniquePath(_root, "IDEAS", ".txt");
+
+ Assert.Equal("IDEAS 2.txt", Path.GetFileName(path));
+ }
+
+ [Theory]
+ [InlineData("", "")]
+ [InlineData(" \n\n ", "")]
+ [InlineData("\n\n the gist \nmore", "the gist")]
+ [InlineData("# Heading\nbody", "Heading")]
+ [InlineData("- a bullet", "a bullet")]
+ public void Preview_is_the_first_line_that_says_something(string text, string expected)
+ {
+ Assert.Equal(expected, NoteStore.Preview(text));
+ }
+
+ [Fact]
+ public void Preview_caps_a_long_line_with_an_ellipsis()
+ {
+ var preview = NoteStore.Preview(new string('a', 400));
+
+ Assert.EndsWith("…", preview);
+ Assert.True(preview.Length < 200, $"preview was {preview.Length} chars");
+ }
+
+ [Fact]
+ public void Matches_hits_on_name_group_and_body()
+ {
+ WriteNote(Path.Combine("widget-api", "ideas.txt"), "line one\nremember the rate limiter\nthree");
+ var note = Assert.Single(_store.Discover());
+
+ Assert.True(NoteStore.Matches(note, "ideas")); // name
+ Assert.True(NoteStore.Matches(note, "widget-api")); // group
+ Assert.True(NoteStore.Matches(note, "RATE LIMITER")); // body, case-insensitive
+ Assert.True(NoteStore.Matches(note, "")); // empty query shows everything
+ Assert.False(NoteStore.Matches(note, "nonsense"));
+ }
+
+ [Fact]
+ public void MatchSnippet_quotes_the_matching_body_line()
+ {
+ WriteNote("ideas.txt", "the gist\nremember the rate limiter");
+ var note = Assert.Single(_store.Discover());
+
+ Assert.Equal("remember the rate limiter", NoteStore.MatchSnippet(note, "rate limiter"));
+ }
+
+ [Fact]
+ public void MatchSnippet_is_null_when_the_hit_is_already_on_the_card()
+ {
+ WriteNote("ideas.txt", "the gist\nmore text");
+ var note = Assert.Single(_store.Discover());
+
+ Assert.Null(NoteStore.MatchSnippet(note, "gist"));
+ Assert.Null(NoteStore.MatchSnippet(note, "nonsense"));
+ }
+
+ [Theory]
+ [InlineData(0, "empty")]
+ [InlineData(412, "412 B")]
+ [InlineData(3174, "3.1 KB")]
+ public void SizeLabel_reads_like_a_file_size(long bytes, string expected)
+ {
+ var note = new NoteEntry
+ {
+ Path = Path.Combine(_root, "a.txt"),
+ Group = "",
+ ModifiedAt = DateTimeOffset.Now,
+ Bytes = bytes,
+ Preview = "",
+ Text = "",
+ };
+
+ Assert.Equal(expected, note.SizeLabel);
+ }
+
+ [Fact]
+ public void DefaultRoot_is_the_mandocode_folder()
+ {
+ // Beside the CLI's own config.json, not in LocalAppData: these are the user's files.
+ Assert.EndsWith(Path.Combine(".mandocode", "notes"), NoteStore.DefaultRoot);
+ }
+}
diff --git a/src/MandoCode.Desktop.Tests/NoteTextTests.cs b/src/MandoCode.Desktop.Tests/NoteTextTests.cs
new file mode 100644
index 0000000..3f1568d
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/NoteTextTests.cs
@@ -0,0 +1,88 @@
+using MandoCode.Desktop.Services;
+using Xunit;
+
+namespace MandoCode.Desktop.Tests;
+
+///
+/// Regression tests for the note editor's newline handling. A WinUI TextBox holds every newline as a
+/// bare CR, so a round trip through the editor has to restore the note's own convention. Two real
+/// bugs came out of this, both caught by watching the app rather than the compiler: a CRLF note being
+/// rewritten as CR-only (one endless line in Notepad), and every OPEN registering as an edit, which
+/// autosaved untouched notes.
+///
+public sealed class NoteTextTests
+{
+ [Fact]
+ public void DetectNewline_prefers_CRLF_when_the_file_has_any()
+ {
+ Assert.Equal("\r\n", NoteText.DetectNewline("a\r\nb"));
+ Assert.Equal("\r\n", NoteText.DetectNewline("a\nb\r\nc")); // mixed: CRLF wins
+ }
+
+ [Fact]
+ public void DetectNewline_keeps_LF_for_an_LF_only_file()
+ {
+ Assert.Equal("\n", NoteText.DetectNewline("a\nb\nc"));
+ }
+
+ [Fact]
+ public void DetectNewline_falls_back_to_the_platform_default()
+ {
+ // Nothing to copy: a new or single-line note.
+ Assert.Equal(Environment.NewLine, NoteText.DetectNewline(""));
+ Assert.Equal(Environment.NewLine, NoteText.DetectNewline("one line, no newline"));
+ }
+
+ [Fact]
+ public void ToFileText_restores_CRLF_from_the_editor_form()
+ {
+ // What the TextBox hands back after a two-line note is edited: bare CRs.
+ Assert.Equal("a\r\nb\r\nc", NoteText.ToFileText("a\rb\rc", "\r\n"));
+ }
+
+ [Fact]
+ public void ToFileText_restores_LF_for_an_LF_note()
+ {
+ Assert.Equal("a\nb", NoteText.ToFileText("a\rb", "\n"));
+ }
+
+ [Fact]
+ public void ToFileText_never_leaves_a_bare_CR_behind()
+ {
+ var result = NoteText.ToFileText("a\rb\r\nc\nd", "\r\n");
+
+ Assert.Equal("a\r\nb\r\nc\r\nd", result);
+ // The failure mode this guards: a lone CR that Notepad renders as no break at all.
+ Assert.DoesNotContain('\r', result.Replace("\r\n", ""));
+ }
+
+ [Fact]
+ public void ToFileText_is_idempotent()
+ {
+ // Saving twice with no edit in between must not double up line endings.
+ var once = NoteText.ToFileText("a\rb", "\r\n");
+ Assert.Equal(once, NoteText.ToFileText(once, "\r\n"));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("no newlines at all")]
+ public void ToFileText_leaves_newline_free_text_alone(string text)
+ {
+ Assert.Equal(text, NoteText.ToFileText(text, "\r\n"));
+ }
+
+ [Fact]
+ public void A_round_trip_through_the_editor_form_preserves_the_file()
+ {
+ // The full path: file text in, editor normalization, file text back out. Equality here is
+ // what makes "opening a note doesn't change it" true.
+ foreach (var original in new[] { "a\r\nb\r\n", "a\nb\n", "single", "" })
+ {
+ var newline = NoteText.DetectNewline(original);
+ var editorForm = original.Replace("\r\n", "\r").Replace('\n', '\r'); // what the TextBox does
+
+ Assert.Equal(original, NoteText.ToFileText(editorForm, newline));
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
index cbc6fc5..9d0fa2d 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs
@@ -200,10 +200,11 @@ private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
/// button and the tab's options menu.
public async Task ExportTranscriptAsync()
{
- if (!CanScript) return;
+ var core = CanScript ? TranscriptView.CoreWebView2 : null; // captured: see AppendRawAsync
+ if (core == null) return;
try
{
- var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML");
+ var json = await core.ExecuteScriptAsync("document.documentElement.outerHTML");
var html = JsonSerializer.Deserialize(json) ?? "";
var picker = new Windows.Storage.Pickers.FileSavePicker();
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
index 8af92a7..9a3aca5 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
@@ -124,12 +124,29 @@ await AppendRawAsync(_html.Dim(
catch { /* memory restore is best-effort; a fresh conversation always works */ }
}
+ ///
+ /// Writes a fragment straight into the transcript document, bypassing the _pendingHtml
+ /// queue on purpose: this runs during journal replay, while _webViewReady is still false so
+ /// that live blocks keep queueing BEHIND the restored history. That's also why it can't use
+ /// — which tests _webViewReady and would send the replay to the
+ /// queue it's meant to precede.
+ ///
+ /// The core is captured into a local instead of being dereferenced off the property twice: replay
+ /// awaits between chunks, and closing (or unparenting) the tab in that window unloads the WebView
+ /// and nulls CoreWebView2 — which is a NullReferenceException on the next chunk. The catch
+ /// below always swallowed it, so it never crashed anything, but it broke into the debugger on
+ /// every occurrence and abandoned the rest of the replay on an exception path.
+ ///
private async Task AppendRawAsync(string html)
{
+ if (_shutDown) return;
+
+ var core = TranscriptView.CoreWebView2;
+ if (core == null) return; // WebView gone (tab closed mid-replay) — nothing to render into
+
try
{
- await TranscriptView.CoreWebView2.ExecuteScriptAsync(
- $"window.__append({JsonSerializer.Serialize(html)})");
+ await core.ExecuteScriptAsync($"window.__append({JsonSerializer.Serialize(html)})");
}
catch { /* transient during navigation/teardown — a fragment failing to render is not fatal */ }
}
@@ -137,7 +154,12 @@ await TranscriptView.CoreWebView2.ExecuteScriptAsync(
private async void AppendHtml(string html)
{
if (_shutDown) return;
- if (!CanScript)
+
+ // Capture once — CanScript reads the property, and re-reading it at the call site is a race
+ // against the WebView unloading. A block that arrives with no live core is queued, not
+ // dropped, so it still renders if the WebView comes back.
+ var core = CanScript ? TranscriptView.CoreWebView2 : null;
+ if (core == null)
{
_pendingHtml.Enqueue(html);
return;
@@ -145,8 +167,7 @@ private async void AppendHtml(string html)
try
{
- await TranscriptView.CoreWebView2.ExecuteScriptAsync(
- $"window.__append({JsonSerializer.Serialize(html)})");
+ await core.ExecuteScriptAsync($"window.__append({JsonSerializer.Serialize(html)})");
}
catch
{
@@ -156,8 +177,9 @@ await TranscriptView.CoreWebView2.ExecuteScriptAsync(
private async void ClearTranscript()
{
- if (!CanScript) return;
- try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); }
+ var core = CanScript ? TranscriptView.CoreWebView2 : null;
+ if (core == null) return;
+ try { await core.ExecuteScriptAsync("window.__clear()"); }
catch { /* transient during navigation/teardown — clearing a gone WebView is a no-op */ }
}
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index ad2e63d..d4899db 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -296,8 +296,10 @@ public async void ApplyTheme()
if (_shutDown) return;
var theme = ThemeManager.Current;
TranscriptView.DefaultBackgroundColor = ThemeManager.C(theme.Background);
- if (!CanScript) return;
- try { await TranscriptView.CoreWebView2.ExecuteScriptAsync(ThemeManager.BuildTranscriptScript(theme)); }
+
+ var core = CanScript ? TranscriptView.CoreWebView2 : null; // captured: see AppendRawAsync
+ if (core == null) return;
+ try { await core.ExecuteScriptAsync(ThemeManager.BuildTranscriptScript(theme)); }
catch { /* WebView gone (window closing) — nothing to recolor */ }
}
diff --git a/src/MandoCode.Desktop/Controls/NoteAskBar.xaml b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml
new file mode 100644
index 0000000..bfdbc57
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs
new file mode 100644
index 0000000..4355c78
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs
@@ -0,0 +1,226 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+
+namespace MandoCode.Desktop.Controls;
+
+///
+/// The prompt bar at the bottom of the Notes panel. Deliberately dumb: it collects a question, shows a
+/// streamed reply, and raises events. It owns no model, no thread, and no note — the window drives it,
+/// because which surface is showing decides what the question is even about.
+///
+/// The reply always gets the same three offers (Insert / Replace / Dismiss) rather than the bar trying
+/// to classify whether a reply is prose or a proposed rewrite. Small local models tag their own output
+/// unreliably, and guessing wrong in either direction is worse than letting the person who asked
+/// decide: the note is only ever written by an explicit press.
+///
+public sealed partial class NoteAskBar : UserControl
+{
+ /// A question was submitted (Enter or the send button).
+ public event Action? Submitted;
+
+ /// The send button was pressed while a reply was streaming.
+ public event Action? CancelRequested;
+
+ /// Insert the reply into the open note at the cursor.
+ public event Action? InsertRequested;
+
+ /// Replace the open note's whole body with the reply.
+ public event Action? ReplaceRequested;
+
+ /// A model was chosen from the chip's flyout.
+ public event Action? ModelChanged;
+
+ private readonly System.Text.StringBuilder _reply = new();
+ private bool _busy;
+
+ public NoteAskBar()
+ {
+ InitializeComponent();
+ }
+
+ /// The reply as it stands — what Insert/Replace would apply.
+ public string Reply => _reply.ToString().Trim();
+
+ public bool IsBusy => _busy;
+
+ /// Whether the note-writing offers are available. False on the list view, where there's no
+ /// open note to write into and the reply is just an answer.
+ public bool AllowNoteEdits { get; set; }
+
+ public void FocusPrompt() => PromptBox.Focus(FocusState.Programmatic);
+
+ /// Switches the bar between its two jobs. Called whenever the panel changes surface.
+ public void SetMode(bool noteOpen, string? noteTitle)
+ {
+ AllowNoteEdits = noteOpen;
+ PromptBox.PlaceholderText = noteOpen
+ ? $"Ask about “{noteTitle}” — or tell it what to write"
+ : "Ask about all your notes…";
+ InsertButton.Visibility = noteOpen ? Visibility.Visible : Visibility.Collapsed;
+ ReplaceButton.Visibility = noteOpen ? Visibility.Visible : Visibility.Collapsed;
+ InsertLabel.Text = "Insert";
+ }
+
+ /// Label under the prompt saying what the model was actually given — the honest version of
+ /// a capped context ("12 notes listed · 3 read in full"), so a partial read never looks total.
+ public void SetScopeNote(string text) => ScopeText.Text = text;
+
+ /// Notes that a selection exists, so Insert reads as replacing it rather than adding.
+ public void SetHasSelection(bool hasSelection) =>
+ InsertLabel.Text = hasSelection ? "Replace selection" : "Insert";
+
+ // ---- model chip ----
+
+ /// Names the model that WOULD answer, before the model list has come back from Ollama.
+ /// Without this the chip reads "no model" for the first second or two of every panel open — which
+ /// says the bar won't work, while it in fact answers fine on the configured default.
+ public void SetModelLabel(string? model) =>
+ ModelText.Text = string.IsNullOrWhiteSpace(model) ? "no model" : model;
+
+ public void SetModels(IReadOnlyList models, string? current)
+ {
+ SetModelLabel(current);
+
+ var flyout = new MenuFlyout
+ {
+ Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.Top,
+ };
+ if (models.Count == 0)
+ {
+ flyout.Items.Add(new MenuFlyoutItem
+ {
+ Text = "No models found — is Ollama running?",
+ IsEnabled = false,
+ });
+ }
+ else
+ {
+ foreach (var model in models)
+ {
+ var item = new MenuFlyoutItem { Text = model };
+ var captured = model;
+ item.Click += (_, _) =>
+ {
+ ModelText.Text = captured;
+ ModelChanged?.Invoke(captured);
+ };
+ flyout.Items.Add(item);
+ }
+ }
+ ModelButton.Flyout = flyout;
+ }
+
+ // ---- reply lifecycle (driven by the window) ----
+
+ /// Opens an empty reply and puts the bar into its streaming state.
+ public void BeginReply(string label)
+ {
+ _reply.Clear();
+ ReplyText.Text = "";
+ ReplyLabel.Text = label;
+ ReplyBox.Visibility = Visibility.Visible;
+ ActionRow.Visibility = Visibility.Collapsed;
+ ReplyRing.IsActive = true;
+ ReplyRing.Visibility = Visibility.Visible;
+ SetBusy(true);
+ }
+
+ /// Appends a streamed chunk. Must be called on the UI thread.
+ public void AppendDelta(string text)
+ {
+ if (text.Length == 0) return;
+ _reply.Append(text);
+ ReplyText.Text = _reply.ToString();
+ ReplyScroller.ChangeView(null, ReplyScroller.ScrollableHeight, null, disableAnimation: true);
+ }
+
+ /// Closes a reply. replaces the body when the call failed — a
+ /// silent empty strip would read as "the model had nothing to say".
+ public void EndReply(string? error = null, string? label = null)
+ {
+ SetBusy(false);
+ ReplyRing.IsActive = false;
+ ReplyRing.Visibility = Visibility.Collapsed;
+
+ if (error != null)
+ {
+ ReplyLabel.Text = "couldn't answer";
+ ReplyText.Text = error;
+ ActionRow.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ if (label != null) ReplyLabel.Text = label;
+
+ var empty = Reply.Length == 0;
+ if (empty) ReplyText.Text = "(no answer came back)";
+ ActionRow.Visibility = empty ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ /// Clears the reply — on switching notes, or on Dismiss.
+ public void ClearReply()
+ {
+ _reply.Clear();
+ ReplyText.Text = "";
+ ReplyBox.Visibility = Visibility.Collapsed;
+ ActionRow.Visibility = Visibility.Collapsed;
+ ReplyRing.IsActive = false;
+ ReplyRing.Visibility = Visibility.Collapsed;
+ }
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ // The send button doubles as stop: one control, and there's only ever one request in flight.
+ SendIcon.Glyph = busy ? "" : "";
+ ToolTipService.SetToolTip(SendButton, busy ? "Stop" : "Ask (Enter)");
+ PromptBox.IsEnabled = !busy;
+ }
+
+ // ---- input ----
+
+ private void PromptBox_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key != VirtualKey.Enter) return;
+ e.Handled = true;
+ Submit();
+ }
+
+ private void Send_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy) CancelRequested?.Invoke();
+ else Submit();
+ }
+
+ private void Submit()
+ {
+ if (_busy) return;
+ var question = PromptBox.Text.Trim();
+ if (question.Length == 0) return;
+ PromptBox.Text = "";
+ Submitted?.Invoke(question);
+ }
+
+ private void Insert_Click(object sender, RoutedEventArgs e)
+ {
+ if (Reply.Length > 0) InsertRequested?.Invoke(Reply);
+ }
+
+ private void Replace_Click(object sender, RoutedEventArgs e)
+ {
+ if (Reply.Length > 0) ReplaceRequested?.Invoke(Reply);
+ }
+
+ private void Copy_Click(object sender, RoutedEventArgs e)
+ {
+ if (Reply.Length == 0) return;
+ var package = new DataPackage { RequestedOperation = DataPackageOperation.Copy };
+ package.SetText(Reply);
+ Clipboard.SetContent(package);
+ }
+
+ private void Dismiss_Click(object sender, RoutedEventArgs e) => ClearReply();
+}
diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs b/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs
new file mode 100644
index 0000000..d881c27
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs
@@ -0,0 +1,113 @@
+using MandoCode.Desktop.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.System;
+
+namespace MandoCode.Desktop.Controls;
+
+///
+/// The note's own actions: rename in place, reveal, delete. Split out from the editor proper because
+/// none of it touches the buffer — they act on the FILE, through the store.
+///
+public sealed partial class NoteEditorPane
+{
+ private bool _renaming;
+
+ // ---- rename ----
+
+ private void Rename_Click(object sender, RoutedEventArgs e) => BeginRename();
+
+ private void BeginRename()
+ {
+ if (Current == null) return;
+ _renaming = true;
+ RenameBox.Text = Current.Title;
+ RenameBox.Visibility = Visibility.Visible;
+ TitleText.Visibility = Visibility.Collapsed;
+ RenameBox.Focus(FocusState.Programmatic);
+ RenameBox.SelectAll();
+ }
+
+ private void EndRename(bool commit)
+ {
+ if (!_renaming) return;
+ _renaming = false;
+
+ RenameBox.Visibility = Visibility.Collapsed;
+ TitleText.Visibility = Visibility.Visible;
+
+ if (!commit || Current == null || Store == null) return;
+
+ // Save first: the rename moves the file, and a pending autosave would then write to a path that
+ // no longer exists.
+ FlushPendingSave();
+
+ var moved = Store.Rename(Current, RenameBox.Text);
+ if (moved == null)
+ {
+ SetStatus("Rename didn't stick — that name may be in use or the file is locked");
+ return;
+ }
+
+ Current = moved;
+ StartWatching(moved.Path); // the old watcher was filtered to the old file name
+ RefreshHeader();
+ SetStatus($"Renamed {DateTime.Now:h:mm tt}");
+ Renamed?.Invoke(moved);
+ }
+
+ private void RenameBox_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Enter) { EndRename(commit: true); e.Handled = true; }
+ else if (e.Key == VirtualKey.Escape) { EndRename(commit: false); e.Handled = true; }
+ }
+
+ /// Clicking away commits rather than discards — a typed name the user walked away from was
+ /// still their intent.
+ private void RenameBox_LostFocus(object sender, RoutedEventArgs e) => EndRename(commit: true);
+
+ // ---- menu actions ----
+
+ private void Reveal_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current == null) return;
+ var dir = Path.GetDirectoryName(Current.Path);
+ if (dir == null) return;
+ var failed = ShellOpen.Try(dir);
+ if (failed != null) SetStatus($"Couldn't open the folder — {failed.Message}");
+ }
+
+ private async void Delete_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current == null || Store == null) return;
+
+ var note = Current;
+ var dialog = new ContentDialog
+ {
+ Title = "Delete note",
+ Content = $"Delete “{note.FileName}”? The file is removed from disk. This can't be undone.",
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ // Stop the debounce and the watcher before removing the file, so neither reacts to our own
+ // delete (an autosave here would put the note straight back).
+ _autosave.Stop();
+ SetDirty(false);
+ StopWatching();
+
+ if (!Store.Delete(note))
+ {
+ SetStatus("Couldn't delete this note — it may be open in another program");
+ StartWatching(note.Path);
+ return;
+ }
+
+ Current = null;
+ Deleted?.Invoke(note);
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs b/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs
new file mode 100644
index 0000000..dc4d6d3
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs
@@ -0,0 +1,186 @@
+using MandoCode.Desktop.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.System;
+
+namespace MandoCode.Desktop.Controls;
+
+///
+/// Keeping the open note in step with the file underneath it. These are plain files in
+/// ~/.mandocode/notes, so this editor is NOT the only thing that writes them: Notepad, VS Code, a sync
+/// client, or a git checkout can all change one while it's open.
+///
+/// Everything here hangs off one decision — compare CONTENT, not timestamps. _lastSavedText is
+/// what this editor believes is on disk, so an incoming change that matches it was ours, one that
+/// differs while the buffer is clean can be adopted silently, and one that differs while there are
+/// unsaved keystrokes is a genuine conflict only the user can resolve. No path here discards typing.
+///
+public sealed partial class NoteEditorPane
+{
+ private enum Conflict { None, ChangedOnDisk, DeletedOnDisk }
+
+ private FileSystemWatcher? _watcher;
+ private Conflict _conflict;
+ private string _conflictDiskText = "";
+
+ // ---- external changes (Notepad, VS Code, sync, git) ----
+
+ private void StartWatching(string path)
+ {
+ StopWatching();
+ try
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return;
+
+ _watcher = new FileSystemWatcher(dir, Path.GetFileName(path))
+ {
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
+ };
+ _watcher.Changed += OnFileSystemEvent;
+ _watcher.Created += OnFileSystemEvent;
+ _watcher.Deleted += OnFileSystemEvent;
+ _watcher.Renamed += OnFileSystemEvent;
+ _watcher.EnableRaisingEvents = true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ // No watcher (deleted folder, network path, exhausted handles) just means external edits
+ // aren't noticed live. Editing still works, so this isn't worth a message.
+ _watcher = null;
+ }
+ }
+
+ private void StopWatching()
+ {
+ if (_watcher == null) return;
+ try
+ {
+ _watcher.EnableRaisingEvents = false;
+ _watcher.Changed -= OnFileSystemEvent;
+ _watcher.Created -= OnFileSystemEvent;
+ _watcher.Deleted -= OnFileSystemEvent;
+ _watcher.Renamed -= OnFileSystemEvent;
+ _watcher.Dispose();
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException) { }
+ _watcher = null;
+ }
+
+ /// Watcher callbacks arrive on a threadpool thread; everything below touches UI.
+ private void OnFileSystemEvent(object sender, FileSystemEventArgs e)
+ => _dispatcher.TryEnqueue(HandleExternalChange);
+
+ private void HandleExternalChange()
+ {
+ if (Current == null) return;
+
+ if (!File.Exists(Current.Path))
+ {
+ // Only interesting if we'd lose something. With a clean buffer the note is simply gone, and
+ // the panel's own refresh will drop the row.
+ if (_dirty)
+ ShowConflict(Conflict.DeletedOnDisk,
+ "This note was deleted while you were typing.",
+ "Save it back", "Discard my text");
+ else
+ Deleted?.Invoke(Current);
+ return;
+ }
+
+ string disk;
+ try
+ {
+ disk = File.ReadAllText(Current.Path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return; // mid-write by someone else; the next event brings the settled content
+ }
+
+ // Compared in FILE form, since that's what the disk holds.
+ if (disk == ToFileText(_lastSavedText)) return; // that write was ours
+ if (disk == ToFileText(Editor.Text)) // converged on the same content
+ {
+ _lastSavedText = Editor.Text;
+ SetDirty(false);
+ return;
+ }
+
+ if (!_dirty)
+ {
+ // Nothing of ours to lose: adopt it.
+ var caret = Editor.SelectionStart;
+ LoadIntoEditor(disk);
+ Editor.Select(Math.Min(caret, Editor.Text.Length), 0);
+ SetStatus($"Updated from disk {DateTime.Now:h:mm tt}");
+ NotifyStored();
+ return;
+ }
+
+ _conflictDiskText = disk;
+ ShowConflict(Conflict.ChangedOnDisk,
+ "This note changed on disk while you had unsaved edits.",
+ "Use the version on disk", "Keep what I typed");
+ }
+
+ private void ShowConflict(Conflict kind, string message, string primary, string secondary)
+ {
+ _conflict = kind;
+ _autosave.Stop(); // no autosave until the user chooses — either write would lose text
+ ConflictBar.Message = message;
+ ConflictPrimary.Content = primary;
+ ConflictSecondary.Content = secondary;
+ ConflictBar.IsOpen = true;
+ }
+
+ private void ClearConflict()
+ {
+ _conflict = Conflict.None;
+ _conflictDiskText = "";
+ ConflictBar.IsOpen = false;
+ }
+
+ private void ConflictPrimary_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current == null) return;
+
+ switch (_conflict)
+ {
+ case Conflict.ChangedOnDisk:
+ LoadIntoEditor(_conflictDiskText);
+ SetDirty(false);
+ SetStatus("Loaded the version from disk");
+ ClearConflict();
+ NotifyStored();
+ break;
+
+ case Conflict.DeletedOnDisk:
+ ClearConflict();
+ SaveNow(force: true); // put the note back, deliberately
+ StartWatching(Current.Path);
+ break;
+ }
+ }
+
+ private void ConflictSecondary_Click(object sender, RoutedEventArgs e)
+ {
+ if (Current == null) return;
+
+ switch (_conflict)
+ {
+ case Conflict.ChangedOnDisk:
+ ClearConflict();
+ SaveNow(force: true); // keep mine: overwrite disk, deliberately
+ break;
+
+ case Conflict.DeletedOnDisk:
+ var gone = Current;
+ ClearConflict();
+ SetDirty(false);
+ Deleted?.Invoke(gone);
+ break;
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
new file mode 100644
index 0000000..0433600
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml.cs b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml.cs
new file mode 100644
index 0000000..cd79afa
--- /dev/null
+++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml.cs
@@ -0,0 +1,327 @@
+using MandoCode.Desktop.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.System;
+
+namespace MandoCode.Desktop.Controls;
+
+///
+/// One open note: a plain TextBox over one file, with autosave, rename-in-place, and the conflict
+/// handling a file-backed editor can't skip.
+///
+/// Autosave. There is no Save button. The point of a jot pad is writing a thought down without
+/// ceremony, and a jot you have to remember to save is a jot you lose. Writes land
+/// ms after you stop typing, plus on Ctrl+S, on leaving the note, on
+/// closing the panel, and on closing the window.
+///
+/// This editor is the only thing that writes note content — but not the only thing that writes the
+/// FILE. These are plain files in ~/.mandocode/notes; Notepad, VS Code, a sync client, or
+/// git can all change one under you. So every write is tracked against —
+/// what we believe is on disk — and a FileSystemWatcher compares against it: identical means the write
+/// was ours, changed-while-clean is adopted silently, and changed-while-you-were-typing is a conflict
+/// the user resolves. No path here silently discards typing.
+///
+/// The note assistant is NOT a writer: it has no file tools at all (see ).
+/// Its output reaches a note only through or ,
+/// each of which is a button the user pressed.
+///
+public sealed partial class NoteEditorPane : UserControl
+{
+ /// Idle time before an autosave fires. Long enough not to write on every keystroke, short
+ /// enough that "did that stick?" is never a real question.
+ private const int AutosaveDebounceMs = 1200;
+
+ // Note-typed members are INTERNAL on purpose: the XAML markup compiler walks a UserControl's public
+ // properties and emits `new T()` activators for their types, which fails on NoteEntry's required
+ // members. Internal keeps it out of that walk, and MainWindow is the only consumer.
+
+ /// Set once by the window. The editor writes note CONTENT; the store owns the file
+ /// lifecycle (create/rename/delete) it delegates to.
+ internal NoteStore? Store { get; set; }
+
+ internal NoteEntry? Current { get; private set; }
+
+ public event Action? BackRequested;
+
+ /// Raised once the note file is gone, so the panel can drop back to the list.
+ internal event Action? Deleted;
+
+ /// Raised after a successful rename, carrying the note at its new path.
+ internal event Action? Renamed;
+
+ /// Raised whenever the note's bytes on disk change (our save, or an adopted external
+ /// edit) so the card behind the editor stays truthful.
+ internal event Action? Stored;
+
+ // Fully qualified: Windows.System (imported here for VirtualKey) has a DispatcherQueue too.
+ private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher;
+ private readonly DispatcherTimer _autosave = new();
+
+ /// What's on disk, in EDITOR form (see — a WinUI TextBox holds
+ /// newlines as bare CR whatever the file uses). The watcher's yardstick for "was that change
+ /// mine?" — content comparison rather than a timing window, which is the only version of this that
+ /// can't misfire under a slow disk.
+ private string _lastSavedText = "";
+
+ /// The note's own line ending, detected on load and restored on save.
+ private string _newline = Environment.NewLine;
+
+ private bool _dirty;
+ private bool _suppressDirty;
+
+ public NoteEditorPane()
+ {
+ InitializeComponent();
+ _dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
+ _autosave.Interval = TimeSpan.FromMilliseconds(AutosaveDebounceMs);
+ _autosave.Tick += (_, _) => { _autosave.Stop(); SaveNow(); };
+ }
+
+ // ---- what the assistant and the ask bar need ----
+
+ /// The live buffer — what the assistant is given, so it always sees the note as it is now,
+ /// including keystrokes not yet autosaved.
+ public string Body => Editor.Text;
+
+ /// The selected passage, or "" — lets a prompt act on a highlighted paragraph.
+ public string SelectionText => Editor.SelectedText ?? "";
+
+ /// Puts assistant output into the note at the cursor, replacing the selection if there is
+ /// one. Goes through the normal dirty/autosave path, so it saves like typing does — and is undoable
+ /// in the TextBox like anything else.
+ public void InsertAtCursor(string text)
+ {
+ if (Current == null || Editor.IsReadOnly || text.Length == 0) return;
+
+ var body = Editor.Text;
+ var start = Math.Clamp(Editor.SelectionStart, 0, body.Length);
+ var length = Math.Clamp(Editor.SelectionLength, 0, body.Length - start);
+
+ Editor.Text = body[..start] + text + body[(start + length)..];
+ Editor.Select(start + text.Length, 0);
+ Editor.Focus(FocusState.Programmatic);
+
+ SetDirty(true);
+ _autosave.Stop();
+ _autosave.Start();
+ }
+
+ /// Replaces the whole note with assistant output. Same path as typing it by hand.
+ public void ReplaceBody(string text)
+ {
+ if (Current == null || Editor.IsReadOnly) return;
+
+ Editor.Text = text;
+ Editor.Select(Editor.Text.Length, 0);
+ Editor.Focus(FocusState.Programmatic);
+
+ SetDirty(true);
+ _autosave.Stop();
+ _autosave.Start();
+ }
+
+ // ---- opening / closing ----
+
+ /// Loads a note, replacing whatever was open (which is saved first — switching notes must
+ /// never be a way to lose one).
+ internal void Open(NoteEntry note)
+ {
+ CloseCurrent();
+
+ string text;
+ try
+ {
+ text = File.Exists(note.Path) ? File.ReadAllText(note.Path) : "";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Read it as empty and DON'T let autosave overwrite a file we couldn't read.
+ Current = note;
+ LoadIntoEditor("");
+ RefreshHeader();
+ SetStatus($"Couldn't open this note — {ex.Message}");
+ Editor.IsReadOnly = true;
+ return;
+ }
+
+ Current = note;
+ Editor.IsReadOnly = false;
+
+ LoadIntoEditor(text);
+ _dirty = false;
+ ClearConflict();
+
+ StartWatching(note.Path);
+ RefreshHeader();
+ SetStatus(text.Length == 0 ? "New note" : "Opened");
+ Editor.Select(Editor.Text.Length, 0); // caret at the end — you're here to add, not re-read
+ }
+
+ ///
+ /// Puts file text into the TextBox and re-reads it back as the disk yardstick. Reading it BACK is
+ /// the point: the control rewrites newlines on assignment, so Editor.Text is generally not
+ /// the string we just handed it — and comparing our original against the control's version is what
+ /// made every open look like an edit (and autosave an untouched note).
+ ///
+ private void LoadIntoEditor(string fileText)
+ {
+ _newline = NoteText.DetectNewline(fileText);
+
+ _suppressDirty = true;
+ Editor.Text = fileText;
+ _suppressDirty = false;
+
+ _lastSavedText = Editor.Text;
+ }
+
+ /// Editor text → file text, in this note's own line ending (see ).
+ private string ToFileText(string editorText) => NoteText.ToFileText(editorText, _newline);
+
+ public void FocusEditor() => Editor.Focus(FocusState.Programmatic);
+
+ /// Commits any debounced edit immediately. Called when the panel closes, the window
+ /// closes, or another note is opened.
+ public void FlushPendingSave()
+ {
+ _autosave.Stop();
+ SaveNow();
+ }
+
+ /// Saves and stops watching, without clearing — the window can still
+ /// ask what was open.
+ public void Shutdown()
+ {
+ FlushPendingSave();
+ StopWatching();
+ }
+
+ private void CloseCurrent()
+ {
+ if (Current == null) return;
+ Shutdown();
+ Current = null;
+ }
+
+ /// Leaves the note entirely (back to the list).
+ public void Close()
+ {
+ CloseCurrent();
+ ClearConflict();
+ }
+
+ private void Back_Click(object sender, RoutedEventArgs e) => BackRequested?.Invoke();
+
+ // ---- editing / autosave ----
+
+ private void Editor_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (_suppressDirty || Current == null) return;
+
+ // WinUI raises TextChanged asynchronously, so the event for our own programmatic load can
+ // arrive AFTER _suppressDirty is cleared. Content is the authority: text equal to what's on
+ // disk is not an edit, whenever the notification shows up.
+ if (Editor.Text == _lastSavedText)
+ {
+ if (_dirty) SetDirty(false);
+ _autosave.Stop();
+ return;
+ }
+
+ SetDirty(true);
+ _autosave.Stop();
+ _autosave.Start();
+ }
+
+ private void Editor_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ // Ctrl+S saves now. A plain KeyDown rather than a KeyboardAccelerator: accelerators on this
+ // window have a history of native-crashing on non-alphanumeric keys, and this is correctly
+ // scoped to the editor anyway.
+ var ctrl = Microsoft.UI.Input.InputKeyboardSource
+ .GetKeyStateForCurrentThread(VirtualKey.Control)
+ .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+
+ if (ctrl && e.Key == VirtualKey.S)
+ {
+ _autosave.Stop();
+ SaveNow();
+ e.Handled = true;
+ }
+ }
+
+ private void SetDirty(bool value)
+ {
+ _dirty = value;
+ DirtyDot.Visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ if (value) SetStatus("Unsaved — autosaving…");
+ }
+
+ ///
+ /// Writes the buffer to the note file. A failed write keeps the dirty flag and says so — the text
+ /// stays in the TextBox and the next keystroke or Ctrl+S retries; pretending it saved is the one
+ /// outcome that loses a note.
+ ///
+ /// Write even if nothing looks changed. Used by the conflict resolutions: there
+ /// the file holds someone ELSE's version, so "keep what I typed" must overwrite it even when the
+ /// buffer matches what this editor last wrote.
+ private void SaveNow(bool force = false)
+ {
+ if (Current == null || Editor.IsReadOnly) return;
+ if (!force && !_dirty) return;
+
+ // Deleted out from under us: writing would resurrect it silently. The conflict bar's
+ // "Save it back" is the deliberate (forced) version of that.
+ if (_conflict == Conflict.DeletedOnDisk && !force) return;
+
+ var text = Editor.Text;
+
+ // Nothing actually differs from disk (an undo back to the original, say): writing would only
+ // bump the modified time and reorder the list for no reason.
+ if (!force && text == _lastSavedText)
+ {
+ SetDirty(false);
+ return;
+ }
+
+ var fileText = ToFileText(text);
+ try
+ {
+ File.WriteAllText(Current.Path, fileText);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ SetStatus($"Couldn't save — {ex.Message}");
+ return;
+ }
+
+ _lastSavedText = text;
+ SetDirty(false);
+ SetStatus($"Saved {DateTime.Now:h:mm tt}");
+ NotifyStored();
+ }
+
+ /// Re-reads the note's metadata and tells the panel, so the card behind the editor shows
+ /// the new size, preview, and timestamp.
+ private void NotifyStored()
+ {
+ if (Current == null || Store == null) return;
+ var refreshed = Store.Reread(Current);
+ if (refreshed == null) return;
+ Current = refreshed;
+ RefreshHeader();
+ Stored?.Invoke(refreshed);
+ }
+
+ // ---- header / status ----
+
+ private void RefreshHeader()
+ {
+ if (Current == null) return;
+ TitleText.Text = Current.Title;
+ GroupText.Text = Current.GroupLabel;
+ SizeText.Text = Current.SizeLabel;
+ }
+
+ private void SetStatus(string text) => StatusText.Text = text;
+}
diff --git a/src/MandoCode.Desktop/MainWindow.History.cs b/src/MandoCode.Desktop/MainWindow.History.cs
index 945e9e9..15b3a52 100644
--- a/src/MandoCode.Desktop/MainWindow.History.cs
+++ b/src/MandoCode.Desktop/MainWindow.History.cs
@@ -82,7 +82,7 @@ private static string LastMessageFor(IReadOnlyList userTurns,
private void OnArchiveChanged()
{
- if (_historyPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
+ if (HistoryPanelOpen) { MarkHistorySeen(); PopulateHistory(); }
else RefreshHistoryBadge();
}
@@ -96,7 +96,7 @@ private void MarkHistorySeen()
private void NavHistory_Click(object sender, RoutedEventArgs e)
{
- if (_historyPanelOpen) CloseLeftPanel();
+ if (HistoryPanelOpen) CloseLeftPanel();
else OpenHistory();
}
@@ -106,7 +106,7 @@ private void OpenHistory()
{
MarkHistorySeen(); // opening the panel IS reading it — clear the unread badge
PopulateHistory();
- ShowLeftPanel(HistoryPanel, snapshots: false);
+ ShowLeftPanel(LeftPanel.History);
_ = BackfillHistoryLastMessagesAsync();
}
@@ -649,7 +649,8 @@ private void EnterEmptyState()
{
RefreshTabStrip(); // empties the toast; disables Settings/MCP via RefreshNavIcons
SwitchPage("chat"); // reveals the empty-state panel + its background
- if (_snapshotsPanelOpen) PopulateSnapshots(); // no agent now → disable Import + show notice
+ if (SnapshotsPanelOpen) PopulateSnapshots(); // no agent now → disable Import + show notice
+ if (NotesPanelOpen) PopulateNotes(); // no agent now → a new note lands unfiled
}
}
diff --git a/src/MandoCode.Desktop/MainWindow.Navigation.cs b/src/MandoCode.Desktop/MainWindow.Navigation.cs
index 675da2b..a7ed2bc 100644
--- a/src/MandoCode.Desktop/MainWindow.Navigation.cs
+++ b/src/MandoCode.Desktop/MainWindow.Navigation.cs
@@ -143,8 +143,9 @@ private void RefreshNavIcons()
NavMcpIcon.Foreground = _currentPage == "mcp" ? accent : normal;
NavSkillsIcon.Foreground = _currentPage == "skills" ? accent : normal;
NavAppearanceIcon.Foreground = _currentPage == "appearance" ? accent : normal;
- NavSnapshotsIcon.Foreground = _snapshotsPanelOpen ? accent : normal;
- NavHistoryIcon.Foreground = _historyPanelOpen ? accent : normal;
+ NavSnapshotsIcon.Foreground = SnapshotsPanelOpen ? accent : normal;
+ NavHistoryIcon.Foreground = HistoryPanelOpen ? accent : normal;
+ NavNotesIcon.Foreground = NotesPanelOpen ? accent : normal;
NavTerminalIcon.Foreground = _terminalOpen ? accent : normal;
// Settings and MCP act on the selected agent — disable them while none is open.
diff --git a/src/MandoCode.Desktop/MainWindow.Notes.cs b/src/MandoCode.Desktop/MainWindow.Notes.cs
new file mode 100644
index 0000000..84666a7
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Notes.cs
@@ -0,0 +1,496 @@
+using MandoCode.Desktop.Services;
+using MandoCode.Services; // OllamaSetupHelper — lists models without needing an agent
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ // ============================================================
+ // Notes panel — the jot pad. The third docked panel, sharing the column with Snapshots and History.
+ //
+ // Notes are APP-WIDE, like both of those: plain text files under ~\.mandocode\notes. A note is
+ // something you want to write down now, which is often between projects or before an agent is even
+ // open, so nothing here requires one. What survives of "which project was this about" is a plain
+ // subfolder: a new note is filed under the active agent's folder name when there is one.
+ //
+ // Two states in one column — the grouped browse list, and the editor that replaces it while a note
+ // is open — with the ask bar pinned below both. The bar's question means different things in each
+ // state (this note / the whole pad), which is all the "mode" there is.
+ // ============================================================
+
+ private readonly NoteStore _notes = new();
+
+ /// Group headings folded shut. Persisted, like the other panels'.
+ private readonly HashSet _collapsedNoteGroups = new();
+
+ /// The note open when the panel last closed, so reopening lands back in it.
+ private string? _lastNotePath;
+
+ /// Model the note assistant answers with. Persisted; falls back to the app-wide default.
+ private string? _noteModel;
+
+ private string _noteFilter = "";
+
+ /// Last discovery result. The panel renders from this, so a keystroke in the search box
+ /// filters in memory instead of walking the pad again.
+ private List _noteCache = new();
+
+ private bool _notesScanned;
+ private bool _lastNoteRestored;
+ private bool _noteModelsFetched;
+
+ // In-memory conversation with the assistant: one thread for the open note, one for the pad. Notes
+ // aren't conversations — these are just enough for "shorter" or "now the second one" to mean
+ // something, and they're deliberately not persisted.
+ private readonly List _noteThread = new();
+ private readonly List _padThread = new();
+ private string? _noteThreadFor;
+ private const int MaxThreadTurns = 12;
+
+ private CancellationTokenSource? _noteAskCts;
+
+ /// Subscribes to the editor and the ask bar. Called once from the constructor — both are
+ /// XAML children that live as long as the window, so these never need detaching.
+ private void WireNotesPanel()
+ {
+ NoteEditor.BackRequested += ShowNoteBrowse;
+
+ NoteEditor.Stored += note =>
+ {
+ UpsertNoteCache(note);
+ _lastNotePath = note.Path;
+ };
+
+ NoteEditor.Renamed += note =>
+ {
+ _lastNotePath = note.Path;
+ SavePanelState();
+ _ = RefreshNotesAsync(); // the old path is gone from disk; rescan rather than patch
+ };
+
+ NoteEditor.Deleted += _ => ShowNoteBrowse();
+
+ NoteAsk.Submitted += question => _ = AskNotesAsync(question);
+ NoteAsk.CancelRequested += () => _noteAskCts?.Cancel();
+
+ // The two ways assistant output can reach a note — both a button the user pressed.
+ NoteAsk.InsertRequested += text =>
+ {
+ NoteEditor.InsertAtCursor(text);
+ NoteAsk.ClearReply();
+ };
+ NoteAsk.ReplaceRequested += text =>
+ {
+ NoteEditor.ReplaceBody(text);
+ NoteAsk.ClearReply();
+ };
+
+ NoteAsk.ModelChanged += model =>
+ {
+ _noteModel = model;
+ SavePanelState();
+ };
+ }
+
+ // ---- panel open/close ----
+
+ private void NavNotes_Click(object sender, RoutedEventArgs e)
+ {
+ if (NotesPanelOpen) CloseLeftPanel();
+ else OpenNotes();
+ }
+
+ private void CloseNotes_Click(object sender, RoutedEventArgs e) => CloseLeftPanel();
+
+ private void OpenNotes()
+ {
+ PopulateNotes(); // instant, from the cache
+ NoteAsk.SetModelLabel(CurrentNoteModel()); // name the model now; the list fills in behind it
+ ShowLeftPanel(LeftPanel.Notes);
+ _ = RefreshNotesAsync(); // then the truth, from disk
+ _ = RefreshNoteModelsAsync();
+ }
+
+ /// Rescans the pad off the UI thread, then repaints. Fired on panel open and after any
+ /// create/rename/delete — never on a keystroke.
+ private async Task RefreshNotesAsync()
+ {
+ List found;
+ try
+ {
+ found = (await Task.Run(() => _notes.Discover())).ToList();
+ }
+ catch (Exception ex)
+ {
+ CrashLog.Write("notes-discovery", ex);
+ return;
+ }
+
+ _noteCache = found;
+ _notesScanned = true;
+
+ if (!NotesPanelOpen) return;
+ PopulateNotes();
+ RestoreLastNoteOnce();
+ }
+
+ /// Fills the assistant's model picker. Best-effort and once per launch: the list comes from
+ /// Ollama, which may not be running — that's what the picker's own empty state says.
+ private async Task RefreshNoteModelsAsync()
+ {
+ if (_noteModelsFetched) return;
+ _noteModelsFetched = true;
+
+ var endpoint = _configs.Defaults.OllamaEndpoint;
+ OllamaSetupHelper.ListModelsResult result;
+ try
+ {
+ result = await OllamaSetupHelper.ListModelsWithStatusAsync(endpoint);
+ }
+ catch
+ {
+ NoteAsk.SetModels(Array.Empty(), CurrentNoteModel());
+ return;
+ }
+
+ var models = result.Ok ? result.Models : new List();
+ // A remembered model that's since been removed shouldn't silently answer as something else.
+ if (_noteModel != null && !models.Contains(_noteModel, StringComparer.OrdinalIgnoreCase))
+ _noteModel = null;
+
+ NoteAsk.SetModels(models, CurrentNoteModel());
+ }
+
+ /// The assistant's model: the user's pick for this panel, else the app-wide default.
+ private string? CurrentNoteModel()
+ {
+ if (!string.IsNullOrWhiteSpace(_noteModel)) return _noteModel;
+ var fallback = _configs.Defaults.GetEffectiveModelName();
+ return string.IsNullOrWhiteSpace(fallback) ? null : fallback;
+ }
+
+ /// Reopens the note that was open when the panel last closed — once per launch, and only if
+ /// it's still on disk. Notes are come-back-to things; landing on the list every launch would mean
+ /// re-finding your note every time.
+ private void RestoreLastNoteOnce()
+ {
+ if (_lastNoteRestored) return;
+ _lastNoteRestored = true;
+
+ if (_lastNotePath == null || NoteEditor.Current != null) return;
+ var note = _noteCache.FirstOrDefault(n =>
+ string.Equals(n.Path, _lastNotePath, StringComparison.OrdinalIgnoreCase));
+ if (note != null) OpenNote(note);
+ }
+
+ // ---- browse list ----
+
+ ///
+ /// Paints the panel for its current state. Both states share a grid row, so this is also what
+ /// switches between them: an open note means editor, otherwise the grouped list. Safe to call any
+ /// time — everything is derived from state.
+ ///
+ private void PopulateNotes()
+ {
+ var editing = NoteEditor.Current != null;
+
+ NotesBlurb.Visibility = editing ? Visibility.Collapsed : Visibility.Visible;
+ NoteEditor.Visibility = editing ? Visibility.Visible : Visibility.Collapsed;
+ NoteAsk.SetMode(editing, NoteEditor.Current?.Title);
+ RefreshNoteAskScope();
+
+ if (editing)
+ {
+ NotesSearch.Visibility = Visibility.Collapsed;
+ NotesScroller.Visibility = Visibility.Collapsed;
+ NotesEmpty.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ var padEmpty = _noteCache.Count == 0;
+ NotesSearch.Visibility = padEmpty ? Visibility.Collapsed : Visibility.Visible;
+
+ var q = _noteFilter;
+ var rows = _noteCache
+ .Where(n => NoteStore.Matches(n, q))
+ .Select(n => new NoteRow
+ {
+ Note = n,
+ MatchSnippet = string.IsNullOrEmpty(q) ? "" : NoteStore.MatchSnippet(n, q) ?? "",
+ })
+ .ToList();
+
+ // Grouped by folder, freshest group first, newest note first inside each — the same ordering as
+ // Snapshots and History, so the three panels behave identically.
+ var groups = rows
+ .GroupBy(r => r.Note.GroupLabel)
+ .OrderByDescending(g => g.Max(r => r.Note.ModifiedAt))
+ .Select(g => new NoteGroup(g.Key, g.OrderByDescending(r => r.Note.ModifiedAt))
+ {
+ IsExpanded = !_collapsedNoteGroups.Contains(g.Key),
+ })
+ .ToList();
+
+ NotesList.ItemsSource = groups;
+
+ var nothingToShow = groups.Count == 0;
+ NotesEmpty.Text = !_notesScanned
+ ? "Looking for notes…"
+ : padEmpty
+ ? "No notes yet. Hit New and start typing — it's saved as you go, into "
+ + $"{_notes.Root}. With an agent open, the note is filed under that project's folder."
+ : $"No notes match “{q}”.";
+ NotesEmpty.Visibility = nothingToShow ? Visibility.Visible : Visibility.Collapsed;
+ NotesScroller.Visibility = nothingToShow ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ /// Tells the user what the assistant would actually be given. A capped read that looks total
+ /// is the one thing a "ask about all my notes" box must not do.
+ private void RefreshNoteAskScope()
+ {
+ if (NoteEditor.Current != null)
+ {
+ NoteAsk.SetScopeNote("reads this note as it is right now");
+ return;
+ }
+
+ var indexed = Math.Min(_noteCache.Count, NoteAssistant.MaxIndexedNotes);
+ var bodies = NoteAssistant.CountBodiesSent(NotesForFullRead());
+
+ NoteAsk.SetScopeNote(_noteCache.Count == 0
+ ? "nothing written yet"
+ : $"{indexed} note{(indexed == 1 ? "" : "s")} listed · {bodies} read in full"
+ + (string.IsNullOrEmpty(_noteFilter) ? "" : $" (matching “{_noteFilter}”)"));
+ }
+
+ ///
+ /// Which notes get their full text sent with a pad-wide question: whatever the search box is
+ /// matching, else the most recently touched. Search is the user saying "these are the ones I mean",
+ /// which is a better selector than anything the app could guess — and it keeps the request bounded.
+ ///
+ private List NotesForFullRead()
+ {
+ var pool = string.IsNullOrEmpty(_noteFilter)
+ ? _noteCache
+ : _noteCache.Where(n => NoteStore.Matches(n, _noteFilter)).ToList();
+
+ return pool.Take(NoteAssistant.MaxFullNotes).ToList();
+ }
+
+ private void NotesSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+ {
+ // Only the user typing — not the programmatic Text changes a repopulate can cause.
+ if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
+ _noteFilter = sender.Text?.Trim() ?? "";
+ PopulateNotes();
+ }
+
+ // The group object is kept in sync as well as the set, so a recycled ListView container re-reads the
+ // correct state from its OneTime IsExpanded binding (same as the other two panels).
+ private void NoteGroup_Expanding(Expander sender, ExpanderExpandingEventArgs args)
+ {
+ if (sender.Tag is not NoteGroup g) return;
+ g.IsExpanded = true;
+ _collapsedNoteGroups.Remove(g.Project);
+ SavePanelState();
+ }
+
+ private void NoteGroup_Collapsed(Expander sender, ExpanderCollapsedEventArgs args)
+ {
+ if (sender.Tag is not NoteGroup g) return;
+ g.IsExpanded = false;
+ _collapsedNoteGroups.Add(g.Project);
+ SavePanelState();
+ }
+
+ /// Replaces one note in the cache (matched by path) so the list repaints without a rescan.
+ /// Adds it if it's new.
+ private void UpsertNoteCache(NoteEntry note)
+ {
+ var i = _noteCache.FindIndex(n => string.Equals(n.Path, note.Path, StringComparison.OrdinalIgnoreCase));
+ if (i >= 0) _noteCache[i] = note;
+ else _noteCache.Insert(0, note);
+ }
+
+ // ---- open / create / delete ----
+
+ private void OpenNote(NoteEntry note)
+ {
+ // A different note is a different subject: the assistant's thread and any pending reply from the
+ // previous note would be answering about text that's no longer on screen.
+ if (!string.Equals(_noteThreadFor, note.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ _noteThread.Clear();
+ _noteThreadFor = note.Path;
+ NoteAsk.ClearReply();
+ }
+
+ NoteEditor.Open(note);
+ _lastNotePath = note.Path;
+ SavePanelState();
+ PopulateNotes(); // switches the panel into edit state
+ NoteEditor.FocusEditor();
+ }
+
+ private void ShowNoteBrowse()
+ {
+ NoteEditor.Close(); // saves and stops watching
+ _lastNotePath = null;
+ _noteThread.Clear();
+ _noteThreadFor = null;
+ NoteAsk.ClearReply();
+ SavePanelState();
+ PopulateNotes();
+ _ = RefreshNotesAsync();
+ }
+
+ private async void NoteOpen_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not NoteEntry note) return;
+
+ // The card may be seconds stale — the note could have been edited or deleted outside the app.
+ var fresh = _notes.Reread(note);
+ if (fresh == null)
+ {
+ await SayAsync("That note is gone", $"“{note.FileName}” is no longer on disk. The list has been refreshed.");
+ _ = RefreshNotesAsync();
+ return;
+ }
+ OpenNote(fresh);
+ }
+
+ private async void NoteNew_Click(object sender, RoutedEventArgs e)
+ {
+ // Filed under the folder you're working in, when there is one. No agent is fine — that's the
+ // point of a jot pad, and the note simply lands unfiled.
+ var root = _sessions.Active?.ProjectRoot.ProjectRoot;
+ var group = string.IsNullOrWhiteSpace(root) ? null : ProjectDisplay.ProjectLabel(root);
+
+ NoteEntry note;
+ try
+ {
+ note = _notes.Create(group: group);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ await SayAsync("Couldn't create the note", $"Writing to {_notes.Root} failed — {ex.Message}");
+ return;
+ }
+
+ UpsertNoteCache(note);
+ OpenNote(note);
+ _ = RefreshNotesAsync();
+ }
+
+ private async void NoteDelete_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not NoteEntry note) return;
+
+ var dialog = new ContentDialog
+ {
+ Title = "Delete note",
+ Content = $"Delete “{note.FileName}”? The file is removed from disk. This can't be undone.",
+ PrimaryButtonText = "Delete",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = Content.XamlRoot,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ if (!_notes.Delete(note))
+ {
+ await SayAsync("Couldn't delete that note", $"“{note.FileName}” may be open in another program.");
+ return;
+ }
+
+ _noteCache.RemoveAll(n => string.Equals(n.Path, note.Path, StringComparison.OrdinalIgnoreCase));
+ PopulateNotes();
+ _ = RefreshNotesAsync();
+ }
+
+ // ---- the assistant ----
+
+ ///
+ /// Asks about the open note, or about the pad when you're on the list. Streams into the bar's reply
+ /// strip; the reply reaches a note only if the user then presses Insert or Replace.
+ ///
+ /// The note's text is taken from the live editor buffer rather than from disk, so the model sees what
+ /// you see — including the last few seconds of typing that autosave hasn't written yet.
+ ///
+ private async Task AskNotesAsync(string question)
+ {
+ var model = CurrentNoteModel();
+ if (string.IsNullOrWhiteSpace(model))
+ {
+ NoteAsk.BeginReply("no model");
+ NoteAsk.EndReply(error: "No model selected. Pick one from the chip below the prompt — "
+ + "or start Ollama (ollama serve) if the list is empty.");
+ return;
+ }
+
+ var endpoint = _configs.Defaults.OllamaEndpoint;
+ var note = NoteEditor.Current;
+ var onNote = note != null;
+ var thread = onNote ? _noteThread : _padThread;
+
+ var selection = onNote ? NoteEditor.SelectionText : "";
+ NoteAsk.SetHasSelection(selection.Length > 0);
+
+ NoteAsk.BeginReply(onNote
+ ? $"{model} · {(selection.Length > 0 ? "your selection" : note!.Title)}"
+ : $"{model} · your notes");
+
+ _noteAskCts?.Cancel();
+ _noteAskCts = new CancellationTokenSource();
+ var ct = _noteAskCts.Token;
+
+ void OnDelta(string delta) => OnUi(() => NoteAsk.AppendDelta(delta));
+
+ try
+ {
+ if (onNote)
+ {
+ await NoteAssistant.AskAboutNoteAsync(
+ endpoint, model!, note!.Title, NoteEditor.Body, selection,
+ thread, question, OnDelta, ct);
+ }
+ else
+ {
+ await NoteAssistant.AskAboutPadAsync(
+ endpoint, model!, _noteCache, NotesForFullRead(),
+ thread, question, OnDelta, ct);
+ }
+
+ NoteAsk.EndReply();
+
+ thread.Add(new NoteAssistant.Turn(true, question));
+ thread.Add(new NoteAssistant.Turn(false, NoteAsk.Reply));
+ if (thread.Count > MaxThreadTurns) thread.RemoveRange(0, thread.Count - MaxThreadTurns);
+ }
+ catch (OperationCanceledException)
+ {
+ NoteAsk.EndReply(label: "stopped");
+ }
+ catch (Exception ex)
+ {
+ // Ollama down, model pulled away, endpoint wrong: say which, in the strip.
+ NoteAsk.EndReply(error: $"{ex.Message}\n\nEndpoint: {endpoint} · model: {model}");
+ }
+ }
+
+ /// One-shot informational dialog. The panel's failures are all "the filesystem said no",
+ /// which needs a sentence and an OK, not an InfoBar to dismiss.
+ private async Task SayAsync(string title, string message)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = title,
+ Content = message,
+ CloseButtonText = "OK",
+ XamlRoot = Content.XamlRoot,
+ };
+ await dialog.ShowAsync();
+ }
+}
diff --git a/src/MandoCode.Desktop/MainWindow.Snapshots.cs b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
index 0468801..45c62ab 100644
--- a/src/MandoCode.Desktop/MainWindow.Snapshots.cs
+++ b/src/MandoCode.Desktop/MainWindow.Snapshots.cs
@@ -28,7 +28,7 @@ public sealed partial class MainWindow
private void NavSnapshots_Click(object sender, RoutedEventArgs e)
{
- if (_snapshotsPanelOpen) CloseLeftPanel();
+ if (SnapshotsPanelOpen) CloseLeftPanel();
else OpenSnapshots();
}
@@ -38,18 +38,18 @@ private void OpenSnapshots()
{
MarkSnapshotsSeen(); // opening the panel IS reading it — clear the unread badge
PopulateSnapshots();
- ShowLeftPanel(SnapshotsPanel, snapshots: true);
+ ShowLeftPanel(LeftPanel.Snapshots);
}
- /// Shows one of the two docked panels (Snapshots/History), swapping if the other was
+ /// Shows one of the docked panels (Snapshots/History/Notes), swapping if another was
/// already up (the column stays out — only the contents change) and sliding it in otherwise.
- private void ShowLeftPanel(Border panel, bool snapshots)
+ private void ShowLeftPanel(LeftPanel which)
{
- bool wasOpen = _snapshotsPanelOpen || _historyPanelOpen;
- _snapshotsPanelOpen = snapshots;
- _historyPanelOpen = !snapshots;
- SnapshotsPanel.Visibility = snapshots ? Visibility.Visible : Visibility.Collapsed;
- HistoryPanel.Visibility = snapshots ? Visibility.Collapsed : Visibility.Visible;
+ bool wasOpen = _leftPanel != LeftPanel.None;
+ _leftPanel = which;
+ SnapshotsPanel.Visibility = which == LeftPanel.Snapshots ? Visibility.Visible : Visibility.Collapsed;
+ HistoryPanel.Visibility = which == LeftPanel.History ? Visibility.Visible : Visibility.Collapsed;
+ NotesPanel.Visibility = which == LeftPanel.Notes ? Visibility.Visible : Visibility.Collapsed;
RefreshNavIcons();
if (wasOpen) return; // column already at width — contents swapped, no re-slide
@@ -61,10 +61,17 @@ private void ShowLeftPanel(Border panel, bool snapshots)
private void CloseLeftPanel()
{
- var toHide = _snapshotsPanelOpen ? (FrameworkElement)SnapshotsPanel
- : _historyPanelOpen ? HistoryPanel : null;
- _snapshotsPanelOpen = false;
- _historyPanelOpen = false;
+ var toHide = _leftPanel switch
+ {
+ LeftPanel.Snapshots => (FrameworkElement)SnapshotsPanel,
+ LeftPanel.History => HistoryPanel,
+ LeftPanel.Notes => NotesPanel,
+ _ => null,
+ };
+ // Leaving the Notes panel commits whatever is in the editor — a jot you can't see is a jot
+ // you'd assume was saved (autosave is on a debounce, so it may not have fired yet).
+ if (_leftPanel == LeftPanel.Notes) NoteEditor.FlushPendingSave();
+ _leftPanel = LeftPanel.None;
RefreshNavIcons();
AnimateLeftColumn(0, hideOnDone: toHide);
}
@@ -104,7 +111,7 @@ private void AnimateLeftColumn(double toPx, FrameworkElement? hideOnDone)
private void OnSnapshotsChanged()
{
// A change while you're looking at the panel is already seen; otherwise it's a new unread.
- if (_snapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
+ if (SnapshotsPanelOpen) { MarkSnapshotsSeen(); PopulateSnapshots(); }
else RefreshSnapshotsBadge();
}
@@ -129,10 +136,12 @@ private void MarkSnapshotsSeen()
private DateTimeOffset? _snapshotsSeenAt;
private DateTimeOffset? _historySeenAt;
- /// Writes both panels' fold state and seen-watermarks to disk (survives relaunch).
+ /// Writes every panel's fold state, the seen-watermarks, and the Notes panel's open
+ /// note to disk (all survive relaunch).
private void SavePanelState() => PanelState.Save(new PanelStateShape(
_collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
- _snapshotsSeenAt, _historySeenAt));
+ _snapshotsSeenAt, _historySeenAt,
+ _collapsedNoteGroups.ToList(), _lastNotePath, _noteModel));
// The group object is kept in sync (not just the set) so that when the ListView recycles a
// container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
@@ -228,7 +237,7 @@ private void SnapshotImport_Click(object sender, RoutedEventArgs e)
target.Session.Controller.ImportContext(snap); // arms the active agent's next message
SwitchPage("chat"); // so the "context armed" note is visible in the active tab
- if (_snapshotsPanelOpen) CloseLeftPanel(); // get out of the way — the chat is where the confirmation shows
+ if (SnapshotsPanelOpen) CloseLeftPanel(); // get out of the way — the chat is where the confirmation shows
target.FocusInput();
}
diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs
index 8c09a04..967a935 100644
--- a/src/MandoCode.Desktop/MainWindow.Tabs.cs
+++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs
@@ -87,7 +87,8 @@ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = n
WireHeader(entry);
SelectTab(entry);
- if (_snapshotsPanelOpen) PopulateSnapshots(); // an agent exists now → re-enable Import
+ if (SnapshotsPanelOpen) PopulateSnapshots(); // an agent exists now → re-enable Import
+ if (NotesPanelOpen) PopulateNotes(); // a new note would now be filed under this folder
if (SplitConfigured) RefreshSplitBar(); // offer the new agent in the pane pickers
return entry;
}
diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
index cb2ce79..e8ea231 100644
--- a/src/MandoCode.Desktop/MainWindow.ViewModels.cs
+++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
@@ -82,6 +82,47 @@ public HistoryGroup(string project, IEnumerable it
public Visibility DeleteAllVisibility => Count > 1 ? Visibility.Visible : Visibility.Collapsed;
}
+///
+/// One note as the panel shows it: the note itself plus the search snippet that explains why it
+/// matched. The snippet depends on the current query, not on the file, so it can't live on
+/// (a reading of disk) — same split as History's rows.
+///
+public sealed class NoteRow
+{
+ public required Services.NoteEntry Note { get; init; }
+
+ /// The matching line from the note's body, when the hit came from text the card doesn't
+ /// already show. Empty otherwise — hides the quote block.
+ public string MatchSnippet { get; init; } = "";
+
+ // Proxies, so the card template reads like the other two panels' cards.
+ public string Title => Note.Title;
+ public string FileName => Note.FileName;
+ public string Preview => Note.Preview;
+ public string TimeLabel => Note.TimeLabel;
+ public string SizeLabel => Note.SizeLabel;
+
+ /// An empty note has no preview line to show; the placeholder keeps the card from
+ /// collapsing into a bare title and says what it is.
+ public string PreviewOrPlaceholder =>
+ string.IsNullOrWhiteSpace(Preview) ? "(empty — nothing written yet)" : Preview;
+}
+
+/// A project's notes, as one collapsible group in the Notes panel — the third of the
+/// grouped-by-project panels, alongside and .
+///
+/// Deliberately WITHOUT the "Delete all n" group action those two carry. A snapshot or an archived
+/// conversation is a derived artifact the app made; a note is something the user wrote by hand, and
+/// one button that deletes a folder's worth of writing is a different class of risk.
+public sealed class NoteGroup : List
+{
+ public NoteGroup(string project, IEnumerable items) : base(items) => Project = project;
+
+ public string Project { get; }
+
+ public bool IsExpanded { get; set; } = true;
+}
+
///
/// 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
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index a12f4d5..971b0ac 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -62,6 +62,15 @@
+
+
public sealed record PanelStateShape(
List CollapsedSnapshotGroups,
List CollapsedHistoryGroups,
DateTimeOffset? SnapshotsSeenAt = null,
- DateTimeOffset? HistorySeenAt = null);
+ DateTimeOffset? HistorySeenAt = null,
+ List? CollapsedNoteGroups = null,
+ string? LastNotePath = null,
+ string? NoteModel = null);
///
/// Persists per-panel UI preference — the fold state of the Snapshots and History project groups —
@@ -36,7 +42,10 @@ public static PanelStateShape Load()
shape.CollapsedSnapshotGroups ?? new(),
shape.CollapsedHistoryGroups ?? new(),
shape.SnapshotsSeenAt,
- shape.HistorySeenAt);
+ shape.HistorySeenAt,
+ shape.CollapsedNoteGroups ?? new(),
+ shape.LastNotePath,
+ shape.NoteModel);
}
}
catch { /* corrupt/unreadable — start with everything expanded */ }