Merge nucleic/warm-north-vole-pf37 into dev

This commit is contained in:
2026-08-04 03:10:06 -07:00
parent f5ee895d3f
commit 817eeff081
6 changed files with 473 additions and 16 deletions
+105
View File
@@ -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<DateTimeOffset> now;
public HomeViewModel(RendererStore store, Func<DateTimeOffset>? 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<SessionSummary> LiveSessions => store.Sessions
.Where(session => !Bool(session.Raw, "archived"))
.OrderByDescending(UpdatedAt)
.ToArray();
public IReadOnlyList<SessionSummary> NeedsAttention => LiveSessions
.Where(session => session.PendingApprovalCount > 0 || session.Status == "awaitingApproval" ||
(session.Status == "awaitingInput" && String(session.Raw, "disposition") == "awaitingInput"))
.ToArray();
public IReadOnlyList<SessionSummary> 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);
@@ -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<ApprovalRequest> 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;
}
}
+92
View File
@@ -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;
}
}
+20
View File
@@ -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<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.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));
}