From 817eeff0812afcca6530ac8b62c98ee1c8a94058 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Tue, 4 Aug 2026 03:10:06 -0700 Subject: [PATCH] Merge nucleic/warm-north-vole-pf37 into dev --- NucleicApp.Tests/PresentationModelTests.cs | 113 ++++++++++++++++++ NucleicApp/ViewModels/HomeViewModel.cs | 105 ++++++++++++++++ .../ViewModels/SessionDetailViewModel.cs | 98 +++++++++++++++ NucleicApp/ViewModels/ShellViewModel.cs | 92 ++++++++++++++ NucleicApp/ViewModels/ViewModelBase.cs | 20 ++++ NucleicProtocol.Interop/ClientIntents.cs | 61 +++++++--- 6 files changed, 473 insertions(+), 16 deletions(-) create mode 100644 NucleicApp.Tests/PresentationModelTests.cs create mode 100644 NucleicApp/ViewModels/HomeViewModel.cs create mode 100644 NucleicApp/ViewModels/SessionDetailViewModel.cs create mode 100644 NucleicApp/ViewModels/ShellViewModel.cs create mode 100644 NucleicApp/ViewModels/ViewModelBase.cs diff --git a/NucleicApp.Tests/PresentationModelTests.cs b/NucleicApp.Tests/PresentationModelTests.cs new file mode 100644 index 0000000..5fc396d --- /dev/null +++ b/NucleicApp.Tests/PresentationModelTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using NucleicApp.Services; +using NucleicApp.ViewModels; +using NucleicProtocol.Interop; + +namespace NucleicApp.Tests; + +public sealed class PresentationModelTests +{ + [Fact] + public void HomeDerivesAttentionActivityAndCountsOnlyFromHostProjections() + { + using var store = new RendererStore(); + store.Apply(""" + {"input":[ + {"archived":false,"disposition":"awaitingInput","pendingApprovalCount":0, + "sessionID":"needs-input","status":"awaitingInput","title":"Needs input","updatedAt":3}, + {"archived":false,"pendingApprovalCount":0, + "sessionID":"running","status":"running","title":"Running","updatedAt":2}, + {"archived":true,"pendingApprovalCount":1, + "sessionID":"archived","status":"awaitingApproval","title":"Archived","updatedAt":4} + ],"t":"sessionList"} + """); + store.Apply(""" + {"activity":[],"counts":{"activeChats":2,"chats":3,"messages":8, + "projects":1,"tokens":1200},"projects":[],"statusFeeds":[], + "t":"dashboard","todos":[{"status":"open"},{"status":"done"}]} + """); + using var home = new HomeViewModel( + store, () => new DateTimeOffset(2026, 8, 4, 9, 0, 0, TimeSpan.Zero)); + + Assert.Equal("Good morning", home.Greeting); + Assert.Equal(new DashboardCounts(1, 2, 3, 8, 1200), home.Counts); + Assert.Equal("2 active of 3 chats · 1 project", home.Subtitle); + Assert.Equal("needs-input", Assert.Single(home.NeedsAttention).Id); + Assert.Equal("running", Assert.Single(home.InProgress).Id); + Assert.Equal(2, home.LiveSessions.Count); + Assert.Equal(1, home.OpenTodoCount); + } + + [Fact] + public async Task ShellSessionLifecycleEmitsSubscriptionComposerAndApprovalIntents() + { + var directory = Directory.CreateTempSubdirectory("nucleic-shell-"); + try + { + var rendezvousPath = Path.Combine(directory.FullName, "hostd.json"); + await File.WriteAllTextAsync(rendezvousPath, JsonSerializer.Serialize( + ProtocolInteropTests.ValidRendezvous())); + var native = new FakeNativeProtocol + { + ProjectionResult = + "{\"sessionID\":\"session-1\",\"splices\":[{\"deleteCount\":0,\"items\":[],\"start\":0}],\"version\":1}", + }; + using var store = new RendererStore(new ProtocolProjection(native)); + store.Apply(TestRepository.Fixture("host", "sessionList")); + store.Apply(TestRepository.Fixture("host", "approvalRequested")); + + var client = new FakeProtocolClient(); + await using var connection = new HostConnection( + store, Path.Combine(directory.FullName, "identity"), + new FakeProtocolClientFactory(client), InlineRendererDispatcher.Instance); + await connection.StartLocalAsync(rendezvousPath); + using var shell = new ShellViewModel(store, connection); + + Assert.Equal(ProtocolResult.Ok, shell.OpenSession("session-1", 6)); + var detail = Assert.IsType(shell.SelectedSession); + Assert.Equal(ShellSection.Sessions, shell.Section); + Assert.Equal("session-1", detail.Session?.Id); + Assert.Equal("approval-1", Assert.Single(detail.PendingApprovals).Id); + + Assert.Equal(ProtocolResult.InvalidArgument, detail.SendText(" ")); + Assert.Equal(ProtocolResult.Ok, detail.SendText("continue")); + var approval = Assert.Single(detail.PendingApprovals); + Assert.Equal(ProtocolResult.Ok, detail.Allow(approval)); + Assert.Equal(ProtocolResult.Ok, detail.Deny(approval, "not now")); + Assert.Equal(ProtocolResult.Ok, + detail.AllowAlways(approval, ApprovalAlwaysScope.ToolName)); + Assert.Equal(ProtocolResult.Ok, detail.CancelRun(approval)); + + store.Apply(TestRepository.Fixture("host", "pong")); + Assert.NotNull(detail.LastProjection); + + shell.Navigate(ShellSection.Home); + Assert.Null(shell.SelectedSession); + Assert.Equal(ShellSection.Home, shell.Section); + + var messages = client.Intents.Select(ProtocolMessage.Parse).ToArray(); + Assert.Equal( + ["subscribe", "sendInput", "approvalRespond", "approvalRespond", + "approvalRespond", "approvalRespond", "unsubscribe"], + messages.Select(message => message.Tag)); + Assert.Equal(6UL, messages[0].Root.GetProperty("sinceSeq").GetUInt64()); + Assert.Equal("continue", + messages[1].Root.GetProperty("input").GetProperty("parts")[0] + .GetProperty("text").GetString()); + Assert.Equal("allow", + messages[2].Root.GetProperty("decision").GetProperty("type").GetString()); + Assert.Equal("deny", + messages[3].Root.GetProperty("decision").GetProperty("type").GetString()); + Assert.Equal("not now", + messages[3].Root.GetProperty("decision").GetProperty("reason").GetString()); + Assert.Equal("allowAlways", + messages[4].Root.GetProperty("decision").GetProperty("type").GetString()); + Assert.Equal("toolName", + messages[4].Root.GetProperty("decision").GetProperty("scope").GetString()); + Assert.Equal("cancelRun", + messages[5].Root.GetProperty("decision").GetProperty("type").GetString()); + Assert.Equal("session-1", messages[6].Root.GetProperty("sessionID").GetString()); + } + finally { directory.Delete(recursive: true); } + } +} diff --git a/NucleicApp/ViewModels/HomeViewModel.cs b/NucleicApp/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..adf2b12 --- /dev/null +++ b/NucleicApp/ViewModels/HomeViewModel.cs @@ -0,0 +1,105 @@ +using System.ComponentModel; +using System.Text.Json; +using NucleicApp.Services; + +namespace NucleicApp.ViewModels; + +/// Presentation state for the first WinUI Home screen. It derives every value from +/// ``RendererStore`` projections; it never mutates host-owned state. +public sealed class HomeViewModel : ViewModelBase, IDisposable +{ + private readonly RendererStore store; + private readonly Func now; + + public HomeViewModel(RendererStore store, Func? now = null) + { + this.store = store; + this.now = now ?? (() => DateTimeOffset.Now); + store.PropertyChanged += StoreChanged; + } + + public string Greeting => now().Hour switch + { + >= 5 and < 12 => "Good morning", + >= 12 and < 17 => "Good afternoon", + >= 17 and < 22 => "Good evening", + _ => "Hello", + }; + + public DashboardCounts Counts + { + get + { + if (store.Dashboard is not { } dashboard || + !dashboard.TryGetProperty("counts", out var counts)) return new(); + return new DashboardCounts( + Int(counts, "projects"), Int(counts, "activeChats"), + Int(counts, "chats"), Int(counts, "messages"), Long(counts, "tokens")); + } + } + + public string Subtitle => Counts.Chats == 0 + ? "Start a chat below to get going." + : $"{Counts.ActiveChats} active of {Counts.Chats} chats · {Counts.Projects} " + + (Counts.Projects == 1 ? "project" : "projects"); + + public IReadOnlyList LiveSessions => store.Sessions + .Where(session => !Bool(session.Raw, "archived")) + .OrderByDescending(UpdatedAt) + .ToArray(); + + public IReadOnlyList NeedsAttention => LiveSessions + .Where(session => session.PendingApprovalCount > 0 || session.Status == "awaitingApproval" || + (session.Status == "awaitingInput" && String(session.Raw, "disposition") == "awaitingInput")) + .ToArray(); + + public IReadOnlyList InProgress => LiveSessions + .Where(session => session.Status is "provisioning" or "running") + .ToArray(); + + public int OpenTodoCount + { + get + { + if (store.Dashboard is not { } dashboard || + !dashboard.TryGetProperty("todos", out var todos) || + todos.ValueKind != JsonValueKind.Array) return 0; + return todos.EnumerateArray().Count(todo => String(todo, "status") != "done"); + } + } + + private void StoreChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(RendererStore.Sessions)) + { + Raise(nameof(LiveSessions)); + Raise(nameof(NeedsAttention)); + Raise(nameof(InProgress)); + } + else if (args.PropertyName == nameof(RendererStore.Dashboard)) + { + Raise(nameof(Counts)); + Raise(nameof(Subtitle)); + Raise(nameof(OpenTodoCount)); + } + } + + private static bool Bool(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + private static string? String(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + private static int Int(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : 0; + private static long Long(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.TryGetInt64(out var result) ? result : 0; + private static double UpdatedAt(SessionSummary session) => + session.Raw.TryGetProperty("updatedAt", out var value) && value.TryGetDouble(out var result) + ? result : 0; + + public void Dispose() => store.PropertyChanged -= StoreChanged; +} + +public sealed record DashboardCounts( + int Projects = 0, int ActiveChats = 0, int Chats = 0, + int Messages = 0, long Tokens = 0); diff --git a/NucleicApp/ViewModels/SessionDetailViewModel.cs b/NucleicApp/ViewModels/SessionDetailViewModel.cs new file mode 100644 index 0000000..fd34e4d --- /dev/null +++ b/NucleicApp/ViewModels/SessionDetailViewModel.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using System.Text.Json; +using NucleicApp.Services; +using NucleicProtocol.Interop; + +namespace NucleicApp.ViewModels; + +/// Presentation and intent surface for one session detail screen. Transcript rows remain owned +/// by the shared Swift projector; this model forwards only that session's splice diffs. +public sealed class SessionDetailViewModel : ViewModelBase, IDisposable +{ + private readonly RendererStore store; + private readonly HostConnection connection; + private ProjectionDiff? lastProjection; + private bool active; + private bool disposed; + + public SessionDetailViewModel( + string sessionId, RendererStore store, HostConnection connection) + { + SessionId = sessionId; + this.store = store; + this.connection = connection; + store.PropertyChanged += StoreChanged; + store.ProjectionChanged += ProjectionChanged; + } + + public string SessionId { get; } + public SessionSummary? Session => store.Sessions.FirstOrDefault(item => item.Id == SessionId); + public IReadOnlyList PendingApprovals => store.PendingApprovals + .Where(item => item.SessionId == SessionId).ToArray(); + public ProjectionDiff? LastProjection + { + get => lastProjection; + private set => Set(ref lastProjection, value); + } + + public ProtocolResult Activate(ulong? sinceSequence = null) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (active) return ProtocolResult.Ok; + var result = connection.SendIntent(ClientIntents.Subscribe(SessionId, sinceSequence)); + if (result == ProtocolResult.Ok) active = true; + return result; + } + + public ProtocolResult SendText(string text) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (string.IsNullOrWhiteSpace(text)) return ProtocolResult.InvalidArgument; + return connection.SendIntent(ClientIntents.SendInput(SessionId, text)); + } + + public ProtocolResult Allow(ApprovalRequest approval, JsonElement? updatedInput = null) => + Respond(approval, ClientIntents.ApprovalAllow(approval.Id, updatedInput)); + + public ProtocolResult Deny(ApprovalRequest approval, string? reason = null) => + Respond(approval, ClientIntents.ApprovalDeny(approval.Id, reason)); + + public ProtocolResult AllowAlways(ApprovalRequest approval, ApprovalAlwaysScope scope) => + Respond(approval, ClientIntents.ApprovalAllowAlways(approval.Id, scope)); + + public ProtocolResult CancelRun(ApprovalRequest approval) => + Respond(approval, ClientIntents.ApprovalCancelRun(approval.Id)); + + private ProtocolResult Respond(ApprovalRequest approval, string intent) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (approval.SessionId != SessionId) return ProtocolResult.InvalidArgument; + return connection.SendIntent(intent); + } + + private void StoreChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(RendererStore.Sessions)) Raise(nameof(Session)); + else if (args.PropertyName == nameof(RendererStore.PendingApprovals)) + Raise(nameof(PendingApprovals)); + } + + private void ProjectionChanged(ProjectionDiff diff) + { + if (diff.Root.TryGetProperty("sessionID", out var session) && + session.ValueKind == JsonValueKind.String && session.GetString() == SessionId) + { + LastProjection = diff; + } + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + store.PropertyChanged -= StoreChanged; + store.ProjectionChanged -= ProjectionChanged; + if (active) _ = connection.SendIntent(ClientIntents.Unsubscribe(SessionId)); + active = false; + } +} diff --git a/NucleicApp/ViewModels/ShellViewModel.cs b/NucleicApp/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..ecdd20f --- /dev/null +++ b/NucleicApp/ViewModels/ShellViewModel.cs @@ -0,0 +1,92 @@ +using NucleicApp.Services; +using NucleicProtocol.Interop; + +namespace NucleicApp.ViewModels; + +public enum ShellSection +{ + Home, + Sessions, + Approvals, + Projects, + Settings, +} + +/// Navigation state for the WinUI split shell. The future XAML layer binds to this class and +/// swaps views; session activation remains an ordinary protocol subscription intent. +public sealed class ShellViewModel : ViewModelBase, IDisposable +{ + private readonly RendererStore store; + private readonly HostConnection connection; + private ShellSection section = ShellSection.Home; + private SessionDetailViewModel? selectedSession; + private HostConnectionState connectionState; + private bool disposed; + + public ShellViewModel(RendererStore store, HostConnection connection) + { + this.store = store; + this.connection = connection; + connectionState = connection.State; + store.PropertyChanged += StoreChanged; + connection.StateChanged += ConnectionChanged; + Home = new HomeViewModel(store); + } + + public HomeViewModel Home { get; } + public ShellSection Section { get => section; private set => Set(ref section, value); } + public SessionDetailViewModel? SelectedSession + { + get => selectedSession; + private set => Set(ref selectedSession, value); + } + public HostConnectionState ConnectionState + { + get => connectionState; + private set => Set(ref connectionState, value); + } + public int PendingApprovalCount => store.PendingApprovals.Count; + + public void Navigate(ShellSection destination) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (destination != ShellSection.Sessions) CloseSession(); + Section = destination; + } + + public ProtocolResult OpenSession(string sessionId, ulong? sinceSequence = null) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (SelectedSession?.SessionId != sessionId) + { + SelectedSession?.Dispose(); + SelectedSession = new SessionDetailViewModel(sessionId, store, connection); + } + Section = ShellSection.Sessions; + return SelectedSession.Activate(sinceSequence); + } + + public void CloseSession() + { + SelectedSession?.Dispose(); + SelectedSession = null; + } + + private void StoreChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(RendererStore.PendingApprovals)) + Raise(nameof(PendingApprovalCount)); + } + + private void ConnectionChanged(HostConnectionState state) => ConnectionState = state; + + public void Dispose() + { + if (disposed) return; + disposed = true; + CloseSession(); + Home.Dispose(); + store.PropertyChanged -= StoreChanged; + connection.StateChanged -= ConnectionChanged; + } +} diff --git a/NucleicApp/ViewModels/ViewModelBase.cs b/NucleicApp/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..3d3be1a --- /dev/null +++ b/NucleicApp/ViewModels/ViewModelBase.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace NucleicApp.ViewModels; + +public abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + Raise(name); + return true; + } + + protected void Raise([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/NucleicProtocol.Interop/ClientIntents.cs b/NucleicProtocol.Interop/ClientIntents.cs index adc5e25..971e9e5 100644 --- a/NucleicProtocol.Interop/ClientIntents.cs +++ b/NucleicProtocol.Interop/ClientIntents.cs @@ -27,22 +27,17 @@ public static class ClientIntents writer.WriteString("t", "unsubscribe"); }); - public static string ApprovalRespond( - string approvalId, string decisionType, JsonElement? updatedInput = null) => - Write(writer => - { - writer.WriteString("approvalID", approvalId); - writer.WritePropertyName("decision"); - writer.WriteStartObject(); - writer.WriteString("type", decisionType); - if (updatedInput is { } input) - { - writer.WritePropertyName("updatedInput"); - input.WriteTo(writer); - } - writer.WriteEndObject(); - writer.WriteString("t", "approvalRespond"); - }); + public static string ApprovalAllow(string approvalId, JsonElement? updatedInput = null) => + ApprovalRespond(approvalId, "allow", updatedInput: updatedInput); + + public static string ApprovalDeny(string approvalId, string? reason = null) => + ApprovalRespond(approvalId, "deny", reason: reason); + + public static string ApprovalAllowAlways(string approvalId, ApprovalAlwaysScope scope) => + ApprovalRespond(approvalId, "allowAlways", scope: ScopeValue(scope)); + + public static string ApprovalCancelRun(string approvalId) => + ApprovalRespond(approvalId, "cancelRun"); public static string SendInput(string sessionId, string text) => Write(writer => { @@ -63,6 +58,33 @@ public static class ClientIntents private static string TagOnly(string tag) => JsonSerializer.Serialize( new TagOnlyIntent(tag), ProtocolJsonContext.Default.TagOnlyIntent); + private static string ApprovalRespond( + string approvalId, string decisionType, JsonElement? updatedInput = null, + string? scope = null, string? reason = null) => Write(writer => + { + writer.WriteString("approvalID", approvalId); + writer.WritePropertyName("decision"); + writer.WriteStartObject(); + writer.WriteString("type", decisionType); + if (updatedInput is { } input) + { + writer.WritePropertyName("updatedInput"); + input.WriteTo(writer); + } + if (scope is not null) writer.WriteString("scope", scope); + if (reason is not null) writer.WriteString("reason", reason); + writer.WriteEndObject(); + writer.WriteString("t", "approvalRespond"); + }); + + private static string ScopeValue(ApprovalAlwaysScope scope) => scope switch + { + ApprovalAlwaysScope.Session => "session", + ApprovalAlwaysScope.ToolName => "toolName", + ApprovalAlwaysScope.ToolNameWithPattern => "toolNameWithPattern", + _ => throw new ArgumentOutOfRangeException(nameof(scope)), + }; + private static string Write(Action body) { using var stream = new MemoryStream(); @@ -78,3 +100,10 @@ public static class ClientIntents return System.Text.Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length)); } } + +public enum ApprovalAlwaysScope +{ + Session, + ToolName, + ToolNameWithPattern, +}