From 1e2ce30c8f942144b5f4b9b6f2bdf63851c73dcf Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Mon, 10 Aug 2026 22:46:53 -0500 Subject: [PATCH 01/19] docs: design for Compose tab and post-crash diagnostics viewer Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015MPRzndB5egy4F2A3q5852 --- ...26-08-10-compose-and-diagnostics-design.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md diff --git a/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md b/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md new file mode 100644 index 0000000..c5ce452 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md @@ -0,0 +1,240 @@ +# Compose tab and post-crash diagnostics — design + +Date: 2026-08-10 + +Two independent features for SharpClient: + +1. **Compose** — a dedicated screen for writing multi-line MUSH poses, with a command + prefix and automatic escaping, sent to the active session as a single line. +2. **Diagnostics** — an in-app viewer for the on-device log, plus a "crashed last run" + banner, so a crash can be investigated after the client is restarted. + +They share no code and can be built in either order. + +--- + +## Feature 1 — Compose + +### Problem + +Poses are multi-line prose. The client's input bar is a single-line field, and the wire +protocol needs one line: newlines have to become `%r`, and a literal `%` in the prose has +to become `%%` or the server substitutes it. Doing this by hand is error-prone, so poses +get written elsewhere and pasted in mangled. + +### Formatter + +`SharpClient.Core/Formatting/MushPoseFormatter.cs` — a pure static class, no dependencies, +the whole of the escaping contract in one testable place. + +```csharp +public static string Format(string prefix, string body) +``` + +Steps, in this order: + +1. Normalise line endings: `\r\n` and lone `\r` become `\n`. +2. Escape `%` → `%%`. +3. Trim trailing whitespace from each line; drop trailing blank lines. Interior blank + lines are preserved and become consecutive `%r`s. +4. Replace `\n` → `%r`. This runs **after** step 2 so the inserted `%r` markers are not + themselves escaped. +5. Join prefix and body with the separator rule below. + +Separator rule: + +- A built-in prefix (`say`, `pose`, `semipose`, `@emit`) is followed by one space. +- A custom prefix ending in `=`, `/`, or whitespace is joined verbatim, so `page Bob=` + yields `page Bob=He grins…`. +- Any other custom prefix gets one space. + +Nothing else is escaped. `[`, `]`, `\`, `;` and `,` pass through unchanged and remain +parser syntax on the server. + +**Accepted consequence:** a user who deliberately types `%r`, `%t`, or another +substitution gets it neutered to `%%r`. This is the direct cost of literal `%` escaping. +The Preview mode shows the exact wire text, so the result is visible before sending rather +than surprising afterwards. There is no raw / no-escape mode in this design. + +### View model + +`SharpClient.Core/Presentation/ComposeViewModel.cs` — singleton, constructor dependencies +`ISessionManager` and `IPreferences`, matching the shape of the existing +`SessionsViewModel` (public properties, a `Changed` event, no INotifyPropertyChanged). + +| Member | Behaviour | +| --- | --- | +| `SelectedPrefix` | `PosePrefix` enum: `Say`, `Pose`, `Semipose`, `Emit`, `Custom`. | +| `CustomPrefix` | Free text. Persisted per world through `IPreferences`, key `compose.custom.{ISession.WorldId}`. Loaded when the active session changes. | +| `Body` | The draft text for the active session. | +| `Preview` | `MushPoseFormatter.Format(EffectivePrefix, Body)`. | +| `CanSend` | Active session is `Connected`, `Body` is not blank, and when `SelectedPrefix == Custom` the custom text is not blank. | +| `SendAsync()` | Sends `Preview` on the active session, clears that session's draft, raises `Changed`. | +| `Changed` | Raised on property changes and on session-manager / active-state changes, so `CanSend` re-evaluates when a connection comes back. | + +Drafts live in a `Dictionary` and are pruned when a session closes — +the same pattern `SessionsViewModel` already uses for command history. Switching session +tabs mid-pose does not clobber the draft. + +Registered in `SharpClient.UI/ServiceCollectionExtensions.cs` as a singleton so both hosts +get it from one place. + +### UI + +`SharpClient.UI/Components/ComposeView.razor` plus `Pages/ComposePage.razor` at route +`/compose`, with a fifth nav entry in `MainLayout` between Session and History. + +The view fills the whole tab body — `.sc-content` is a flex column at full height: + +- **Top, fixed height:** the prefix chip row — `say`, `pose`, `semipose`, `@emit`, + `custom`. Selecting `custom` reveals a single-line text field beneath the chips. +- **Middle, `flex: 1`:** in Edit mode a monospace ` + } + + + + + +@code { + [Parameter] + public ComposeViewModel Vm { get; set; } = null!; + + private static readonly (string Label, PosePrefix Value)[] PrefixOptions = + [ + ("say", PosePrefix.Say), + ("pose", PosePrefix.Pose), + ("semipose", PosePrefix.Semipose), + ("@emit", PosePrefix.Emit), + ("custom", PosePrefix.Custom), + ]; + + private bool _previewing; + + protected override void OnInitialized() => Vm.Changed += OnChanged; + + private void OnChanged() => InvokeAsync(StateHasChanged); + + private void OnBodyInput(ChangeEventArgs e) => Vm.Body = e.Value?.ToString() ?? string.Empty; + + private void TogglePreview() => _previewing = !_previewing; + + private void Clear() + { + Vm.Clear(); + _previewing = false; + } + + // Ctrl/Cmd+Enter sends; a bare Enter has to stay a newline in a multi-line composer. + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && (e.CtrlKey || e.MetaKey)) + { + await SendAsync(); + } + } + + private async Task SendAsync() + { + await Vm.SendAsync(); + _previewing = false; + StateHasChanged(); + } + + public void Dispose() => Vm.Changed -= OnChanged; +} +``` + +- [ ] **Step 4: Write the page** + +Create `src/SharpClient.UI/Pages/ComposePage.razor`: + +```razor +@page "/compose" + +@inject ComposeViewModel Vm + +SharpClient · Compose + + +``` + +- [ ] **Step 5: Add the nav entry** + +In `src/SharpClient.UI/Layout/MainLayout.razor`, insert between the `/session` and `/history` `NavLink`s: + +```razor + + + Compose + +``` + +- [ ] **Step 6: Add the styles** + +Append to `src/SharpClient.UI/wwwroot/app.css`: + +```css +/* ── Compose page ──────────────────────────────────────────────── */ +.sc-compose { height: 100%; display: flex; flex-direction: column; gap: 10px; padding: 12px 12px 0; } +.sc-compose-prefixes { display: flex; flex-wrap: wrap; gap: 6px; } +.sc-compose-chip { font-family: var(--mono); font-size: 12px; color: var(--dim); background: var(--panel); border: 1px solid var(--bd); border-radius: 8px; padding: 6px 11px; cursor: pointer; } +.sc-compose-chip-active { color: var(--acc2); background: var(--acc-soft); border-color: var(--acc-line); } +.sc-compose-custom { font-family: var(--mono); font-size: 12px; color: var(--tx); background: var(--outbg); border: 1px solid var(--bd2); border-radius: 8px; padding: 8px 10px; } +.sc-compose-body { flex: 1; min-height: 0; display: flex; } +.sc-compose-input, +.sc-compose-preview { flex: 1; min-width: 0; font-family: var(--mono); font-size: var(--out-fs); line-height: 1.5; color: var(--tx); background: var(--outbg); border: 1px solid var(--bd2); border-radius: 10px; padding: 10px 12px; overflow-y: auto; } +.sc-compose-input { resize: none; } +.sc-compose-preview { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pho); } +.sc-compose-input:focus, +.sc-compose-custom:focus { outline: none; border-color: var(--acc-line); } +.sc-compose-footer { display: flex; align-items: center; gap: 10px; padding: 10px 0 12px; } +.sc-compose-count { font-family: var(--mono); font-size: 11px; color: var(--faint); } +.sc-compose-hint { font-family: var(--mono); font-size: 11px; color: var(--dim); text-decoration: none; } +.sc-compose-actions { margin-left: auto; display: flex; gap: 8px; } +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/ComposeViewTests/*"` + +Expected: all 8 tests pass. + +- [ ] **Step 8: Run every net10.0 suite** + +```bash +dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release +dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release +dotnet run --project tests/SharpClient.Data.Tests/SharpClient.Data.Tests.csproj -c Release +``` + +Expected: all pass. `SessionTabsTests` and `SettingsViewTests` must be unaffected. + +- [ ] **Step 9: Commit** + +```bash +git add src/SharpClient.UI/Components/ComposeView.razor src/SharpClient.UI/Pages/ComposePage.razor src/SharpClient.UI/Layout/MainLayout.razor src/SharpClient.UI/wwwroot/app.css tests/SharpClient.UI.Tests/ComposeViewTests.cs +git commit -m "feat(compose): full-height Compose tab with preview toggle" +``` + +--- + +### Task 4: Move the log store into Core + +This is a pure move plus a constructor change. No behaviour changes — but the write and rotation logic gets its first tests, because it becomes reachable from a test project for the first time. + +**Files:** +- Create: `src/SharpClient.Core/Diagnostics/FileLogStore.cs` (moved from `src/SharpClient.App/Services/FileLogStore.cs`) +- Create: `src/SharpClient.Core/Diagnostics/FileLoggerProvider.cs` (moved from `src/SharpClient.App/Services/FileLoggerProvider.cs`) +- Delete: `src/SharpClient.App/Services/FileLogStore.cs`, `src/SharpClient.App/Services/FileLoggerProvider.cs` +- Modify: `src/SharpClient.Core/SharpClient.Core.csproj`, `src/SharpClient.App/MauiProgram.cs`, `src/SharpClient.App/Platforms/Android/MainActivity.cs`, `src/SharpClient.App/Services/MauiLogExporter.cs` +- Test: `tests/SharpClient.Tests/Diagnostics/FileLogStoreTests.cs` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `SharpClient.Core.Diagnostics.FileLogStore` with `FileLogStore(string logDirectory)`, `string FilePath`, `string BackupPath`, `void Append(string level, string category, string message, Exception? ex = null)`, `void WriteException(string source, Exception? ex)`. Also `SharpClient.Core.Diagnostics.FileLoggerProvider(FileLogStore store, LogLevel minLevel = LogLevel.Information)`. + +- [ ] **Step 1: Add the logging abstraction package to Core** + +In `src/SharpClient.Core/SharpClient.Core.csproj`, add to the existing `ItemGroup`: + +```xml + +``` + +- [ ] **Step 2: Write the failing tests** + +Create `tests/SharpClient.Tests/Diagnostics/FileLogStoreTests.cs`: + +```csharp +using SharpClient.Core.Diagnostics; + +namespace SharpClient.Tests.Diagnostics; + +public sealed class FileLogStoreTests +{ + private static string NewTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "sharpclient-tests", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(dir); + return dir; + } + + [Test] + public async Task ConstructorCreatesTheDirectory() + { + var dir = Path.Combine(NewTempDir(), "nested", "logs"); + + var store = new FileLogStore(dir); + + await Assert.That(Directory.Exists(dir)).IsTrue(); + await Assert.That(store.FilePath).IsEqualTo(Path.Combine(dir, "sharpclient.log")); + } + + [Test] + public async Task AppendWritesLevelCategoryAndMessage() + { + var store = new FileLogStore(NewTempDir()); + + store.Append("Information", "App", "hello"); + + var text = File.ReadAllText(store.FilePath); + await Assert.That(text).Contains("[Information]"); + await Assert.That(text).Contains("App: hello"); + } + + [Test] + public async Task AppendOmitsTheCategorySeparatorWhenCategoryIsEmpty() + { + var store = new FileLogStore(NewTempDir()); + + store.Append("Information", string.Empty, "bare"); + + await Assert.That(File.ReadAllText(store.FilePath)).Contains("[Information] bare"); + } + + [Test] + public async Task AppendIncludesExceptionDetailOnFollowingLines() + { + var store = new FileLogStore(NewTempDir()); + var ex = new InvalidOperationException("boom"); + + store.Append("Error", "Session", "failed", ex); + + var text = File.ReadAllText(store.FilePath); + await Assert.That(text).Contains("Session: failed"); + await Assert.That(text).Contains("System.InvalidOperationException: boom"); + } + + [Test] + public async Task WriteExceptionRecordsACrashLevelEntry() + { + var store = new FileLogStore(NewTempDir()); + + store.WriteException("AppDomain", new InvalidOperationException("boom")); + + var text = File.ReadAllText(store.FilePath); + await Assert.That(text).Contains("[CRASH]"); + await Assert.That(text).Contains("AppDomain: boom"); + } + + [Test] + public async Task WriteExceptionToleratesANullException() + { + var store = new FileLogStore(NewTempDir()); + + store.WriteException("AppDomain", null); + + await Assert.That(File.ReadAllText(store.FilePath)).Contains("(no exception object)"); + } + + [Test] + public async Task OversizeLogRotatesIntoTheBackupFile() + { + var store = new FileLogStore(NewTempDir()); + var big = new string('x', 600 * 1024); + + store.Append("Information", "App", big); + store.Append("Information", "App", "after rotation"); + + await Assert.That(File.Exists(store.BackupPath)).IsTrue(); + await Assert.That(File.ReadAllText(store.BackupPath)).Contains(big); + await Assert.That(File.ReadAllText(store.FilePath)).Contains("after rotation"); + await Assert.That(File.ReadAllText(store.FilePath)).DoesNotContain(big); + } + + [Test] + public async Task RotationReplacesAnExistingBackup() + { + var store = new FileLogStore(NewTempDir()); + var big = new string('x', 600 * 1024); + + store.Append("Information", "App", "first generation"); + store.Append("Information", "App", big); + store.Append("Information", "App", "second generation trigger"); + store.Append("Information", "App", big); + store.Append("Information", "App", "final"); + + await Assert.That(File.ReadAllText(store.BackupPath)).DoesNotContain("first generation"); + await Assert.That(File.ReadAllText(store.FilePath)).Contains("final"); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/FileLogStoreTests/*"` + +Expected: build failure — `SharpClient.Core.Diagnostics.FileLogStore` does not exist. + +- [ ] **Step 4: Move the store** + +Create `src/SharpClient.Core/Diagnostics/FileLogStore.cs` — the existing `src/SharpClient.App/Services/FileLogStore.cs` with a changed namespace and constructor, then delete the App copy: + +```csharp +using System.Globalization; +using System.Text; + +namespace SharpClient.Core.Diagnostics; + +/// +/// Thread-safe, append-only diagnostics log written to a caller-supplied directory, with simple +/// size-based rotation (current file + one rolled backup). It is the single sink for both +/// (framework/app ILogger output) and the global +/// unhandled-exception hooks, so a crash that takes the process down still leaves its stack trace on +/// disk. +/// +public sealed class FileLogStore +{ + // Keep the log small enough to share over chat but large enough to hold the lead-up to a crash. + private const long MaxBytes = 512 * 1024; + + private readonly object _gate = new(); + + /// Absolute path to the active log file. + public string FilePath { get; } + + /// Absolute path to the single rolled-over backup. + public string BackupPath { get; } + + public FileLogStore(string logDirectory) + { + Directory.CreateDirectory(logDirectory); + FilePath = Path.Combine(logDirectory, "sharpclient.log"); + BackupPath = FilePath + ".1"; + } + + /// Appends a single timestamped entry. Never throws — logging must not crash the app. + public void Append(string level, string category, string message, Exception? ex = null) + => Write(FormatEntry(level, category, message, ex)); + + /// Records an unhandled exception captured by one of the global hooks. + public void WriteException(string source, Exception? ex) + => Write(FormatEntry("CRASH", source, ex?.Message ?? "(no exception object)", ex)); + + private static string FormatEntry(string level, string category, string message, Exception? ex) + { + var sb = new StringBuilder(256); + sb.Append(DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)); + sb.Append(" [").Append(level).Append("] "); + if (!string.IsNullOrEmpty(category)) + { + sb.Append(category).Append(": "); + } + + sb.Append(message); + if (ex is not null) + { + sb.Append('\n').Append(ex); + } + + sb.Append('\n'); + return sb.ToString(); + } + + private void Write(string text) + { + lock (_gate) + { + try + { + RotateIfNeeded(); + File.AppendAllText(FilePath, text); + } + catch + { + // Swallow: a failed log write must never propagate into the running app. + } + } + } + + private void RotateIfNeeded() + { + try + { + var fi = new FileInfo(FilePath); + if (!fi.Exists || fi.Length <= MaxBytes) + { + return; + } + + if (File.Exists(BackupPath)) + { + File.Delete(BackupPath); + } + + File.Move(FilePath, BackupPath); + } + catch + { + // If rotation fails, fall through and keep appending to the existing file. + } + } +} +``` + +- [ ] **Step 5: Move the logger provider** + +Create `src/SharpClient.Core/Diagnostics/FileLoggerProvider.cs` with the exact contents of `src/SharpClient.App/Services/FileLoggerProvider.cs`, changing only the namespace to `SharpClient.Core.Diagnostics`. Then delete the App copy. + +- [ ] **Step 6: Update the MAUI host** + +In `src/SharpClient.App/MauiProgram.cs`, `SharpClient.Core.Diagnostics` is already imported via the existing `using SharpClient.Core.Diagnostics;`. Change the construction line so the directory is supplied by the host: + +```csharp + var logStore = new FileLogStore(Path.Combine(FileSystem.AppDataDirectory, "logs")); +``` + +Update the comment above it — it currently says the log "lives under the app's private data dir"; keep that sentence accurate now that the path is passed in. + +In `src/SharpClient.App/Platforms/Android/MainActivity.cs`, add `using SharpClient.Core.Diagnostics;` (the `using SharpClient.App.Services;` line stays only if something else in the file needs it — it does not, so remove it). + +In `src/SharpClient.App/Services/MauiLogExporter.cs`, add `using SharpClient.Core.Diagnostics;`. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/FileLogStoreTests/*"` + +Expected: all 8 tests pass. + +- [ ] **Step 8: Verify the Android head still compiles** + +Run: `dotnet build src/SharpClient.App/SharpClient.App.csproj -f net10.0-android -c Release` + +Expected: build succeeds. This needs JDK 17 and the maui-android workload. If the workload is unavailable in this environment, say so explicitly in the task report rather than skipping silently — CI's `android-build` job will catch it either way. + +- [ ] **Step 9: Commit** + +```bash +git add -A src/SharpClient.Core src/SharpClient.App tests/SharpClient.Tests/Diagnostics +git commit -m "refactor(diagnostics): move FileLogStore and FileLoggerProvider into Core" +``` + +--- + +### Task 5: LogEntryParser + +**Files:** +- Create: `src/SharpClient.Core/Diagnostics/LogEntry.cs` +- Create: `src/SharpClient.Core/Diagnostics/LogEntryParser.cs` +- Test: `tests/SharpClient.Tests/Diagnostics/LogEntryParserTests.cs` + +**Interfaces:** +- Consumes: the on-disk format written by `FileLogStore.FormatEntry` (Task 4). +- Produces: + - `sealed record LogEntry(DateTimeOffset Timestamp, string Level, string Category, string Message, string? Detail)` + - `sealed record CrashReport(DateTimeOffset Timestamp, string Source, string Message, string Detail)` + - `static IReadOnlyList LogEntryParser.Parse(string text)` — oldest first. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/SharpClient.Tests/Diagnostics/LogEntryParserTests.cs`: + +```csharp +using SharpClient.Core.Diagnostics; + +namespace SharpClient.Tests.Diagnostics; + +public sealed class LogEntryParserTests +{ + [Test] + public async Task ParsesTimestampLevelCategoryAndMessage() + { + var entries = LogEntryParser.Parse("2026-08-10 23:41:02.123 -05:00 [Information] App: started\n"); + + await Assert.That(entries).HasCount().EqualTo(1); + await Assert.That(entries[0].Level).IsEqualTo("Information"); + await Assert.That(entries[0].Category).IsEqualTo("App"); + await Assert.That(entries[0].Message).IsEqualTo("started"); + await Assert.That(entries[0].Detail).IsNull(); + await Assert.That(entries[0].Timestamp.Offset).IsEqualTo(TimeSpan.FromHours(-5)); + await Assert.That(entries[0].Timestamp.Year).IsEqualTo(2026); + } + + [Test] + public async Task ParsesAnEntryWithNoCategory() + { + var entries = LogEntryParser.Parse("2026-08-10 23:41:02.123 -05:00 [Information] bare message\n"); + + await Assert.That(entries[0].Category).IsEqualTo(string.Empty); + await Assert.That(entries[0].Message).IsEqualTo("bare message"); + } + + [Test] + public async Task AMessageContainingColonSpaceIsNotMistakenForACategory() + { + var entries = LogEntryParser.Parse("2026-08-10 23:41:02.123 -05:00 [Information] NAWS fit: cols=78\n"); + + await Assert.That(entries[0].Category).IsEqualTo(string.Empty); + await Assert.That(entries[0].Message).IsEqualTo("NAWS fit: cols=78"); + } + + [Test] + public async Task ContinuationLinesBecomeTheDetail() + { + const string text = """ + 2026-08-10 23:41:02.123 -05:00 [CRASH] AppDomain: boom + System.InvalidOperationException: boom + at SharpClient.Core.Sessions.Session.SendAsync(String line) + at SharpClient.UI.Components.InputBar.SendAsync() + + """; + + var entries = LogEntryParser.Parse(text); + + await Assert.That(entries).HasCount().EqualTo(1); + await Assert.That(entries[0].Message).IsEqualTo("boom"); + await Assert.That(entries[0].Detail).Contains("System.InvalidOperationException: boom"); + await Assert.That(entries[0].Detail).Contains("at SharpClient.UI.Components.InputBar.SendAsync()"); + } + + [Test] + public async Task MultipleEntriesAreReturnedOldestFirst() + { + const string text = """ + 2026-08-10 23:41:02.123 -05:00 [Information] App: first + 2026-08-10 23:41:03.456 -05:00 [Warning] App: second + + """; + + var entries = LogEntryParser.Parse(text); + + await Assert.That(entries).HasCount().EqualTo(2); + await Assert.That(entries[0].Message).IsEqualTo("first"); + await Assert.That(entries[1].Message).IsEqualTo("second"); + } + + [Test] + public async Task TextBeforeTheFirstHeaderIsDiscarded() + { + const string text = """ + at SomeTruncatedStackFrame() + 2026-08-10 23:41:02.123 -05:00 [Information] App: real entry + + """; + + var entries = LogEntryParser.Parse(text); + + await Assert.That(entries).HasCount().EqualTo(1); + await Assert.That(entries[0].Message).IsEqualTo("real entry"); + } + + [Test] + public async Task TextWithNoHeadersYieldsNoEntries() + { + var entries = LogEntryParser.Parse("garbage\nmore garbage\n"); + + await Assert.That(entries).IsEmpty(); + } + + [Test] + public async Task EmptyTextYieldsNoEntries() + { + await Assert.That(LogEntryParser.Parse(string.Empty)).IsEmpty(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/LogEntryParserTests/*"` + +Expected: build failure — `LogEntryParser` does not exist. + +- [ ] **Step 3: Write the records** + +Create `src/SharpClient.Core/Diagnostics/LogEntry.cs`: + +```csharp +namespace SharpClient.Core.Diagnostics; + +/// One parsed line of the diagnostics log; holds an attached stack trace. +public sealed record LogEntry( + DateTimeOffset Timestamp, + string Level, + string Category, + string Message, + string? Detail); + +/// The crash recorded by the previous run, surfaced at launch. +public sealed record CrashReport( + DateTimeOffset Timestamp, + string Source, + string Message, + string Detail); +``` + +- [ ] **Step 4: Write the parser** + +Create `src/SharpClient.Core/Diagnostics/LogEntryParser.cs`: + +```csharp +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace SharpClient.Core.Diagnostics; + +/// Reverses 's line format so the log can be shown in the app. +public static partial class LogEntryParser +{ + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff zzz"; + + [GeneratedRegex( + @"^(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}) \[(?[^\]]+)\] (?.*)$")] + private static partial Regex HeaderPattern(); + + // A category is a type name, so it never contains whitespace. Requiring that keeps a message + // like "NAWS fit: cols=78" from being split into a bogus category. + [GeneratedRegex(@"^(?[^\s:]+): (?.*)$")] + private static partial Regex CategoryPattern(); + + public static IReadOnlyList Parse(string text) + { + var entries = new List(); + if (string.IsNullOrEmpty(text)) + { + return entries; + } + + DateTimeOffset timestamp = default; + var level = string.Empty; + var category = string.Empty; + var message = string.Empty; + StringBuilder? detail = null; + var open = false; + + foreach (var raw in text.Replace("\r\n", "\n").Split('\n')) + { + var header = HeaderPattern().Match(raw); + if (header.Success) + { + if (open) + { + entries.Add(Build(timestamp, level, category, message, detail)); + } + + timestamp = DateTimeOffset.ParseExact( + header.Groups["ts"].Value, TimestampFormat, CultureInfo.InvariantCulture); + level = header.Groups["level"].Value; + var rest = header.Groups["rest"].Value; + var split = CategoryPattern().Match(rest); + category = split.Success ? split.Groups["category"].Value : string.Empty; + message = split.Success ? split.Groups["message"].Value : rest; + detail = null; + open = true; + continue; + } + + // Lines before the first header are the tail of an entry that rotation cut in half. + if (!open || raw.Length == 0) + { + continue; + } + + detail ??= new StringBuilder(); + if (detail.Length > 0) + { + detail.Append('\n'); + } + + detail.Append(raw); + } + + if (open) + { + entries.Add(Build(timestamp, level, category, message, detail)); + } + + return entries; + } + + private static LogEntry Build( + DateTimeOffset timestamp, string level, string category, string message, StringBuilder? detail) + => new(timestamp, level, category, message, detail?.ToString()); +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/LogEntryParserTests/*"` + +Expected: all 8 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/SharpClient.Core/Diagnostics/LogEntry.cs src/SharpClient.Core/Diagnostics/LogEntryParser.cs tests/SharpClient.Tests/Diagnostics/LogEntryParserTests.cs +git commit -m "feat(diagnostics): parse the on-disk log back into entries" +``` + +--- + +### Task 6: ILogReader, FileLogReader, and the crash marker + +**Files:** +- Modify: `src/SharpClient.Core/Diagnostics/FileLogStore.cs` +- Create: `src/SharpClient.Core/Diagnostics/ILogReader.cs` +- Create: `src/SharpClient.Core/Diagnostics/FileLogReader.cs` +- Modify: `src/SharpClient.App/MauiProgram.cs`, `src/SharpClient.Web/Program.cs` +- Test: `tests/SharpClient.Tests/Diagnostics/FileLogReaderTests.cs` + +**Interfaces:** +- Consumes: `FileLogStore` (Task 4), `LogEntry`, `CrashReport`, `LogEntryParser.Parse` (Task 5). +- Produces: + - `FileLogStore.CrashMarkerPath` (string property). + - `interface ILogReader { bool IsAvailable { get; } Task> ReadAsync(int maxEntries = 500); Task GetPendingCrashAsync(); Task DismissCrashAsync(); Task ClearAsync(); }` + - `sealed class FileLogReader(FileLogStore store) : ILogReader` + - `sealed class NoopLogReader : ILogReader` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/SharpClient.Tests/Diagnostics/FileLogReaderTests.cs`: + +```csharp +using SharpClient.Core.Diagnostics; + +namespace SharpClient.Tests.Diagnostics; + +public sealed class FileLogReaderTests +{ + private static (FileLogStore store, FileLogReader reader) Build() + { + var dir = Path.Combine(Path.GetTempPath(), "sharpclient-tests", Guid.NewGuid().ToString("n")); + var store = new FileLogStore(dir); + return (store, new FileLogReader(store)); + } + + [Test] + public async Task ReadReturnsNewestFirst() + { + var (store, reader) = Build(); + store.Append("Information", "App", "first"); + store.Append("Information", "App", "second"); + + var entries = await reader.ReadAsync(); + + await Assert.That(entries).HasCount().EqualTo(2); + await Assert.That(entries[0].Message).IsEqualTo("second"); + await Assert.That(entries[1].Message).IsEqualTo("first"); + } + + [Test] + public async Task ReadReturnsEmptyWhenNothingHasBeenLogged() + { + var (_, reader) = Build(); + + await Assert.That(await reader.ReadAsync()).IsEmpty(); + } + + [Test] + public async Task ReadMergesTheRotatedBackupBeforeTheCurrentFile() + { + var (store, reader) = Build(); + store.Append("Information", "App", "oldest"); + store.Append("Information", "App", new string('x', 600 * 1024)); + store.Append("Information", "App", "newest"); + + var entries = await reader.ReadAsync(); + + await Assert.That(entries[0].Message).IsEqualTo("newest"); + await Assert.That(entries[^1].Message).IsEqualTo("oldest"); + } + + [Test] + public async Task MaxEntriesKeepsTheNewest() + { + var (store, reader) = Build(); + for (var i = 0; i < 10; i++) + { + store.Append("Information", "App", $"entry {i}"); + } + + var entries = await reader.ReadAsync(maxEntries: 3); + + await Assert.That(entries).HasCount().EqualTo(3); + await Assert.That(entries[0].Message).IsEqualTo("entry 9"); + await Assert.That(entries[2].Message).IsEqualTo("entry 7"); + } + + [Test] + public async Task NoPendingCrashOnAFreshStore() + { + var (_, reader) = Build(); + + await Assert.That(await reader.GetPendingCrashAsync()).IsNull(); + } + + [Test] + public async Task WriteExceptionLeavesAPendingCrash() + { + var (store, reader) = Build(); + store.WriteException("AppDomain", new InvalidOperationException("boom")); + + var report = await reader.GetPendingCrashAsync(); + + await Assert.That(report).IsNotNull(); + await Assert.That(report!.Source).IsEqualTo("AppDomain"); + await Assert.That(report.Message).IsEqualTo("boom"); + await Assert.That(report.Detail).Contains("System.InvalidOperationException"); + } + + [Test] + public async Task DismissClearsThePendingCrashButKeepsTheLog() + { + var (store, reader) = Build(); + store.WriteException("AppDomain", new InvalidOperationException("boom")); + + await reader.DismissCrashAsync(); + + await Assert.That(await reader.GetPendingCrashAsync()).IsNull(); + await Assert.That(await reader.ReadAsync()).IsNotEmpty(); + } + + [Test] + public async Task ClearDeletesTheLogFilesButNotThePendingCrash() + { + var (store, reader) = Build(); + store.WriteException("AppDomain", new InvalidOperationException("boom")); + + await reader.ClearAsync(); + + await Assert.That(await reader.ReadAsync()).IsEmpty(); + await Assert.That(File.Exists(store.FilePath)).IsFalse(); + await Assert.That(await reader.GetPendingCrashAsync()).IsNotNull(); + } + + [Test] + public async Task NoopReaderIsUnavailableAndEmpty() + { + var reader = new NoopLogReader(); + + await Assert.That(reader.IsAvailable).IsFalse(); + await Assert.That(await reader.ReadAsync()).IsEmpty(); + await Assert.That(await reader.GetPendingCrashAsync()).IsNull(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/FileLogReaderTests/*"` + +Expected: build failure — `FileLogReader` does not exist. + +- [ ] **Step 3: Add the crash marker to the store** + +In `src/SharpClient.Core/Diagnostics/FileLogStore.cs`, add the property beside `BackupPath`: + +```csharp + /// + /// Sidecar holding the most recent crash block. Checking one small file at launch is cheaper than + /// scanning the log, and it survives rotation. + /// + public string CrashMarkerPath { get; } +``` + +Set it in the constructor: + +```csharp + CrashMarkerPath = Path.Combine(logDirectory, "last-crash.txt"); +``` + +Replace `WriteException` with: + +```csharp + /// Records an unhandled exception captured by one of the global hooks. + public void WriteException(string source, Exception? ex) + { + var block = FormatEntry("CRASH", source, ex?.Message ?? "(no exception object)", ex); + Write(block); + WriteCrashMarker(block); + } +``` + +And add: + +```csharp + private void WriteCrashMarker(string block) + { + try + { + File.WriteAllText(CrashMarkerPath, block); + } + catch + { + // Same contract as the log write: recording a crash must not cause another one. + } + } +``` + +- [ ] **Step 4: Write the interface** + +Create `src/SharpClient.Core/Diagnostics/ILogReader.cs`: + +```csharp +namespace SharpClient.Core.Diagnostics; + +/// +/// Reads the on-device diagnostics log back so the app can show it after a restart. Implemented per +/// host: the MAUI app reads the real files, the Blazor Web host has no persistent log and uses +/// . +/// +public interface ILogReader +{ + /// True when a log exists on this platform and the viewer should be offered. + public bool IsAvailable { get; } + + /// The most recent entries, newest first, across the current and rotated files. + public Task> ReadAsync(int maxEntries = 500); + + /// The crash recorded by the previous run, or null if it exited cleanly. + public Task GetPendingCrashAsync(); + + /// Forgets the pending crash so the banner stops appearing. + public Task DismissCrashAsync(); + + /// Deletes the log and its rotated backup. Leaves any pending crash marker alone. + public Task ClearAsync(); +} + +/// No-op reader for hosts without a persistent file log (e.g. the Web preview). +public sealed class NoopLogReader : ILogReader +{ + public bool IsAvailable => false; + + public Task> ReadAsync(int maxEntries = 500) => + Task.FromResult>([]); + + public Task GetPendingCrashAsync() => Task.FromResult(null); + + public Task DismissCrashAsync() => Task.CompletedTask; + + public Task ClearAsync() => Task.CompletedTask; +} +``` + +- [ ] **Step 5: Write the file reader** + +Create `src/SharpClient.Core/Diagnostics/FileLogReader.cs`: + +```csharp +namespace SharpClient.Core.Diagnostics; + +public sealed class FileLogReader : ILogReader +{ + private readonly FileLogStore _store; + + public FileLogReader(FileLogStore store) => _store = store; + + public bool IsAvailable => true; + + public Task> ReadAsync(int maxEntries = 500) + { + var entries = new List(); + entries.AddRange(ReadFile(_store.BackupPath)); + entries.AddRange(ReadFile(_store.FilePath)); + + var start = Math.Max(0, entries.Count - maxEntries); + var newest = entries.GetRange(start, entries.Count - start); + newest.Reverse(); + + return Task.FromResult>(newest); + } + + public Task GetPendingCrashAsync() + { + var entry = ReadFile(_store.CrashMarkerPath).FirstOrDefault(); + return Task.FromResult(entry is null + ? null + : new CrashReport(entry.Timestamp, entry.Category, entry.Message, entry.Detail ?? string.Empty)); + } + + public Task DismissCrashAsync() + { + Delete(_store.CrashMarkerPath); + return Task.CompletedTask; + } + + public Task ClearAsync() + { + Delete(_store.FilePath); + Delete(_store.BackupPath); + return Task.CompletedTask; + } + + private static IReadOnlyList ReadFile(string path) + { + try + { + if (!File.Exists(path)) + { + return []; + } + + // The writer may hold the file open; share aggressively rather than fail the read. + using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var text = new StreamReader(stream); + return LogEntryParser.Parse(text.ReadToEnd()); + } + catch + { + return []; + } + } + + private static void Delete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Best effort: a locked file just means the viewer still shows it. + } + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release -- --treenode-filter "/*/*/FileLogReaderTests/*"` + +Expected: all 9 tests pass. Re-run the `FileLogStoreTests` filter too — `WriteException` changed. + +- [ ] **Step 7: Register the reader in both hosts** + +In `src/SharpClient.App/MauiProgram.cs`, beside the existing `ILogExporter` registration: + +```csharp + builder.Services.AddSingleton(_ => new FileLogReader(logStore)); +``` + +In `src/SharpClient.Web/Program.cs`, beside the existing `NoopLogExporter` registration: + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 8: Full suite** + +```bash +dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release +dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release +``` + +Expected: all pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/SharpClient.Core/Diagnostics src/SharpClient.App/MauiProgram.cs src/SharpClient.Web/Program.cs tests/SharpClient.Tests/Diagnostics/FileLogReaderTests.cs +git commit -m "feat(diagnostics): ILogReader with crash marker and rotated-file merge" +``` + +--- + +### Task 7: Diagnostics viewer + +**Files:** +- Create: `src/SharpClient.UI/Components/DiagnosticsView.razor` +- Create: `src/SharpClient.UI/Pages/DiagnosticsPage.razor` +- Modify: `src/SharpClient.UI/Components/SettingsView.razor` +- Modify: `src/SharpClient.UI/wwwroot/sc-interop.js`, `src/SharpClient.UI/wwwroot/app.css` +- Test: `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs` + +**Interfaces:** +- Consumes: `ILogReader`, `LogEntry` (Tasks 5–6), the existing `ILogExporter`. +- Produces: component `DiagnosticsView` with `[Parameter] string? InitialFilter`; route `/diagnostics` accepting `?filter=crashes`; CSS classes `.sc-diag`, `.sc-diag-bar`, `.sc-diag-chip`, `.sc-diag-chip-active`, `.sc-diag-list`, `.sc-diag-entry`, `.sc-diag-empty`, `.sc-diag-clear`; JS export `copyText(text)`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs`: + +```csharp +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Diagnostics; +using SharpClient.UI.Components; + +namespace SharpClient.UI.Tests; + +file sealed class FakeLogReader : ILogReader +{ + public List Entries { get; } = []; + public CrashReport? Pending { get; set; } + public int ClearCalls { get; private set; } + public int DismissCalls { get; private set; } + + public bool IsAvailable => true; + + public Task> ReadAsync(int maxEntries = 500) => + Task.FromResult>(Entries); + + public Task GetPendingCrashAsync() => Task.FromResult(Pending); + + public Task DismissCrashAsync() + { + DismissCalls++; + Pending = null; + return Task.CompletedTask; + } + + public Task ClearAsync() + { + ClearCalls++; + Entries.Clear(); + return Task.CompletedTask; + } +} + +public sealed class DiagnosticsViewTests +{ + private static readonly DateTimeOffset Base = new(2026, 8, 10, 23, 41, 0, TimeSpan.Zero); + + private static (BunitContext ctx, FakeLogReader reader) NewContext() + { + var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var reader = new FakeLogReader(); + ctx.Services.AddSingleton(reader); + ctx.Services.AddSingleton(new NoopLogExporter()); + return (ctx, reader); + } + + private static void Seed(FakeLogReader reader) + { + reader.Entries.Add(new LogEntry(Base.AddSeconds(2), "CRASH", "AppDomain", "boom", "stack frame here")); + reader.Entries.Add(new LogEntry(Base.AddSeconds(1), "Error", "Session", "send failed", null)); + reader.Entries.Add(new LogEntry(Base, "Information", "App", "started", null)); + } + + [Test] + public async Task RendersEveryEntryByDefault() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-diag-entry")).HasCount().EqualTo(3); + } + + [Test] + public async Task CrashesFilterShowsOnlyCrashEntries() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + await cut.FindAll(".sc-diag-chip")[2].ClickAsync(new MouseEventArgs()); + + await Assert.That(cut.FindAll(".sc-diag-entry")).HasCount().EqualTo(1); + await Assert.That(cut.Find(".sc-diag-entry").TextContent).Contains("boom"); + } + + [Test] + public async Task ErrorsFilterIncludesErrorsAndCrashes() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + await cut.FindAll(".sc-diag-chip")[1].ClickAsync(new MouseEventArgs()); + + await Assert.That(cut.FindAll(".sc-diag-entry")).HasCount().EqualTo(2); + } + + [Test] + public async Task InitialFilterParameterSelectsCrashes() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(p => p.Add(c => c.InitialFilter, "crashes")); + + await Assert.That(cut.FindAll(".sc-diag-entry")).HasCount().EqualTo(1); + } + + [Test] + public async Task DetailIsRenderedForEntriesThatHaveIt() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll("details")).HasCount().EqualTo(1); + await Assert.That(cut.Find("details").TextContent).Contains("stack frame here"); + } + + [Test] + public async Task EmptyStateIsShownWhenThereAreNoEntries() + { + var (ctx, _) = NewContext(); + using var __ = ctx; + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-diag-empty")).HasCount().EqualTo(1); + } + + [Test] + public async Task ClearRequiresASecondConfirmingClick() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + await cut.Find(".sc-diag-clear").ClickAsync(new MouseEventArgs()); + + await Assert.That(reader.ClearCalls).IsEqualTo(0); + await Assert.That(cut.Find(".sc-diag-clear").TextContent.Trim()).IsEqualTo("Confirm clear"); + + await cut.Find(".sc-diag-clear").ClickAsync(new MouseEventArgs()); + + await Assert.That(reader.ClearCalls).IsEqualTo(1); + await Assert.That(cut.FindAll(".sc-diag-entry")).IsEmpty(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/DiagnosticsViewTests/*"` + +Expected: build failure — `DiagnosticsView` does not exist. + +- [ ] **Step 3: Add the clipboard interop export** + +Append to `src/SharpClient.UI/wwwroot/sc-interop.js`: + +```js +export function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return Promise.resolve(); +} +``` + +- [ ] **Step 4: Write the component** + +Create `src/SharpClient.UI/Components/DiagnosticsView.razor`: + +```razor +@using Microsoft.JSInterop +@using SharpClient.Core.Diagnostics +@inject ILogReader Reader +@inject ILogExporter Exporter +@inject IJSRuntime JS +@implements IAsyncDisposable + +
+
+ @foreach (var option in FilterOptions) + { + var value = option.Value; + + } +
+ + + + +
+
+ +
+ @if (Visible.Count == 0) + { +
No log entries recorded yet.
+ } + else + { + @foreach (var entry in Visible) + { +
+
+ @entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + @entry.Level + @if (entry.Category.Length > 0) + { + @entry.Category + } +
+
@entry.Message
+ @if (entry.Detail is not null) + { +
+ Detail +
@entry.Detail
+
+ } +
+ } + @if (_truncated) + { +
Older entries were truncated.
+ } + } +
+
+ +@code { + private const int MaxEntries = 500; + + [Parameter] + public string? InitialFilter { get; set; } + + private enum LogFilter { All, Errors, Crashes } + + private static readonly (string Label, LogFilter Value)[] FilterOptions = + [ + ("All", LogFilter.All), + ("Errors", LogFilter.Errors), + ("Crashes", LogFilter.Crashes), + ]; + + private IReadOnlyList _entries = []; + private LogFilter _filter = LogFilter.All; + private bool _confirmingClear; + private bool _truncated; + private IJSObjectReference? _interop; + + private List Visible => _filter switch + { + LogFilter.Crashes => _entries.Where(IsCrash).ToList(), + LogFilter.Errors => _entries.Where(e => IsCrash(e) || IsError(e)).ToList(), + _ => _entries.ToList(), + }; + + private static bool IsCrash(LogEntry e) => e.Level.Equals("CRASH", StringComparison.OrdinalIgnoreCase); + + private static bool IsError(LogEntry e) => + e.Level.Equals("Error", StringComparison.OrdinalIgnoreCase) + || e.Level.Equals("Critical", StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + if (string.Equals(InitialFilter, "crashes", StringComparison.OrdinalIgnoreCase)) + { + _filter = LogFilter.Crashes; + } + else if (string.Equals(InitialFilter, "errors", StringComparison.OrdinalIgnoreCase)) + { + _filter = LogFilter.Errors; + } + + await RefreshAsync(); + } + + private async Task RefreshAsync() + { + _entries = await Reader.ReadAsync(MaxEntries); + _truncated = _entries.Count == MaxEntries; + _confirmingClear = false; + } + + private async Task CopyAsync() + { + var text = string.Join('\n', Visible.Select(e => + $"{e.Timestamp:yyyy-MM-dd HH:mm:ss} [{e.Level}] {e.Category}: {e.Message}" + + (e.Detail is null ? string.Empty : "\n" + e.Detail))); + + _interop ??= await JS.InvokeAsync( + "import", "./_content/SharpClient.UI/sc-interop.js"); + await _interop.InvokeVoidAsync("copyText", text); + } + + private async Task ShareAsync() + { + if (Exporter.IsAvailable) + { + await Exporter.ShareAsync(); + } + } + + // Two-step rather than a JS confirm(): a modal dialog inside the Android WebView blocks the + // Blazor circuit. + private async Task ClearAsync() + { + if (!_confirmingClear) + { + _confirmingClear = true; + return; + } + + await Reader.ClearAsync(); + await RefreshAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_interop is not null) + { + await _interop.DisposeAsync(); + } + } +} +``` + +- [ ] **Step 5: Write the page** + +Create `src/SharpClient.UI/Pages/DiagnosticsPage.razor`: + +```razor +@page "/diagnostics" + +SharpClient · Diagnostics + + + +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "filter")] + public string? Filter { get; set; } +} +``` + +- [ ] **Step 6: Link it from Settings** + +In `src/SharpClient.UI/Components/SettingsView.razor`, inside the Diagnostics section, add a row above the existing export row: + +```razor +
+ View log + Open +
+``` + +- [ ] **Step 7: Add the styles** + +Append to `src/SharpClient.UI/wwwroot/app.css`: + +```css +/* ── Diagnostics page ──────────────────────────────────────────── */ +.sc-diag { height: 100%; display: flex; flex-direction: column; gap: 10px; padding: 12px 12px 0; } +.sc-diag-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.sc-diag-chip { font-family: var(--mono); font-size: 12px; color: var(--dim); background: var(--panel); border: 1px solid var(--bd); border-radius: 8px; padding: 6px 11px; cursor: pointer; } +.sc-diag-chip-active { color: var(--acc2); background: var(--acc-soft); border-color: var(--acc-line); } +.sc-diag-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 6px; } +.sc-diag-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding-bottom: 12px; } +.sc-diag-entry { background: var(--panel); border: 1px solid var(--bd); border-radius: 10px; padding: 9px 11px; } +.sc-diag-entry-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 4px; } +.sc-diag-time { font-family: var(--mono); font-size: 11px; color: var(--faint); } +.sc-diag-level { font-family: var(--mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); border: 1px solid var(--bd2); border-radius: 6px; padding: 1px 6px; } +.sc-diag-level-error, .sc-diag-level-critical, .sc-diag-level-crash { color: #e88; border-color: rgba(238,136,136,.45); } +.sc-diag-category { font-family: var(--mono); font-size: 11px; color: var(--dim); } +.sc-diag-message { font-family: var(--mono); font-size: 12px; color: var(--tx); overflow-wrap: anywhere; } +.sc-diag-detail { margin-top: 6px; } +.sc-diag-detail summary { font-family: var(--mono); font-size: 11px; color: var(--dim); cursor: pointer; } +.sc-diag-detail pre { margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: pre-wrap; overflow-wrap: anywhere; } +.sc-diag-empty { font-family: var(--mono); font-size: 12px; color: var(--faint); padding: 14px 2px; } +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/DiagnosticsViewTests/*"` + +Expected: all 7 tests pass. + +- [ ] **Step 9: Confirm SettingsView tests still pass** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/SettingsViewTests/*"` + +Expected: all pass. Those tests register `NoopLogExporter`, whose `IsAvailable` is false, so the whole Diagnostics section including the new row stays hidden. + +- [ ] **Step 10: Commit** + +```bash +git add src/SharpClient.UI/Components/DiagnosticsView.razor src/SharpClient.UI/Pages/DiagnosticsPage.razor src/SharpClient.UI/Components/SettingsView.razor src/SharpClient.UI/wwwroot/sc-interop.js src/SharpClient.UI/wwwroot/app.css tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs +git commit -m "feat(diagnostics): in-app log viewer with level filters" +``` + +--- + +### Task 8: Crash banner + +**Files:** +- Create: `src/SharpClient.UI/Components/CrashBanner.razor` +- Modify: `src/SharpClient.UI/Layout/MainLayout.razor`, `src/SharpClient.UI/wwwroot/app.css` +- Test: `tests/SharpClient.UI.Tests/CrashBannerTests.cs` + +**Interfaces:** +- Consumes: `ILogReader.GetPendingCrashAsync()`, `ILogReader.DismissCrashAsync()`, `CrashReport` (Task 6). +- Produces: component `CrashBanner` (no parameters); CSS classes `.sc-crash-banner`, `.sc-crash-view`, `.sc-crash-dismiss`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/SharpClient.UI.Tests/CrashBannerTests.cs`: + +```csharp +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Diagnostics; +using SharpClient.UI.Components; + +namespace SharpClient.UI.Tests; + +file sealed class BannerLogReader : ILogReader +{ + public CrashReport? Pending { get; set; } + public int DismissCalls { get; private set; } + + public bool IsAvailable => true; + + public Task> ReadAsync(int maxEntries = 500) => + Task.FromResult>([]); + + public Task GetPendingCrashAsync() => Task.FromResult(Pending); + + public Task DismissCrashAsync() + { + DismissCalls++; + Pending = null; + return Task.CompletedTask; + } + + public Task ClearAsync() => Task.CompletedTask; +} + +public sealed class CrashBannerTests +{ + private static (BunitContext ctx, BannerLogReader reader) NewContext(CrashReport? pending) + { + var ctx = new BunitContext(); + var reader = new BannerLogReader { Pending = pending }; + ctx.Services.AddSingleton(reader); + return (ctx, reader); + } + + [Test] + public async Task NothingIsRenderedWhenThereIsNoPendingCrash() + { + var (ctx, _) = NewContext(null); + using var _unused = ctx; + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-crash-banner")).IsEmpty(); + } + + [Test] + public async Task BannerShowsTheCrashTimestamp() + { + var report = new CrashReport( + new DateTimeOffset(2026, 8, 9, 23, 41, 0, TimeSpan.Zero), "AppDomain", "boom", "stack"); + var (ctx, _) = NewContext(report); + using var _unused = ctx; + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-crash-banner")).HasCount().EqualTo(1); + await Assert.That(cut.Find(".sc-crash-banner").TextContent).Contains("2026-08-09 23:41"); + } + + [Test] + public async Task DismissingHidesTheBannerAndTellsTheReader() + { + var report = new CrashReport( + new DateTimeOffset(2026, 8, 9, 23, 41, 0, TimeSpan.Zero), "AppDomain", "boom", "stack"); + var (ctx, reader) = NewContext(report); + using var _unused = ctx; + + var cut = ctx.Render(); + await cut.Find(".sc-crash-dismiss").ClickAsync(new MouseEventArgs()); + + await Assert.That(reader.DismissCalls).IsEqualTo(1); + await Assert.That(cut.FindAll(".sc-crash-banner")).IsEmpty(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/CrashBannerTests/*"` + +Expected: build failure — `CrashBanner` does not exist. + +- [ ] **Step 3: Write the component** + +Create `src/SharpClient.UI/Components/CrashBanner.razor`. The report is loaded in `OnInitializedAsync` so bUnit's synchronous `Render` sees it; the file read is small and guarded inside `FileLogReader`. + +```razor +@using SharpClient.Core.Diagnostics +@inject ILogReader Reader +@inject NavigationManager Nav + +@if (_report is not null) +{ +
+ + SharpClient crashed last run (@_report.Timestamp.ToString("yyyy-MM-dd HH:mm")) + + + +
+} + +@code { + private CrashReport? _report; + + protected override async Task OnInitializedAsync() => _report = await Reader.GetPendingCrashAsync(); + + private void View() => Nav.NavigateTo("/diagnostics?filter=crashes"); + + private async Task DismissAsync() + { + await Reader.DismissCrashAsync(); + _report = null; + } +} +``` + +`NavigationManager` resolves from `Microsoft.AspNetCore.Components`, already imported by the Razor SDK's implicit usings. + +- [ ] **Step 4: Mount it in the layout** + +In `src/SharpClient.UI/Layout/MainLayout.razor`, add immediately before `
`: + +```razor + +``` + +- [ ] **Step 5: Add the styles** + +Append to `src/SharpClient.UI/wwwroot/app.css`: + +```css +/* ── Crash banner ──────────────────────────────────────────────── */ +.sc-crash-banner { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: rgba(238,136,136,.12); border-bottom: 1px solid rgba(238,136,136,.4); } +.sc-crash-text { flex: 1; font-family: var(--mono); font-size: 11px; color: #e8a; overflow-wrap: anywhere; } +.sc-crash-view { font-family: var(--mono); font-size: 11px; color: var(--acc2); background: var(--acc-soft); border: 1px solid var(--acc-line); border-radius: 7px; padding: 4px 10px; cursor: pointer; } +.sc-crash-dismiss { font-size: 12px; line-height: 1; color: var(--dim); background: none; border: none; padding: 4px 6px; cursor: pointer; } +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/CrashBannerTests/*"` + +Expected: all 3 tests pass. + +- [ ] **Step 7: Run every suite plus the Android head** + +```bash +dotnet run --project tests/SharpClient.Tests/SharpClient.Tests.csproj -c Release +dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release +dotnet run --project tests/SharpClient.Data.Tests/SharpClient.Data.Tests.csproj -c Release +dotnet build src/SharpClient.App/SharpClient.App.csproj -f net10.0-android -c Release +dotnet build src/SharpClient.Web/SharpClient.Web.csproj -c Release +``` + +Expected: all green. Report explicitly if the Android head could not be built locally for want of the workload. + +- [ ] **Step 8: Commit** + +```bash +git add src/SharpClient.UI/Components/CrashBanner.razor src/SharpClient.UI/Layout/MainLayout.razor src/SharpClient.UI/wwwroot/app.css tests/SharpClient.UI.Tests/CrashBannerTests.cs +git commit -m "feat(diagnostics): crashed-last-run banner linking to the log viewer" +``` + +--- + +## Manual verification + +After Task 8, run the Web host (`dotnet run --project src/SharpClient.Web/SharpClient.Web.csproj`) and check: + +1. The Compose tab appears in the nav and its editor fills the body between the chip row and the footer, at both phone and desktop widths. +2. With no connected session, Send is disabled and the hint links to `/session`. +3. Preview shows the exact wire line; Edit returns to the draft unchanged. +4. Settings shows no Diagnostics section — the Web host uses `NoopLogExporter`/`NoopLogReader` and no crash banner appears. + +The MAUI-only paths (real log file, share sheet, crash banner) need an Android or Windows run of `src/SharpClient.App`. Force a crash by throwing from a component event handler, kill and relaunch the app, and confirm the banner appears and the entry is visible under the Crashes filter. diff --git a/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md b/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md index c5ce452..f0db9ec 100644 --- a/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md +++ b/docs/superpowers/specs/2026-08-10-compose-and-diagnostics-design.md @@ -35,8 +35,8 @@ Steps, in this order: 1. Normalise line endings: `\r\n` and lone `\r` become `\n`. 2. Escape `%` → `%%`. -3. Trim trailing whitespace from each line; drop trailing blank lines. Interior blank - lines are preserved and become consecutive `%r`s. +3. Trim trailing whitespace from each line; drop leading and trailing blank lines. + Interior blank lines are preserved and become consecutive `%r`s. 4. Replace `\n` → `%r`. This runs **after** step 2 so the inserted `%r` markers are not themselves escaped. 5. Join prefix and body with the separator rule below. @@ -196,7 +196,8 @@ button next to the existing **Export log**, and the whole section stays hidden w visible only when `GetPendingCrashAsync` returns a report: *"SharpClient crashed last run (timestamp)"* with **View** and a dismiss **×**. View navigates to `/diagnostics?filter=crashes`; dismiss calls `DismissCrashAsync`. The report is fetched in -`OnAfterRenderAsync(firstRender)` so a slow or failed file read never blocks first paint. +`OnInitializedAsync`; the read is one small file and `FileLogReader` swallows any IO failure, +so it cannot block or break first paint. ### Out of scope From 393e0c232e5909c33376fe150bbb1730ada845c0 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 00:49:26 -0500 Subject: [PATCH 03/19] docs: share one ILogReader test double across the diagnostics tasks --- .../2026-08-10-compose-and-diagnostics.md | 82 ++++++++----------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-compose-and-diagnostics.md b/docs/superpowers/plans/2026-08-10-compose-and-diagnostics.md index e5c5825..1d587a4 100644 --- a/docs/superpowers/plans/2026-08-10-compose-and-diagnostics.md +++ b/docs/superpowers/plans/2026-08-10-compose-and-diagnostics.md @@ -2035,26 +2035,22 @@ git commit -m "feat(diagnostics): ILogReader with crash marker and rotated-file - Create: `src/SharpClient.UI/Pages/DiagnosticsPage.razor` - Modify: `src/SharpClient.UI/Components/SettingsView.razor` - Modify: `src/SharpClient.UI/wwwroot/sc-interop.js`, `src/SharpClient.UI/wwwroot/app.css` -- Test: `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs` +- Test: `tests/SharpClient.UI.Tests/UiFakeLogReader.cs`, `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs` **Interfaces:** - Consumes: `ILogReader`, `LogEntry` (Tasks 5–6), the existing `ILogExporter`. -- Produces: component `DiagnosticsView` with `[Parameter] string? InitialFilter`; route `/diagnostics` accepting `?filter=crashes`; CSS classes `.sc-diag`, `.sc-diag-bar`, `.sc-diag-chip`, `.sc-diag-chip-active`, `.sc-diag-list`, `.sc-diag-entry`, `.sc-diag-empty`, `.sc-diag-clear`; JS export `copyText(text)`. +- Produces: component `DiagnosticsView` with `[Parameter] string? InitialFilter`; route `/diagnostics` accepting `?filter=crashes`; CSS classes `.sc-diag`, `.sc-diag-bar`, `.sc-diag-chip`, `.sc-diag-chip-active`, `.sc-diag-list`, `.sc-diag-entry`, `.sc-diag-empty`, `.sc-diag-clear`; JS export `copyText(text)`; test double `UiFakeLogReader` reused by Task 8. -- [ ] **Step 1: Write the failing tests** +- [ ] **Step 1: Write the shared test double** -Create `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs`: +Create `tests/SharpClient.UI.Tests/UiFakeLogReader.cs`, matching the naming of the existing `UiFakeSession.cs` / `UiFakeWorldStore.cs`. Task 8 reuses this type, so it is not file-scoped. ```csharp -using Bunit; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.Extensions.DependencyInjection; using SharpClient.Core.Diagnostics; -using SharpClient.UI.Components; namespace SharpClient.UI.Tests; -file sealed class FakeLogReader : ILogReader +public sealed class UiFakeLogReader : ILogReader { public List Entries { get; } = []; public CrashReport? Pending { get; set; } @@ -2082,22 +2078,36 @@ file sealed class FakeLogReader : ILogReader return Task.CompletedTask; } } +``` + +- [ ] **Step 2: Write the failing tests** + +Create `tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs`: + +```csharp +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Diagnostics; +using SharpClient.UI.Components; + +namespace SharpClient.UI.Tests; public sealed class DiagnosticsViewTests { private static readonly DateTimeOffset Base = new(2026, 8, 10, 23, 41, 0, TimeSpan.Zero); - private static (BunitContext ctx, FakeLogReader reader) NewContext() + private static (BunitContext ctx, UiFakeLogReader reader) NewContext() { var ctx = new BunitContext(); ctx.JSInterop.Mode = JSRuntimeMode.Loose; - var reader = new FakeLogReader(); + var reader = new UiFakeLogReader(); ctx.Services.AddSingleton(reader); ctx.Services.AddSingleton(new NoopLogExporter()); return (ctx, reader); } - private static void Seed(FakeLogReader reader) + private static void Seed(UiFakeLogReader reader) { reader.Entries.Add(new LogEntry(Base.AddSeconds(2), "CRASH", "AppDomain", "boom", "stack frame here")); reader.Entries.Add(new LogEntry(Base.AddSeconds(1), "Error", "Session", "send failed", null)); @@ -2200,13 +2210,13 @@ public sealed class DiagnosticsViewTests } ``` -- [ ] **Step 2: Run the tests to verify they fail** +- [ ] **Step 3: Run the tests to verify they fail** Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/DiagnosticsViewTests/*"` Expected: build failure — `DiagnosticsView` does not exist. -- [ ] **Step 3: Add the clipboard interop export** +- [ ] **Step 4: Add the clipboard interop export** Append to `src/SharpClient.UI/wwwroot/sc-interop.js`: @@ -2219,7 +2229,7 @@ export function copyText(text) { } ``` -- [ ] **Step 4: Write the component** +- [ ] **Step 5: Write the component** Create `src/SharpClient.UI/Components/DiagnosticsView.razor`: @@ -2384,7 +2394,7 @@ Create `src/SharpClient.UI/Components/DiagnosticsView.razor`: } ``` -- [ ] **Step 5: Write the page** +- [ ] **Step 6: Write the page** Create `src/SharpClient.UI/Pages/DiagnosticsPage.razor`: @@ -2402,7 +2412,7 @@ Create `src/SharpClient.UI/Pages/DiagnosticsPage.razor`: } ``` -- [ ] **Step 6: Link it from Settings** +- [ ] **Step 7: Link it from Settings** In `src/SharpClient.UI/Components/SettingsView.razor`, inside the Diagnostics section, add a row above the existing export row: @@ -2413,7 +2423,7 @@ In `src/SharpClient.UI/Components/SettingsView.razor`, inside the Diagnostics se
``` -- [ ] **Step 7: Add the styles** +- [ ] **Step 8: Add the styles** Append to `src/SharpClient.UI/wwwroot/app.css`: @@ -2438,19 +2448,19 @@ Append to `src/SharpClient.UI/wwwroot/app.css`: .sc-diag-empty { font-family: var(--mono); font-size: 12px; color: var(--faint); padding: 14px 2px; } ``` -- [ ] **Step 8: Run the tests to verify they pass** +- [ ] **Step 9: Run the tests to verify they pass** Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/DiagnosticsViewTests/*"` Expected: all 7 tests pass. -- [ ] **Step 9: Confirm SettingsView tests still pass** +- [ ] **Step 10: Confirm SettingsView tests still pass** Run: `dotnet run --project tests/SharpClient.UI.Tests/SharpClient.UI.Tests.csproj -c Release -- --treenode-filter "/*/*/SettingsViewTests/*"` Expected: all pass. Those tests register `NoopLogExporter`, whose `IsAvailable` is false, so the whole Diagnostics section including the new row stays hidden. -- [ ] **Step 10: Commit** +- [ ] **Step 11: Commit** ```bash git add src/SharpClient.UI/Components/DiagnosticsView.razor src/SharpClient.UI/Pages/DiagnosticsPage.razor src/SharpClient.UI/Components/SettingsView.razor src/SharpClient.UI/wwwroot/sc-interop.js src/SharpClient.UI/wwwroot/app.css tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs @@ -2467,12 +2477,12 @@ git commit -m "feat(diagnostics): in-app log viewer with level filters" - Test: `tests/SharpClient.UI.Tests/CrashBannerTests.cs` **Interfaces:** -- Consumes: `ILogReader.GetPendingCrashAsync()`, `ILogReader.DismissCrashAsync()`, `CrashReport` (Task 6). +- Consumes: `ILogReader.GetPendingCrashAsync()`, `ILogReader.DismissCrashAsync()`, `CrashReport` (Task 6), `UiFakeLogReader` (Task 7). - Produces: component `CrashBanner` (no parameters); CSS classes `.sc-crash-banner`, `.sc-crash-view`, `.sc-crash-dismiss`. - [ ] **Step 1: Write the failing tests** -Create `tests/SharpClient.UI.Tests/CrashBannerTests.cs`: +Create `tests/SharpClient.UI.Tests/CrashBannerTests.cs`, reusing the `UiFakeLogReader` that Task 7 added — do not define a second `ILogReader` double. ```csharp using Bunit; @@ -2483,34 +2493,12 @@ using SharpClient.UI.Components; namespace SharpClient.UI.Tests; -file sealed class BannerLogReader : ILogReader -{ - public CrashReport? Pending { get; set; } - public int DismissCalls { get; private set; } - - public bool IsAvailable => true; - - public Task> ReadAsync(int maxEntries = 500) => - Task.FromResult>([]); - - public Task GetPendingCrashAsync() => Task.FromResult(Pending); - - public Task DismissCrashAsync() - { - DismissCalls++; - Pending = null; - return Task.CompletedTask; - } - - public Task ClearAsync() => Task.CompletedTask; -} - public sealed class CrashBannerTests { - private static (BunitContext ctx, BannerLogReader reader) NewContext(CrashReport? pending) + private static (BunitContext ctx, UiFakeLogReader reader) NewContext(CrashReport? pending) { var ctx = new BunitContext(); - var reader = new BannerLogReader { Pending = pending }; + var reader = new UiFakeLogReader { Pending = pending }; ctx.Services.AddSingleton(reader); return (ctx, reader); } From 0ac0b9ca7a6ed1d3d87553f497adedfaf67fcd19 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 00:51:39 -0500 Subject: [PATCH 04/19] feat(compose): MUSH pose formatter with %-escaping and %r line joining --- .../Formatting/MushPoseFormatter.cs | 77 +++++++++ .../Formatting/MushPoseFormatterTests.cs | 147 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/SharpClient.Core/Formatting/MushPoseFormatter.cs create mode 100644 tests/SharpClient.Tests/Formatting/MushPoseFormatterTests.cs diff --git a/src/SharpClient.Core/Formatting/MushPoseFormatter.cs b/src/SharpClient.Core/Formatting/MushPoseFormatter.cs new file mode 100644 index 0000000..d7e951d --- /dev/null +++ b/src/SharpClient.Core/Formatting/MushPoseFormatter.cs @@ -0,0 +1,77 @@ +namespace SharpClient.Core.Formatting; + +public enum PosePrefix +{ + Say, + Pose, + Semipose, + Emit, + Custom, +} + +/// +/// Turns multi-line prose into the single line a MUSH expects: literal percent signs are doubled so +/// the server does not treat them as substitutions, and line breaks become %r. +/// +public static class MushPoseFormatter +{ + public static string CommandFor(PosePrefix prefix, string customPrefix) => prefix switch + { + PosePrefix.Say => "say", + PosePrefix.Pose => "pose", + PosePrefix.Semipose => "semipose", + PosePrefix.Emit => "@emit", + _ => customPrefix, + }; + + public static string Format(string prefix, string body) => Join(prefix, EscapeBody(body)); + + private static string EscapeBody(string body) + { + var lines = body.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + var first = 0; + var last = lines.Length - 1; + while (first <= last && lines[first].Trim().Length == 0) + { + first++; + } + + while (last >= first && lines[last].Trim().Length == 0) + { + last--; + } + + if (first > last) + { + return string.Empty; + } + + // Escape per line, then join with %r: the substitution markers must not be escaped themselves. + var escaped = new string[last - first + 1]; + for (var i = first; i <= last; i++) + { + escaped[i - first] = lines[i].TrimEnd().Replace("%", "%%"); + } + + return string.Join("%r", escaped); + } + + private static string Join(string prefix, string body) + { + if (prefix.Length == 0) + { + return body; + } + + if (body.Length == 0) + { + return prefix.TrimEnd(); + } + + var last = prefix[^1]; + return last is '=' or '/' || char.IsWhiteSpace(last) + ? prefix + body + : prefix + " " + body; + } +} diff --git a/tests/SharpClient.Tests/Formatting/MushPoseFormatterTests.cs b/tests/SharpClient.Tests/Formatting/MushPoseFormatterTests.cs new file mode 100644 index 0000000..29e5fa1 --- /dev/null +++ b/tests/SharpClient.Tests/Formatting/MushPoseFormatterTests.cs @@ -0,0 +1,147 @@ +using SharpClient.Core.Formatting; + +namespace SharpClient.Tests.Formatting; + +public sealed class MushPoseFormatterTests +{ + [Test] + public async Task LiteralPercentIsDoubled() + { + var result = MushPoseFormatter.Format("pose", "is 100% sure"); + await Assert.That(result).IsEqualTo("pose is 100%% sure"); + } + + [Test] + public async Task NewlineBecomesPercentR() + { + var result = MushPoseFormatter.Format("pose", "line one\nline two"); + await Assert.That(result).IsEqualTo("pose line one%rline two"); + } + + [Test] + public async Task PercentEscapingRunsBeforeNewlineSubstitution() + { + var result = MushPoseFormatter.Format("pose", "50%\nrest"); + await Assert.That(result).IsEqualTo("pose 50%%%rrest"); + } + + [Test] + public async Task CarriageReturnLineFeedNormalisesLikeLineFeed() + { + var result = MushPoseFormatter.Format("pose", "a\r\nb"); + await Assert.That(result).IsEqualTo("pose a%rb"); + } + + [Test] + public async Task LoneCarriageReturnNormalisesLikeLineFeed() + { + var result = MushPoseFormatter.Format("pose", "a\rb"); + await Assert.That(result).IsEqualTo("pose a%rb"); + } + + [Test] + public async Task InteriorBlankLineBecomesTwoPercentR() + { + var result = MushPoseFormatter.Format("pose", "a\n\nb"); + await Assert.That(result).IsEqualTo("pose a%r%rb"); + } + + [Test] + public async Task TrailingBlankLinesAreDropped() + { + var result = MushPoseFormatter.Format("pose", "a\n\n\n"); + await Assert.That(result).IsEqualTo("pose a"); + } + + [Test] + public async Task LeadingBlankLinesAreDropped() + { + var result = MushPoseFormatter.Format("pose", "\n\na"); + await Assert.That(result).IsEqualTo("pose a"); + } + + [Test] + public async Task TrailingWhitespaceIsTrimmedPerLine() + { + var result = MushPoseFormatter.Format("pose", "a \nb\t"); + await Assert.That(result).IsEqualTo("pose a%rb"); + } + + [Test] + public async Task LeadingIndentOnALineIsPreserved() + { + var result = MushPoseFormatter.Format("pose", "a\n b"); + await Assert.That(result).IsEqualTo("pose a%r b"); + } + + [Test] + public async Task BracketsAndBackslashesPassThroughUnescaped() + { + var result = MushPoseFormatter.Format("pose", @"holds [a] \ thing"); + await Assert.That(result).IsEqualTo(@"pose holds [a] \ thing"); + } + + [Test] + public async Task PrefixEndingInEqualsJoinsWithoutSpace() + { + var result = MushPoseFormatter.Format("page Bob=", "hello"); + await Assert.That(result).IsEqualTo("page Bob=hello"); + } + + [Test] + public async Task PrefixEndingInSlashJoinsWithoutSpace() + { + var result = MushPoseFormatter.Format("chan/", "hello"); + await Assert.That(result).IsEqualTo("chan/hello"); + } + + [Test] + public async Task PrefixEndingInSpaceJoinsVerbatim() + { + var result = MushPoseFormatter.Format("page Bob ", "hello"); + await Assert.That(result).IsEqualTo("page Bob hello"); + } + + [Test] + public async Task BarePrefixGetsASingleSpace() + { + var result = MushPoseFormatter.Format("@emit", "hello"); + await Assert.That(result).IsEqualTo("@emit hello"); + } + + [Test] + public async Task EmptyBodyYieldsPrefixOnly() + { + var result = MushPoseFormatter.Format("pose", string.Empty); + await Assert.That(result).IsEqualTo("pose"); + } + + [Test] + public async Task WhitespaceOnlyBodyYieldsPrefixOnly() + { + var result = MushPoseFormatter.Format("page Bob=", " \n \n "); + await Assert.That(result).IsEqualTo("page Bob="); + } + + [Test] + public async Task EmptyPrefixYieldsBodyOnly() + { + var result = MushPoseFormatter.Format(string.Empty, "hello"); + await Assert.That(result).IsEqualTo("hello"); + } + + [Test] + public async Task CommandForMapsEveryBuiltIn() + { + await Assert.That(MushPoseFormatter.CommandFor(PosePrefix.Say, "x")).IsEqualTo("say"); + await Assert.That(MushPoseFormatter.CommandFor(PosePrefix.Pose, "x")).IsEqualTo("pose"); + await Assert.That(MushPoseFormatter.CommandFor(PosePrefix.Semipose, "x")).IsEqualTo("semipose"); + await Assert.That(MushPoseFormatter.CommandFor(PosePrefix.Emit, "x")).IsEqualTo("@emit"); + } + + [Test] + public async Task CommandForCustomReturnsTheCustomPrefix() + { + await Assert.That(MushPoseFormatter.CommandFor(PosePrefix.Custom, "page Bob=")).IsEqualTo("page Bob="); + } +} From 76cdfeba7dfceaf6c8f24174d6ba4f6b66b1cdba Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 00:56:57 -0500 Subject: [PATCH 05/19] feat(compose): ComposeViewModel with per-session drafts and per-world custom prefix --- .../Presentation/ComposeViewModel.cs | 142 ++++++++++++ .../ServiceCollectionExtensions.cs | 6 +- .../Presentation/ComposeViewModelTests.cs | 209 ++++++++++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 src/SharpClient.Core/Presentation/ComposeViewModel.cs create mode 100644 tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs diff --git a/src/SharpClient.Core/Presentation/ComposeViewModel.cs b/src/SharpClient.Core/Presentation/ComposeViewModel.cs new file mode 100644 index 0000000..4bd8fe6 --- /dev/null +++ b/src/SharpClient.Core/Presentation/ComposeViewModel.cs @@ -0,0 +1,142 @@ +using SharpClient.Core.Connection; +using SharpClient.Core.Formatting; +using SharpClient.Core.Platform; +using SharpClient.Core.Sessions; + +namespace SharpClient.Core.Presentation; + +public sealed class ComposeViewModel +{ + private readonly ISessionManager _manager; + private readonly IPreferences _prefs; + private readonly Dictionary _drafts = []; + private ISession? _activeSession; + private PosePrefix _selectedPrefix = PosePrefix.Pose; + private string _customPrefix = string.Empty; + + public ComposeViewModel(ISessionManager manager, IPreferences prefs) + { + _manager = manager; + _prefs = prefs; + _manager.Changed += OnManagerChanged; + TrackActiveSession(_manager.Active); + } + + internal int TrackedDraftCount => _drafts.Count; + + public event Action? Changed; + + public ISession? Active => _manager.Active; + + public PosePrefix SelectedPrefix + { + get => _selectedPrefix; + set + { + _selectedPrefix = value; + Changed?.Invoke(); + } + } + + public string CustomPrefix + { + get => _customPrefix; + set + { + _customPrefix = value; + if (Active is not null) + { + _prefs.SetString(CustomPrefixKey(Active.WorldId), value); + } + + Changed?.Invoke(); + } + } + + public string Body + { + get => Active is not null && _drafts.TryGetValue(Active, out var draft) ? draft : string.Empty; + set + { + if (Active is null) + { + return; + } + + _drafts[Active] = value; + Changed?.Invoke(); + } + } + + public string Command => MushPoseFormatter.CommandFor(_selectedPrefix, _customPrefix); + + public string Preview => MushPoseFormatter.Format(Command, Body); + + public bool CanSend => + Active?.State == ConnectionState.Connected + && !string.IsNullOrWhiteSpace(Body) + && !string.IsNullOrWhiteSpace(Command); + + public async Task SendAsync() + { + if (!CanSend || Active is null) + { + return; + } + + var active = Active; + var line = Preview; + await active.SendAsync(line); + + _drafts[active] = string.Empty; + Changed?.Invoke(); + } + + public void Clear() + { + if (Active is null) + { + return; + } + + _drafts[Active] = string.Empty; + Changed?.Invoke(); + } + + internal static string CustomPrefixKey(Guid worldId) => $"compose.custom.{worldId}"; + + private void OnManagerChanged() + { + var sessions = _manager.Sessions; + foreach (var key in _drafts.Keys.Where(k => !sessions.Contains(k)).ToList()) + { + _drafts.Remove(key); + } + + TrackActiveSession(_manager.Active); + Changed?.Invoke(); + } + + private void TrackActiveSession(ISession? newActive) + { + if (ReferenceEquals(newActive, _activeSession)) + { + return; + } + + if (_activeSession is not null) + { + _activeSession.StateChanged -= OnActiveStateChanged; + } + + _activeSession = newActive; + + if (_activeSession is not null) + { + _activeSession.StateChanged += OnActiveStateChanged; + _customPrefix = _prefs.GetString(CustomPrefixKey(_activeSession.WorldId), string.Empty); + } + } + + private void OnActiveStateChanged(ConnectionState _) => Changed?.Invoke(); +} diff --git a/src/SharpClient.UI/ServiceCollectionExtensions.cs b/src/SharpClient.UI/ServiceCollectionExtensions.cs index a30b318..095a99c 100644 --- a/src/SharpClient.UI/ServiceCollectionExtensions.cs +++ b/src/SharpClient.UI/ServiceCollectionExtensions.cs @@ -16,7 +16,7 @@ namespace SharpClient.UI; public static class ServiceCollectionExtensions { /// - /// Registers all six presentation view models. The three session/settings view models are always + /// Registers all seven presentation view models. The four session/settings/compose view models are always /// singletons; the three per-view view models use (Transient /// in MAUI, Scoped in Web). All constructor dependencies must already be registered by the host. /// @@ -30,6 +30,10 @@ public static IServiceCollection AddSharpClientViewModels( new ProtocolPanelViewModel(sp.GetRequiredService())); services.AddSingleton(sp => new SettingsViewModel(sp.GetRequiredService())); + services.AddSingleton(sp => + new ComposeViewModel( + sp.GetRequiredService(), + sp.GetRequiredService())); services.Add(new ServiceDescriptor( typeof(WorldManagerViewModel), diff --git a/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs new file mode 100644 index 0000000..a14c29e --- /dev/null +++ b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs @@ -0,0 +1,209 @@ +using SharpClient.Core.Connection; +using SharpClient.Core.Formatting; +using SharpClient.Core.Presentation; +using SharpClient.Core.Sessions; +using SharpClient.Tests.Fakes; +using SharpClient.Tests.Sessions; + +namespace SharpClient.Tests.Presentation; + +public sealed class ComposeViewModelTests +{ + private static (ComposeViewModel vm, SessionManager mgr, FakePreferences prefs) Build() + { + var mgr = new SessionManager(); + var prefs = new FakePreferences(); + return (new ComposeViewModel(mgr, prefs), mgr, prefs); + } + + [Test] + public async Task CannotSendWithNoSession() + { + var (vm, _, _) = Build(); + await Assert.That(vm.CanSend).IsFalse(); + } + + [Test] + public async Task CannotSendWhenDisconnected() + { + var (vm, mgr, _) = Build(); + var s = new FakeSession { State = ConnectionState.Disconnected }; + mgr.Add(s); + vm.Body = "waves"; + + await Assert.That(vm.CanSend).IsFalse(); + } + + [Test] + public async Task CannotSendWhenBodyIsBlank() + { + var (vm, mgr, _) = Build(); + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + vm.Body = " "; + + await Assert.That(vm.CanSend).IsFalse(); + } + + [Test] + public async Task CannotSendWhenCustomPrefixIsBlank() + { + var (vm, mgr, _) = Build(); + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + vm.Body = "waves"; + vm.SelectedPrefix = PosePrefix.Custom; + vm.CustomPrefix = " "; + + await Assert.That(vm.CanSend).IsFalse(); + } + + [Test] + public async Task CanSendWhenConnectedWithBody() + { + var (vm, mgr, _) = Build(); + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + vm.Body = "waves"; + + await Assert.That(vm.CanSend).IsTrue(); + } + + [Test] + public async Task PreviewUsesSelectedPrefixAndEscapes() + { + var (vm, mgr, _) = Build(); + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + vm.SelectedPrefix = PosePrefix.Emit; + vm.Body = "50% off\nsecond line"; + + await Assert.That(vm.Preview).IsEqualTo("@emit 50%% off%rsecond line"); + } + + [Test] + public async Task SendDeliversFormattedLineAndClearsDraft() + { + var (vm, mgr, _) = Build(); + var s = new FakeSession { State = ConnectionState.Connected }; + mgr.Add(s); + vm.Body = "grins\nwidely"; + + await vm.SendAsync(); + + await Assert.That(s.Sent).Contains("pose grins%rwidely"); + await Assert.That(vm.Body).IsEqualTo(string.Empty); + } + + [Test] + public async Task SendDoesNothingWhenCannotSend() + { + var (vm, mgr, _) = Build(); + var s = new FakeSession { State = ConnectionState.Disconnected }; + mgr.Add(s); + vm.Body = "grins"; + + await vm.SendAsync(); + + await Assert.That(s.Sent).IsEmpty(); + } + + [Test] + public async Task DraftsAreKeptPerSession() + { + var (vm, mgr, _) = Build(); + var a = new FakeSession { State = ConnectionState.Connected }; + var b = new FakeSession { State = ConnectionState.Connected }; + mgr.Add(a); + vm.Body = "draft for a"; + mgr.Add(b); + vm.Body = "draft for b"; + + mgr.Activate(a); + await Assert.That(vm.Body).IsEqualTo("draft for a"); + + mgr.Activate(b); + await Assert.That(vm.Body).IsEqualTo("draft for b"); + } + + [Test] + public async Task DraftsArePrunedWhenSessionCloses() + { + var (vm, mgr, _) = Build(); + var a = new FakeSession { State = ConnectionState.Connected }; + mgr.Add(a); + vm.Body = "draft for a"; + + await mgr.CloseAsync(a); + + await Assert.That(vm.TrackedDraftCount).IsEqualTo(0); + } + + [Test] + public async Task CustomPrefixPersistsPerWorld() + { + var worldA = Guid.NewGuid(); + var worldB = Guid.NewGuid(); + var (vm, mgr, prefs) = Build(); + var a = new FakeSession { State = ConnectionState.Connected, WorldId = worldA }; + var b = new FakeSession { State = ConnectionState.Connected, WorldId = worldB }; + + mgr.Add(a); + vm.SelectedPrefix = PosePrefix.Custom; + vm.CustomPrefix = "page Alice="; + + mgr.Add(b); + vm.CustomPrefix = "page Bob="; + + await Assert.That(prefs.GetString($"compose.custom.{worldA}", "")).IsEqualTo("page Alice="); + await Assert.That(prefs.GetString($"compose.custom.{worldB}", "")).IsEqualTo("page Bob="); + } + + [Test] + public async Task CustomPrefixIsReloadedWhenActiveSessionChanges() + { + var worldA = Guid.NewGuid(); + var worldB = Guid.NewGuid(); + var (vm, mgr, _) = Build(); + var a = new FakeSession { State = ConnectionState.Connected, WorldId = worldA }; + var b = new FakeSession { State = ConnectionState.Connected, WorldId = worldB }; + + mgr.Add(a); + vm.CustomPrefix = "page Alice="; + mgr.Add(b); + vm.CustomPrefix = "page Bob="; + + mgr.Activate(a); + + await Assert.That(vm.CustomPrefix).IsEqualTo("page Alice="); + } + + [Test] + public async Task ChangedFiresWhenConnectionStateChanges() + { + var (vm, mgr, _) = Build(); + var s = new FakeSession { State = ConnectionState.Disconnected }; + mgr.Add(s); + var fired = 0; + vm.Changed += () => fired++; + + s.RaiseState(ConnectionState.Connected); + + await Assert.That(fired).IsEqualTo(1); + await Assert.That(vm.CanSend).IsFalse(); + } + + [Test] + public async Task ClearEmptiesOnlyTheActiveDraft() + { + var (vm, mgr, _) = Build(); + var a = new FakeSession { State = ConnectionState.Connected }; + var b = new FakeSession { State = ConnectionState.Connected }; + mgr.Add(a); + vm.Body = "keep me"; + mgr.Add(b); + vm.Body = "drop me"; + + vm.Clear(); + + await Assert.That(vm.Body).IsEqualTo(string.Empty); + mgr.Activate(a); + await Assert.That(vm.Body).IsEqualTo("keep me"); + } +} From 71bf869039d30a809c7ef934e450ed5944d52a99 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 01:03:25 -0500 Subject: [PATCH 06/19] feat(compose): full-height Compose tab with preview toggle Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015MPRzndB5egy4F2A3q5852 --- .../Components/ComposeView.razor | 102 +++++++++++++ src/SharpClient.UI/Layout/MainLayout.razor | 7 + src/SharpClient.UI/Pages/ComposePage.razor | 7 + src/SharpClient.UI/wwwroot/app.css | 18 +++ .../SharpClient.UI.Tests/ComposeViewTests.cs | 144 ++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 src/SharpClient.UI/Components/ComposeView.razor create mode 100644 src/SharpClient.UI/Pages/ComposePage.razor create mode 100644 tests/SharpClient.UI.Tests/ComposeViewTests.cs diff --git a/src/SharpClient.UI/Components/ComposeView.razor b/src/SharpClient.UI/Components/ComposeView.razor new file mode 100644 index 0000000..87af8c7 --- /dev/null +++ b/src/SharpClient.UI/Components/ComposeView.razor @@ -0,0 +1,102 @@ +@using SharpClient.Core.Connection +@using SharpClient.Core.Formatting +@using SharpClient.Core.Presentation +@implements IDisposable + +
+
+ @foreach (var option in PrefixOptions) + { + var value = option.Value; + + } +
+ + @if (Vm.SelectedPrefix == PosePrefix.Custom) + { + + } + +
+ @if (_previewing) + { +
@Vm.Preview
+ } + else + { + + } +
+ + +
+ +@code { + [Parameter] + public ComposeViewModel Vm { get; set; } = null!; + + private static readonly (string Label, PosePrefix Value)[] PrefixOptions = + [ + ("say", PosePrefix.Say), + ("pose", PosePrefix.Pose), + ("semipose", PosePrefix.Semipose), + ("@emit", PosePrefix.Emit), + ("custom", PosePrefix.Custom), + ]; + + private bool _previewing; + + protected override void OnInitialized() => Vm.Changed += OnChanged; + + private void OnChanged() => InvokeAsync(StateHasChanged); + + private void OnBodyInput(ChangeEventArgs e) => Vm.Body = e.Value?.ToString() ?? string.Empty; + + private void TogglePreview() => _previewing = !_previewing; + + private void Clear() + { + Vm.Clear(); + _previewing = false; + } + + // Ctrl/Cmd+Enter sends; a bare Enter has to stay a newline in a multi-line composer. + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && (e.CtrlKey || e.MetaKey)) + { + await SendAsync(); + } + } + + private async Task SendAsync() + { + await Vm.SendAsync(); + _previewing = false; + StateHasChanged(); + } + + public void Dispose() => Vm.Changed -= OnChanged; +} diff --git a/src/SharpClient.UI/Layout/MainLayout.razor b/src/SharpClient.UI/Layout/MainLayout.razor index d849aa2..76f03d2 100644 --- a/src/SharpClient.UI/Layout/MainLayout.razor +++ b/src/SharpClient.UI/Layout/MainLayout.razor @@ -30,6 +30,13 @@ Session + + + Compose +
+
+ @foreach (var option in FilterOptions) + { + var value = option.Value; + + } +
+ + + + +
+
+ +
+ @if (Visible.Count == 0) + { +
No log entries recorded yet.
+ } + else + { + @foreach (var entry in Visible) + { +
+
+ @entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + @entry.Level + @if (entry.Category.Length > 0) + { + @entry.Category + } +
+
@entry.Message
+ @if (entry.Detail is not null) + { +
+ Detail +
@entry.Detail
+
+ } +
+ } + @if (_truncated) + { +
Older entries were truncated.
+ } + } +
+
+ +@code { + private const int MaxEntries = 500; + + [Parameter] + public string? InitialFilter { get; set; } + + private enum LogFilter { All, Errors, Crashes } + + private static readonly (string Label, LogFilter Value)[] FilterOptions = + [ + ("All", LogFilter.All), + ("Errors", LogFilter.Errors), + ("Crashes", LogFilter.Crashes), + ]; + + private IReadOnlyList _entries = []; + private LogFilter _filter = LogFilter.All; + private bool _confirmingClear; + private bool _truncated; + private IJSObjectReference? _interop; + + private List Visible => _filter switch + { + LogFilter.Crashes => _entries.Where(IsCrash).ToList(), + LogFilter.Errors => _entries.Where(e => IsCrash(e) || IsError(e)).ToList(), + _ => _entries.ToList(), + }; + + private static bool IsCrash(LogEntry e) => e.Level.Equals("CRASH", StringComparison.OrdinalIgnoreCase); + + private static bool IsError(LogEntry e) => + e.Level.Equals("Error", StringComparison.OrdinalIgnoreCase) + || e.Level.Equals("Critical", StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + if (string.Equals(InitialFilter, "crashes", StringComparison.OrdinalIgnoreCase)) + { + _filter = LogFilter.Crashes; + } + else if (string.Equals(InitialFilter, "errors", StringComparison.OrdinalIgnoreCase)) + { + _filter = LogFilter.Errors; + } + + await RefreshAsync(); + } + + private async Task RefreshAsync() + { + _entries = await Reader.ReadAsync(MaxEntries); + _truncated = _entries.Count == MaxEntries; + _confirmingClear = false; + } + + private async Task CopyAsync() + { + var text = string.Join('\n', Visible.Select(e => + $"{e.Timestamp:yyyy-MM-dd HH:mm:ss} [{e.Level}] {e.Category}: {e.Message}" + + (e.Detail is null ? string.Empty : "\n" + e.Detail))); + + _interop ??= await JS.InvokeAsync( + "import", "./_content/SharpClient.UI/sc-interop.js"); + await _interop.InvokeVoidAsync("copyText", text); + } + + private async Task ShareAsync() + { + if (Exporter.IsAvailable) + { + await Exporter.ShareAsync(); + } + } + + // Two-step rather than a JS confirm(): a modal dialog inside the Android WebView blocks the + // Blazor circuit. + private async Task ClearAsync() + { + if (!_confirmingClear) + { + _confirmingClear = true; + return; + } + + await Reader.ClearAsync(); + await RefreshAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_interop is not null) + { + await _interop.DisposeAsync(); + } + } +} diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor index 3523f13..c735899 100644 --- a/src/SharpClient.UI/Components/SettingsView.razor +++ b/src/SharpClient.UI/Components/SettingsView.razor @@ -90,6 +90,10 @@ {
Diagnostics
+
+ View log + Open +
Crash & error log + +
+} + +@code { + private CrashReport? _report; + + protected override async Task OnInitializedAsync() => _report = await Reader.GetPendingCrashAsync(); + + private void View() => Nav.NavigateTo("/diagnostics?filter=crashes"); + + private async Task DismissAsync() + { + await Reader.DismissCrashAsync(); + _report = null; + } +} diff --git a/src/SharpClient.UI/Layout/MainLayout.razor b/src/SharpClient.UI/Layout/MainLayout.razor index 76f03d2..fc54e46 100644 --- a/src/SharpClient.UI/Layout/MainLayout.razor +++ b/src/SharpClient.UI/Layout/MainLayout.razor @@ -9,6 +9,8 @@
+ +
@Body
diff --git a/src/SharpClient.UI/wwwroot/app.css b/src/SharpClient.UI/wwwroot/app.css index 805e489..2034953 100644 --- a/src/SharpClient.UI/wwwroot/app.css +++ b/src/SharpClient.UI/wwwroot/app.css @@ -861,3 +861,9 @@ body { .sc-diag-detail summary { font-family: var(--mono); font-size: 11px; color: var(--dim); cursor: pointer; } .sc-diag-detail pre { margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: pre-wrap; overflow-wrap: anywhere; } .sc-diag-empty { font-family: var(--mono); font-size: 12px; color: var(--faint); padding: 14px 2px; } + +/* ── Crash banner ──────────────────────────────────────────────── */ +.sc-crash-banner { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: rgba(238,136,136,.12); border-bottom: 1px solid rgba(238,136,136,.4); } +.sc-crash-text { flex: 1; font-family: var(--mono); font-size: 11px; color: #e8a; overflow-wrap: anywhere; } +.sc-crash-view { font-family: var(--mono); font-size: 11px; color: var(--acc2); background: var(--acc-soft); border: 1px solid var(--acc-line); border-radius: 7px; padding: 4px 10px; cursor: pointer; } +.sc-crash-dismiss { font-size: 12px; line-height: 1; color: var(--dim); background: none; border: none; padding: 4px 6px; cursor: pointer; } diff --git a/tests/SharpClient.UI.Tests/CrashBannerTests.cs b/tests/SharpClient.UI.Tests/CrashBannerTests.cs new file mode 100644 index 0000000..431e27c --- /dev/null +++ b/tests/SharpClient.UI.Tests/CrashBannerTests.cs @@ -0,0 +1,58 @@ +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Diagnostics; +using SharpClient.UI.Components; + +namespace SharpClient.UI.Tests; + +public sealed class CrashBannerTests +{ + private static (BunitContext ctx, UiFakeLogReader reader) NewContext(CrashReport? pending) + { + var ctx = new BunitContext(); + var reader = new UiFakeLogReader { Pending = pending }; + ctx.Services.AddSingleton(reader); + return (ctx, reader); + } + + [Test] + public async Task NothingIsRenderedWhenThereIsNoPendingCrash() + { + var (ctx, _) = NewContext(null); + using var _unused = ctx; + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-crash-banner")).IsEmpty(); + } + + [Test] + public async Task BannerShowsTheCrashTimestamp() + { + var report = new CrashReport( + new DateTimeOffset(2026, 8, 9, 23, 41, 0, TimeSpan.Zero), "AppDomain", "boom", "stack"); + var (ctx, _) = NewContext(report); + using var _unused = ctx; + + var cut = ctx.Render(); + + await Assert.That(cut.FindAll(".sc-crash-banner")).Count().IsEqualTo(1); + await Assert.That(cut.Find(".sc-crash-banner").TextContent).Contains("2026-08-09 23:41"); + } + + [Test] + public async Task DismissingHidesTheBannerAndTellsTheReader() + { + var report = new CrashReport( + new DateTimeOffset(2026, 8, 9, 23, 41, 0, TimeSpan.Zero), "AppDomain", "boom", "stack"); + var (ctx, reader) = NewContext(report); + using var _unused = ctx; + + var cut = ctx.Render(); + await cut.Find(".sc-crash-dismiss").ClickAsync(new MouseEventArgs()); + + await Assert.That(reader.DismissCalls).IsEqualTo(1); + await Assert.That(cut.FindAll(".sc-crash-banner")).IsEmpty(); + } +} From 5373664d8cec36521f5ac4c8b4e2901f6e67448d Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 12:26:27 -0500 Subject: [PATCH 18/19] fix: address final-review findings for compose drafts and diagnostics gating - Compose: keep a fallback draft while no session is active and adopt it into the first session that becomes active, instead of discarding text typed before connecting. - Diagnostics: re-apply InitialFilter on OnParametersSet (not just on init) so the crash banner's deep link works while already on /diagnostics, without clobbering a manually chosen filter chip. - Settings: gate the "View log" row on ILogReader.IsAvailable and "Export log" on ILogExporter.IsAvailable, matching the design doc, instead of gating both off the exporter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015MPRzndB5egy4F2A3q5852 --- .../Presentation/ComposeViewModel.cs | 21 +++++++++++- .../Components/DiagnosticsView.razor | 27 +++++++++++---- .../Components/SettingsView.razor | 29 ++++++++++------ .../Presentation/ComposeViewModelTests.cs | 20 +++++++++++ .../DiagnosticsViewTests.cs | 32 +++++++++++++++++ .../SharpClient.UI.Tests/SettingsViewTests.cs | 34 ++++++++++++++++--- 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/SharpClient.Core/Presentation/ComposeViewModel.cs b/src/SharpClient.Core/Presentation/ComposeViewModel.cs index 4bd8fe6..c75adc5 100644 --- a/src/SharpClient.Core/Presentation/ComposeViewModel.cs +++ b/src/SharpClient.Core/Presentation/ComposeViewModel.cs @@ -13,6 +13,7 @@ public sealed class ComposeViewModel private ISession? _activeSession; private PosePrefix _selectedPrefix = PosePrefix.Pose; private string _customPrefix = string.Empty; + private string _pendingDraft = string.Empty; public ComposeViewModel(ISessionManager manager, IPreferences prefs) { @@ -55,11 +56,21 @@ public string CustomPrefix public string Body { - get => Active is not null && _drafts.TryGetValue(Active, out var draft) ? draft : string.Empty; + get + { + if (Active is null) + { + return _pendingDraft; + } + + return _drafts.TryGetValue(Active, out var draft) ? draft : string.Empty; + } set { if (Active is null) { + _pendingDraft = value; + Changed?.Invoke(); return; } @@ -124,6 +135,8 @@ private void TrackActiveSession(ISession? newActive) return; } + var hadNoActiveSession = _activeSession is null; + if (_activeSession is not null) { _activeSession.StateChanged -= OnActiveStateChanged; @@ -135,6 +148,12 @@ private void TrackActiveSession(ISession? newActive) { _activeSession.StateChanged += OnActiveStateChanged; _customPrefix = _prefs.GetString(CustomPrefixKey(_activeSession.WorldId), string.Empty); + + if (hadNoActiveSession && _pendingDraft.Length > 0 && !_drafts.ContainsKey(_activeSession)) + { + _drafts[_activeSession] = _pendingDraft; + _pendingDraft = string.Empty; + } } } diff --git a/src/SharpClient.UI/Components/DiagnosticsView.razor b/src/SharpClient.UI/Components/DiagnosticsView.razor index d43bc6d..6293d30 100644 --- a/src/SharpClient.UI/Components/DiagnosticsView.razor +++ b/src/SharpClient.UI/Components/DiagnosticsView.razor @@ -80,6 +80,7 @@ private bool _confirmingClear; private bool _truncated; private IJSObjectReference? _interop; + private string? _appliedInitialFilter; private List Visible => _filter switch { @@ -94,18 +95,30 @@ e.Level.Equals("Error", StringComparison.OrdinalIgnoreCase) || e.Level.Equals("Critical", StringComparison.OrdinalIgnoreCase); + private static LogFilter ParseInitialFilter(string? initialFilter) => initialFilter switch + { + _ when string.Equals(initialFilter, "crashes", StringComparison.OrdinalIgnoreCase) => LogFilter.Crashes, + _ when string.Equals(initialFilter, "errors", StringComparison.OrdinalIgnoreCase) => LogFilter.Errors, + _ => LogFilter.All, + }; + protected override async Task OnInitializedAsync() { - if (string.Equals(InitialFilter, "crashes", StringComparison.OrdinalIgnoreCase)) - { - _filter = LogFilter.Crashes; - } - else if (string.Equals(InitialFilter, "errors", StringComparison.OrdinalIgnoreCase)) + _appliedInitialFilter = InitialFilter; + _filter = ParseInitialFilter(InitialFilter); + + await RefreshAsync(); + } + + protected override void OnParametersSet() + { + if (string.Equals(InitialFilter, _appliedInitialFilter, StringComparison.Ordinal)) { - _filter = LogFilter.Errors; + return; } - await RefreshAsync(); + _appliedInitialFilter = InitialFilter; + _filter = ParseInitialFilter(InitialFilter); } private async Task RefreshAsync() diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor index c735899..99e2c02 100644 --- a/src/SharpClient.UI/Components/SettingsView.razor +++ b/src/SharpClient.UI/Components/SettingsView.razor @@ -1,6 +1,7 @@ @using SharpClient.Core.Presentation @using SharpClient.Core.Diagnostics @inject ILogExporter LogExporter +@inject ILogReader LogReader @implements IDisposable
@@ -86,20 +87,26 @@
- @if (LogExporter.IsAvailable) + @if (LogReader.IsAvailable || LogExporter.IsAvailable) {
Diagnostics
-
- View log - Open -
-
- Crash & error log - -
+ @if (LogReader.IsAvailable) + { +
+ View log + Open +
+ } + @if (LogExporter.IsAvailable) + { +
+ Crash & error log + +
+ }
}
diff --git a/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs index a14c29e..4ca00d6 100644 --- a/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs +++ b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs @@ -189,6 +189,26 @@ public async Task ChangedFiresWhenConnectionStateChanges() await Assert.That(vm.CanSend).IsFalse(); } + [Test] + public async Task TextTypedWithNoSessionSurvivesReadBack() + { + var (vm, _, _) = Build(); + vm.Body = "waiting to connect"; + + await Assert.That(vm.Body).IsEqualTo("waiting to connect"); + } + + [Test] + public async Task TextTypedWithNoSessionIsAdoptedByFirstActiveSession() + { + var (vm, mgr, _) = Build(); + vm.Body = "waiting to connect"; + + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + + await Assert.That(vm.Body).IsEqualTo("waiting to connect"); + } + [Test] public async Task ClearEmptiesOnlyTheActiveDraft() { diff --git a/tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs b/tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs index e6d9dc1..09b7684 100644 --- a/tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs +++ b/tests/SharpClient.UI.Tests/DiagnosticsViewTests.cs @@ -86,6 +86,38 @@ public async Task InitialFilterParameterSelectsCrashes() await Assert.That(cut.FindAll(".sc-diag-entry")).Count().IsEqualTo(1); } + [Test] + public async Task ReRenderingWithChangedInitialFilterSwitchesVisibleEntries() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(); + await Assert.That(cut.FindAll(".sc-diag-entry")).Count().IsEqualTo(3); + + cut.Render(p => p.Add(c => c.InitialFilter, "crashes")); + + await Assert.That(cut.FindAll(".sc-diag-entry")).Count().IsEqualTo(1); + await Assert.That(cut.Find(".sc-diag-entry").TextContent).Contains("boom"); + } + + [Test] + public async Task ManuallyChosenFilterIsNotClobberedByUnchangedInitialFilter() + { + var (ctx, reader) = NewContext(); + using var _ = ctx; + Seed(reader); + + var cut = ctx.Render(p => p.Add(c => c.InitialFilter, "crashes")); + await cut.FindAll(".sc-diag-chip")[0].ClickAsync(new MouseEventArgs()); + await Assert.That(cut.FindAll(".sc-diag-entry")).Count().IsEqualTo(3); + + cut.Render(p => p.Add(c => c.InitialFilter, "crashes")); + + await Assert.That(cut.FindAll(".sc-diag-entry")).Count().IsEqualTo(3); + } + [Test] public async Task DetailIsRenderedForEntriesThatHaveIt() { diff --git a/tests/SharpClient.UI.Tests/SettingsViewTests.cs b/tests/SharpClient.UI.Tests/SettingsViewTests.cs index 0ac4430..aa26d2f 100644 --- a/tests/SharpClient.UI.Tests/SettingsViewTests.cs +++ b/tests/SharpClient.UI.Tests/SettingsViewTests.cs @@ -22,12 +22,14 @@ public sealed class SettingsViewTests { private static SettingsViewModel MakeVm() => new(new LocalFakePrefs()); - // SettingsView injects ILogExporter; register a no-op so renders resolve. IsAvailable == false - // hides the Diagnostics section, leaving the assertions below (font/accent/slider counts) intact. - private static BunitContext NewContext() + // SettingsView injects ILogExporter and ILogReader; register no-ops so renders resolve. + // IsAvailable == false on both hides the Diagnostics section, leaving the assertions below + // (font/accent/slider counts) intact. + private static BunitContext NewContext(ILogReader? reader = null, ILogExporter? exporter = null) { var ctx = new BunitContext(); - ctx.Services.AddSingleton(new NoopLogExporter()); + ctx.Services.AddSingleton(exporter ?? new NoopLogExporter()); + ctx.Services.AddSingleton(reader ?? new NoopLogReader()); return ctx; } @@ -142,4 +144,28 @@ public async Task MaxFontSizeSliderRendersWithCorrectValue() var slider = cut.Find("input[type='range'][min='6']"); await Assert.That(slider.GetAttribute("value")).IsEqualTo("16"); } + + [Test] + public async Task ViewLogRowAppearsWhenLogReaderIsAvailable() + { + using var ctx = NewContext(reader: new UiFakeLogReader()); + var vm = MakeVm(); + + var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); + + await Assert.That(cut.FindAll(".sc-settings-section-header").Select(e => e.TextContent)) + .Contains("Diagnostics"); + await Assert.That(cut.Find("a.sc-rules-btn").TextContent.Trim()).IsEqualTo("Open"); + } + + [Test] + public async Task ViewLogRowIsAbsentWhenLogReaderIsUnavailable() + { + using var ctx = NewContext(); + var vm = MakeVm(); + + var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); + + await Assert.That(cut.FindAll("a.sc-rules-btn")).IsEmpty(); + } } From 3a0ce205606b62600d50685ca6d8f69834eedd06 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Tue, 11 Aug 2026 12:34:03 -0500 Subject: [PATCH 19/19] fix: close re-review gaps in compose clear and settings gating tests - Clear() now empties the pending draft when no session is active, so the button isn't a dead no-op now that Body persists text typed before a session connects. - Add SettingsView coverage proving the View log / Export log rows are gated independently (reader-only and exporter-only host combinations). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015MPRzndB5egy4F2A3q5852 --- .../Presentation/ComposeViewModel.cs | 2 ++ .../Presentation/ComposeViewModelTests.cs | 14 ++++++++ .../SharpClient.UI.Tests/SettingsViewTests.cs | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/SharpClient.Core/Presentation/ComposeViewModel.cs b/src/SharpClient.Core/Presentation/ComposeViewModel.cs index c75adc5..a743d8f 100644 --- a/src/SharpClient.Core/Presentation/ComposeViewModel.cs +++ b/src/SharpClient.Core/Presentation/ComposeViewModel.cs @@ -107,6 +107,8 @@ public void Clear() { if (Active is null) { + _pendingDraft = string.Empty; + Changed?.Invoke(); return; } diff --git a/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs index 4ca00d6..5cae7c6 100644 --- a/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs +++ b/tests/SharpClient.Tests/Presentation/ComposeViewModelTests.cs @@ -209,6 +209,20 @@ public async Task TextTypedWithNoSessionIsAdoptedByFirstActiveSession() await Assert.That(vm.Body).IsEqualTo("waiting to connect"); } + [Test] + public async Task ClearWithNoSessionEmptiesThePendingDraftAndPreventsAdoption() + { + var (vm, mgr, _) = Build(); + vm.Body = "waiting to connect"; + + vm.Clear(); + await Assert.That(vm.Body).IsEqualTo(string.Empty); + + mgr.Add(new FakeSession { State = ConnectionState.Connected }); + + await Assert.That(vm.Body).IsEqualTo(string.Empty); + } + [Test] public async Task ClearEmptiesOnlyTheActiveDraft() { diff --git a/tests/SharpClient.UI.Tests/SettingsViewTests.cs b/tests/SharpClient.UI.Tests/SettingsViewTests.cs index aa26d2f..dc73d58 100644 --- a/tests/SharpClient.UI.Tests/SettingsViewTests.cs +++ b/tests/SharpClient.UI.Tests/SettingsViewTests.cs @@ -20,6 +20,13 @@ namespace SharpClient.UI.Tests; public sealed class SettingsViewTests { + private sealed class AvailableLogExporter : ILogExporter + { + public bool IsAvailable => true; + public string? LogPath => null; + public Task ShareAsync() => Task.CompletedTask; + } + private static SettingsViewModel MakeVm() => new(new LocalFakePrefs()); // SettingsView injects ILogExporter and ILogReader; register no-ops so renders resolve. @@ -168,4 +175,33 @@ public async Task ViewLogRowIsAbsentWhenLogReaderIsUnavailable() await Assert.That(cut.FindAll("a.sc-rules-btn")).IsEmpty(); } + + [Test] + public async Task ReaderAvailableExporterUnavailableShowsOnlyViewLog() + { + using var ctx = NewContext(reader: new UiFakeLogReader()); + var vm = MakeVm(); + + var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); + + await Assert.That(cut.FindAll("a.sc-rules-btn")).Count().IsEqualTo(1); + await Assert.That(cut.Find("a.sc-rules-btn").TextContent.Trim()).IsEqualTo("Open"); + await Assert.That(cut.FindAll(".sc-setting-label").Select(e => e.TextContent)) + .DoesNotContain("Crash & error log"); + } + + [Test] + public async Task ExporterAvailableReaderUnavailableShowsOnlyExportLog() + { + using var ctx = NewContext(exporter: new AvailableLogExporter()); + var vm = MakeVm(); + + var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); + + await Assert.That(cut.FindAll("a.sc-rules-btn")).IsEmpty(); + await Assert.That(cut.FindAll(".sc-setting-label").Select(e => e.TextContent)) + .DoesNotContain("View log"); + await Assert.That(cut.FindAll("button.sc-rules-btn").Select(e => e.TextContent.Trim())) + .Contains("Export log"); + } }