diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 28f7c233e9..1284632238 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -6,6 +6,46 @@ diff. `CLAUDE.md` holds the invariants; `docs/superpowers/specs/` holds the full Not release notes. Each entry is written as of the change that produced it and is not revised as the code moves on; where an entry disagrees with the code, the code wins. +## The work-context pane reads the work item from one endpoint + +**AI-2521** fills the sidebar's SOON slots — the item's state, its overview, per-part completion, +the linked issue and who is on it — from the server's one read per work item, +`GET /api/work-items/{id}`. It joins the assignments, topology and summary calls as a fourth read: +it starts beside the topology read once the primary assignment is known, a final 401 or a +plan-gate 403 on it decides the whole read like the others, and any other failure degrades its +section the way a topology blip does. + +**The key is the endpoint's, not split from the label.** The assignments label packed `"KEY — title"` +by convention; the item read carries the key, the title and the tracker's enriched title as +separate fields, so the split and its silent failure mode are gone. When the item read fails for a +primary the pane has not shown before, the label shows whole as the title with no key chip and the +pane is stale. + +**Parts move to the item read; the topology keeps the rest.** The item's parts carry a settled flag +the topology never had, so the parts list, its "N of M" header and the marks come from there. +Part-of, blockers and the cycle marker still come from the topology, and each section keeps its own +last projection when its read fails. + +**The card's identity is the served id.** An absorbed item is served under its survivor's id, and +the assignments row may catch up to that id a poll later. The pane keys "same primary" on the served +id and falls back to the requested one when a read carried no item, so neither transition drops the +projection. + +**Reference-class links are ignored on purpose.** The server passes `link_class = reference` rows +through for other consumers; the issue card is the first `kind = issue` row of class `link`, and its +URL crosses the same `LinkPolicy` boundary as the PR cards. + +**Contributors render as initials.** The app has no remote image loader, so `avatar_url` is carried +on the view model and not fetched. Collapsed, the section is an initials stack with the session +count; expanded, a row per person with their last activity. Until an item has contributors the +row shows this session's requester, the one person the daemon knows. + +**A mechanical overview is hidden.** `is_overview_mechanical` marks a generated one-liner that +restates the title; only a summarizer overview earns the paragraph under the title. + +The no-repository note no longer says breakdown and blockers come with the repository: the server +dropped its same-repository rule for structure, though a work item itself still requires one. + ## The SessionStart index names the repo's projects An agent can only land a memory at project scope by passing a slug, and nothing in a session told it diff --git a/src/Capacitor.App/ViewModels/RelativeTime.cs b/src/Capacitor.App/ViewModels/RelativeTime.cs new file mode 100644 index 0000000000..bc116e5271 --- /dev/null +++ b/src/Capacitor.App/ViewModels/RelativeTime.cs @@ -0,0 +1,16 @@ +using System.Globalization; + +namespace Capacitor.App.ViewModels; + +public static class RelativeTime { + /// "just now", then minutes, hours and days for a week, then the date. Never negative: a stamp + /// ahead of the clock reads as now. + public static string Format(DateTimeOffset at, DateTimeOffset now) { + var age = now - at; + if (age < TimeSpan.FromMinutes(1)) return "just now"; + if (age < TimeSpan.FromHours(1)) return $"{(int)age.TotalMinutes}m ago"; + if (age < TimeSpan.FromDays(1)) return $"{(int)age.TotalHours}h ago"; + if (age < TimeSpan.FromDays(7)) return $"{(int)age.TotalDays}d ago"; + return at.ToString(at.Year == now.Year ? "MMM d" : "MMM d, yyyy", CultureInfo.InvariantCulture); + } +} diff --git a/src/Capacitor.App/ViewModels/WorkContextItems.cs b/src/Capacitor.App/ViewModels/WorkContextItems.cs index 9d0971993f..10f9e1ddbc 100644 --- a/src/Capacitor.App/ViewModels/WorkContextItems.cs +++ b/src/Capacitor.App/ViewModels/WorkContextItems.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reactive; using System.Reactive.Linq; using Capacitor.App.Services; @@ -5,18 +6,19 @@ namespace Capacitor.App.ViewModels; -public enum WorkContextPartMark { ThisSession, Unknown } +public enum WorkContextPartMark { Settled, ThisSession, Unknown } -/// One declared part. The server exposes no completion state over HTTP, so a part is either the -/// one this session is attached to or unknown. +/// One declared part: settled by the server's milestone, else the one this session is attached +/// to, else unknown. public sealed class WorkContextPartViewModel(string title, WorkContextPartMark mark) { public string Title { get; } = title; public WorkContextPartMark Mark { get; } = mark; + public bool IsSettled => Mark == WorkContextPartMark.Settled; public bool IsThisSession => Mark == WorkContextPartMark.ThisSession; } -/// A pull-request card. The URL is server-returned, so it crosses the same trust boundary the chat -/// tab applies before a link reaches the shell opener. +/// A pull-request or issue card. The URL is server-returned, so it crosses the same trust boundary +/// the chat tab applies before a link reaches the shell opener. public sealed class WorkContextLinkViewModel { public string Eyebrow { get; } public string Key { get; } @@ -34,3 +36,22 @@ public WorkContextLinkViewModel(string eyebrow, string key, string title, string OpenCommand = ReactiveCommand.Create(() => LinkPolicy.Open(opener, url), Observable.Return(CanOpen)); } } + +/// One person with a session attached to the work item. is carried but not +/// loaded: the pane draws the initial. +public sealed class WorkContextPersonViewModel { + public string Name { get; } + public string Initial { get; } + public string? AvatarUrl { get; } + public string LastActivityText { get; } + + public WorkContextPersonViewModel(string name, string? avatarUrl, DateTimeOffset? lastActivityAt, DateTimeOffset now) { + Name = name; + Initial = InitialOf(name); + AvatarUrl = avatarUrl; + LastActivityText = lastActivityAt is { } at ? RelativeTime.Format(at, now) : ""; + } + + /// The first text element, so a surrogate pair or a combining sequence stays whole. + public static string InitialOf(string name) => new StringInfo(name).SubstringByTextElements(0, 1).ToUpperInvariant(); +} diff --git a/src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs b/src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs index e91115de1d..ec5c623661 100644 --- a/src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs +++ b/src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Reactive; using Avalonia.Collections; using Capacitor.Cli.Core.LocalIpc; @@ -14,24 +13,69 @@ public sealed partial class WorkContextViewModel { readonly AvaloniaList _parts = new(); readonly AvaloniaList _blockedBy = new(); readonly AvaloniaList _links = new(); + readonly AvaloniaList _contributors = new(); + // The card's identity is the id the server served, which for an absorbed item is the survivor's + // rather than the assignment's; the requested id stands in when a read carried no item. string? _primaryId; + string? _requestedId; public IAvaloniaReadOnlyList Parts => _parts; public IAvaloniaReadOnlyList BlockedBy => _blockedBy; public IAvaloniaReadOnlyList Links => _links; + public IAvaloniaReadOnlyList Contributors => _contributors; string? _key; public string? Key { get => _key; private set => this.RaiseAndSetIfChanged(ref _key, value); } string _title = ""; public string Title { get => _title; private set => this.RaiseAndSetIfChanged(ref _title, value); } + string? _overview; + public string? Overview { get => _overview; private set => this.RaiseAndSetIfChanged(ref _overview, value); } + string? _stateLabel; + public string? StateLabel { get => _stateLabel; private set => this.RaiseAndSetIfChanged(ref _stateLabel, value); } + bool _isShipped; + public bool IsShipped { get => _isShipped; private set => this.RaiseAndSetIfChanged(ref _isShipped, value); } + bool _isClosed; + public bool IsClosed { get => _isClosed; private set => this.RaiseAndSetIfChanged(ref _isClosed, value); } string? _partOfTitle; public string? PartOfTitle { get => _partOfTitle; private set => this.RaiseAndSetIfChanged(ref _partOfTitle, value); } string? _cycleNote; public string? CycleNote { get => _cycleNote; private set => this.RaiseAndSetIfChanged(ref _cycleNote, value); } - public string PartsHeader => _parts.Count == 1 ? "1 part" : $"{_parts.Count} parts"; + WorkContextLinkViewModel? _issue; + public WorkContextLinkViewModel? Issue { + get => _issue; + private set { + if (ReferenceEquals(_issue, value)) return; + this.RaiseAndSetIfChanged(ref _issue, value); + this.RaisePropertyChanged(nameof(HasIssue)); + } + } + + int _sessionCount; + int SessionCount { + get => _sessionCount; + set { + if (_sessionCount == value) return; + _sessionCount = value; + this.RaisePropertyChanged(nameof(SessionCountText)); + } + } + + public string PartsHeader => _parts.Count switch { + 0 => "0 parts", + 1 => $"{SettledCount} of 1 part", + var n => $"{SettledCount} of {n} parts", + }; + int SettledCount => _parts.Count(p => p.IsSettled); public bool HasParts => _parts.Count > 0; public bool HasBlockers => _blockedBy.Count > 0; + public bool HasIssue => Issue is not null; + public bool HasContributors => _contributors.Count > 0; + public string SessionCountText => _sessionCount switch { + 0 => "", + 1 => "1 session", + var n => $"{n} sessions", + }; string _requester = "You"; public string Requester { get => _requester; private set => this.RaiseAndSetIfChanged(ref _requester, value); } @@ -66,7 +110,7 @@ ReactiveCommand Toggle(Action flip) { void UpdateRequester(AgentStatusDto dto, string vendorLabel) { Requester = FirstNonBlank(dto.RequesterDisplay, dto.Requester) ?? "You"; RequesterRole = $"This session · {vendorLabel}"; - RequesterInitial = new StringInfo(Requester).SubstringByTextElements(0, 1).ToUpperInvariant(); + RequesterInitial = WorkContextPersonViewModel.InitialOf(Requester); } static string? FirstNonBlank(params string?[] values) { @@ -82,15 +126,26 @@ void ClearServerProjections() { void ClearCard() { _primaryId = null; + _requestedId = null; + ClearItem(); + ClearTopology(); + } + + void ClearItem() { Key = null; Title = ""; - ClearTopology(); + Overview = null; + ApplyState(null); + _parts.Clear(); + Issue = null; + _contributors.Clear(); + SessionCount = 0; + RaiseCardCounts(); } void ClearTopology() { PartOfTitle = null; CycleNote = null; - _parts.Clear(); _blockedBy.Clear(); RaiseCardCounts(); } @@ -99,6 +154,7 @@ void RaiseCardCounts() { this.RaisePropertyChanged(nameof(PartsHeader)); this.RaisePropertyChanged(nameof(HasParts)); this.RaisePropertyChanged(nameof(HasBlockers)); + this.RaisePropertyChanged(nameof(HasContributors)); } void ApplyReady(WorkContextRead read) { @@ -110,28 +166,80 @@ void ApplyReady(WorkContextRead read) { return; } - var samePrimary = string.Equals(read.Primary.WorkItemId, _primaryId, StringComparison.Ordinal); - _primaryId = read.Primary.WorkItemId; - var (key, display) = WorkContextLabel.Split(read.Primary.Label); - Key = key; - Title = read.Topology?.Item?.Title is { Length: > 0 } itemTitle ? itemTitle : display; + var requested = read.Primary.WorkItemId; + var served = read.Item?.WorkItemId; + var samePrimary = served is not null + ? Same(served, _primaryId) + : Same(requested, _requestedId) || Same(requested, _primaryId); + _requestedId = requested; + if (served is not null) _primaryId = served; + else if (!samePrimary) _primaryId = requested; + + if (read.Item is { } item) ApplyItem(item, read.Assignments); + else if (!samePrimary) { + ClearItem(); + Title = read.Primary.Label; + } - if (!read.TopologyFailed && read.Topology is { } topology) ApplyTopology(topology, read.Assignments); + if (!read.TopologyFailed && read.Topology is { } topology) ApplyTopology(topology); else if (!samePrimary) ClearTopology(); ApplyLinks(read); Phase = WorkContextPhase.Ready; - IsStale = read.TopologyFailed || read.SummaryFailed; + IsStale = read.ItemFailed || read.TopologyFailed || read.SummaryFailed; } - void ApplyTopology(WorkItemTopologyDto topology, IReadOnlyList assignments) { + static bool Same(string a, string? b) => string.Equals(a, b, StringComparison.Ordinal); + + void ApplyItem(WorkItemDto item, IReadOnlyList assignments) { + Key = FirstNonBlank(item.Key?.ShortKey); + Title = FirstNonBlank(item.EnrichedTitle, item.Title) ?? ""; + Overview = item.IsOverviewMechanical ? null : FirstNonBlank(item.Overview); + ApplyState(item.State?.Kind); + var attached = new HashSet(assignments.Select(a => a.WorkItemId), StringComparer.Ordinal); - PartOfTitle = topology.PartOf?.Title; - var parts = topology.Parts + var parts = item.Parts .OrderBy(p => p.Ordinal) - .Select(p => new WorkContextPartViewModel(p.Title, attached.Contains(p.WorkItemId) ? WorkContextPartMark.ThisSession : WorkContextPartMark.Unknown)) + .Select(p => new WorkContextPartViewModel(p.Title, PartMark(p, attached))) .ToList(); Replace(_parts, parts, p => (p.Title, p.Mark)); + + ApplyIssue(item.Links.FirstOrDefault(l => l.Kind == "issue" && l.LinkClass == "link")); + + var now = _time.GetUtcNow(); + var people = item.Contributors + .Select(c => new WorkContextPersonViewModel(FirstNonBlank(c.DisplayName, c.UserId) ?? "Someone", c.AvatarUrl, c.LastActivityAt, now)) + .ToList(); + Replace(_contributors, people, c => (c.Name, c.AvatarUrl, c.LastActivityText)); + SessionCount = item.SessionCount; + RaiseCardCounts(); + } + + static WorkContextPartMark PartMark(WorkItemPartDto part, HashSet attached) => + part.IsSettled ? WorkContextPartMark.Settled + : attached.Contains(part.WorkItemId) ? WorkContextPartMark.ThisSession + : WorkContextPartMark.Unknown; + + /// The server may add kinds; an unknown one is shown as sent, in the in-flight look. + void ApplyState(string? kind) { + var text = kind?.Replace('_', ' ').Trim().ToUpperInvariant(); + StateLabel = string.IsNullOrEmpty(text) ? null : text; + IsShipped = kind == "shipped"; + IsClosed = kind == "closed"; + } + + void ApplyIssue(WorkItemLinkDto? link) { + if (link is null) { + Issue = null; + return; + } + var title = FirstNonBlank(link.Title) ?? $"Issue {link.ShortKey}"; + if (Issue is { } current && current.Key == link.ShortKey && current.Title == title && current.Url == link.Url) return; + Issue = new WorkContextLinkViewModel("ISSUE", link.ShortKey, title, link.Url, _opener); + } + + void ApplyTopology(WorkItemTopologyDto topology) { + PartOfTitle = topology.PartOf?.Title; Replace(_blockedBy, topology.BlockedBy.Select(b => b.Title).ToList(), b => b); CycleNote = topology.Cycle switch { "cyclic" => "Dependencies form a cycle", diff --git a/src/Capacitor.App/ViewModels/WorkContextViewModel.cs b/src/Capacitor.App/ViewModels/WorkContextViewModel.cs index 9ab18396de..f7ec87dd82 100644 --- a/src/Capacitor.App/ViewModels/WorkContextViewModel.cs +++ b/src/Capacitor.App/ViewModels/WorkContextViewModel.cs @@ -27,7 +27,7 @@ public sealed partial class WorkContextViewModel : ReactiveObject { internal const string WaitingNote = "Waiting for the session to register…"; internal const string LoadingNote = "Loading work context…"; internal const string NoWorkItemNote = "No work item attached yet. The agent's declare tool attaches one."; - internal const string NoRepositoryNote = "This session has no repository. A work item cannot attach until the work lands in one — breakdown and blockers come with it."; + internal const string NoRepositoryNote = "This session has no repository. A work item cannot attach until the work lands in one."; internal const string SignedOutNote = "Sign in to see the work context."; internal const string NotInPlanNote = "Work items are not in this workspace's plan."; internal const string UnreachableNote = "Couldn't reach the server."; @@ -42,6 +42,7 @@ sealed class ReadLease(string sessionId) { readonly IWorkContextSource _source; readonly IUrlOpener _opener; + readonly TimeProvider _time; readonly CompositeDisposable _disposables = new(); readonly List _outstanding = []; ReadLease? _current; @@ -135,6 +136,7 @@ public WorkContextViewModel( Action? requestSignIn = null, IObservable? signInCompleted = null) { _source = source; _opener = opener; + _time = time; InitializeProjections(); _disposables.Add(_hasSessionChanges); diff --git a/src/Capacitor.App/Views/WorkContextView.axaml b/src/Capacitor.App/Views/WorkContextView.axaml index 76c39dec6a..1f612f48ff 100644 --- a/src/Capacitor.App/Views/WorkContextView.axaml +++ b/src/Capacitor.App/Views/WorkContextView.axaml @@ -19,19 +19,47 @@ - - + + + + + + + + + + + + @@ -117,7 +160,11 @@ - + + + @@ -128,36 +175,38 @@ IsVisible="{Binding Key, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> + - + - - + + + + + Data="M3,6 L5.2,8.2 L9,4" IsVisible="{Binding IsSettled}" /> + Foreground="{StaticResource KcapTextBrush}" Classes.settled="{Binding IsSettled}" /> @@ -197,46 +246,59 @@ - - - - - - - - - - - - - - + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index 64fc1f79a3..29018d4a40 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -986,6 +986,7 @@ public sealed record CurationApplyResponse { [JsonSerializable(typeof(CliProjectError))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(WorkItems.WorkItemTopologyDto))] +[JsonSerializable(typeof(WorkItems.WorkItemDto))] [JsonSerializable(typeof(WorkItems.SessionSummaryDto))] [JsonSerializable(typeof(WorkItems.WorkItemErrorDto))] [JsonSerializable(typeof(RepositoryPayload))] diff --git a/src/Capacitor.Cli.Core/WorkItems/IWorkContextChannel.cs b/src/Capacitor.Cli.Core/WorkItems/IWorkContextChannel.cs index 1e3c365aff..60a1c59e8b 100644 --- a/src/Capacitor.Cli.Core/WorkItems/IWorkContextChannel.cs +++ b/src/Capacitor.Cli.Core/WorkItems/IWorkContextChannel.cs @@ -7,9 +7,9 @@ public sealed record WorkContextOutcome(int StatusCode, T? Body, WorkItemErro public bool Succeeded => StatusCode is >= 200 and < 300 && Body is not null; } -/// The three routes as a seam, so the reader is testable without a socket. public interface IWorkContextChannel { Task>> GetSessionAssignmentsAsync(string sessionId, CancellationToken ct); + Task> GetWorkItemAsync(string workItemId, CancellationToken ct); Task> GetTopologyAsync(string workItemId, CancellationToken ct); Task> GetSessionSummaryAsync(string sessionId, CancellationToken ct); } diff --git a/src/Capacitor.Cli.Core/WorkItems/WorkContextClient.cs b/src/Capacitor.Cli.Core/WorkItems/WorkContextClient.cs index 094097a6b4..cbd6d1beff 100644 --- a/src/Capacitor.Cli.Core/WorkItems/WorkContextClient.cs +++ b/src/Capacitor.Cli.Core/WorkItems/WorkContextClient.cs @@ -15,6 +15,11 @@ public Task>> GetSessionAs ? GetAsync($"{_base}/api/work-items/session/{Uri.EscapeDataString(id)}", CapacitorJsonContext.Default.ListSessionWorkItemAssignmentDto, ct) : Task.FromResult(Refused>()); + public Task> GetWorkItemAsync(string workItemId, CancellationToken ct) => + WorkContextIds.ValidWorkItemId(workItemId) is { } id + ? GetAsync($"{_base}/api/work-items/{Uri.EscapeDataString(id)}", CapacitorJsonContext.Default.WorkItemDto, ct) + : Task.FromResult(Refused()); + public Task> GetTopologyAsync(string workItemId, CancellationToken ct) => WorkContextIds.ValidWorkItemId(workItemId) is { } id ? GetAsync($"{_base}/api/work-items/{Uri.EscapeDataString(id)}/topology", CapacitorJsonContext.Default.WorkItemTopologyDto, ct) diff --git a/src/Capacitor.Cli.Core/WorkItems/WorkContextLabel.cs b/src/Capacitor.Cli.Core/WorkItems/WorkContextLabel.cs deleted file mode 100644 index 669e985acb..0000000000 --- a/src/Capacitor.Cli.Core/WorkItems/WorkContextLabel.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Capacitor.Cli.Core.WorkItems; - -/// The server composes a keyed item's label as "KEY — title". A label without that separator, or -/// with an empty half, is display text alone: never a guessed key. -public static class WorkContextLabel { - const string Separator = " — "; - - public static (string? Key, string Display) Split(string label) { - var at = label.IndexOf(Separator, StringComparison.Ordinal); - if (at < 0) return (null, label.Trim()); - - var key = label[..at].Trim(); - var display = label[(at + Separator.Length)..].Trim(); - - return key.Length == 0 || display.Length == 0 ? (null, label.Trim()) : (key, display); - } -} diff --git a/src/Capacitor.Cli.Core/WorkItems/WorkContextReader.cs b/src/Capacitor.Cli.Core/WorkItems/WorkContextReader.cs index 763e2f1023..fe8f9bea86 100644 --- a/src/Capacitor.Cli.Core/WorkItems/WorkContextReader.cs +++ b/src/Capacitor.Cli.Core/WorkItems/WorkContextReader.cs @@ -3,40 +3,45 @@ namespace Capacitor.Cli.Core.WorkItems; public enum WorkContextReadKind { Ready, SessionUnknown, SignedOut, NotInPlan, Unreachable } /// One read of a session's work context, totalized. with a -/// null is the no-work-item state; and -/// degrade a section without failing the read. +/// null is the no-work-item state; , +/// and degrade a section without failing +/// the read. is served for the primary and may carry a different id than it. public sealed record WorkContextRead( WorkContextReadKind Kind, IReadOnlyList Assignments, SessionWorkItemAssignmentDto? Primary, + WorkItemDto? Item, WorkItemTopologyDto? Topology, SessionSummaryDto? Summary, + bool ItemFailed, bool TopologyFailed, bool SummaryFailed, string? Detail) { public static WorkContextRead Of(WorkContextReadKind kind, string? detail = null) => - new(kind, [], null, null, null, false, false, detail); + new(kind, [], null, null, null, null, false, false, false, detail); } public static class WorkContextReader { public const string PlanGateError = "work_items_not_in_plan"; - /// Assignments and summary run concurrently; the topology follows the assignments alone, so it - /// overlaps the summary rather than queueing behind it. Every call started is awaited before - /// anything is classified. A final 401 anywhere signs the read out: the retry handler has - /// already spent the refresh, and only that outcome makes the source drop its client. + /// Assignments and summary run concurrently; the item and topology reads follow the assignments + /// together, so both overlap the summary rather than queueing behind it. Every call started is + /// awaited before anything is classified. A final 401 anywhere signs the read out: the retry + /// handler has already spent the refresh, and only that outcome makes the source drop its client. public static async Task ReadAsync(IWorkContextChannel channel, string sessionId, CancellationToken ct) { var summaryTask = channel.GetSessionSummaryAsync(sessionId, ct); var assignments = await channel.GetSessionAssignmentsAsync(sessionId, ct).ConfigureAwait(false); var rows = assignments.Succeeded ? assignments.Body! : []; var primary = rows.FirstOrDefault(r => r.IsPrimary) ?? rows.FirstOrDefault(); + var itemTask = primary is null ? null : channel.GetWorkItemAsync(primary.WorkItemId, ct); var topologyTask = primary is null ? null : channel.GetTopologyAsync(primary.WorkItemId, ct); - var summary = await summaryTask.ConfigureAwait(false); + var summary = await summaryTask.ConfigureAwait(false); + var item = itemTask is null ? null : await itemTask.ConfigureAwait(false); var topology = topologyTask is null ? null : await topologyTask.ConfigureAwait(false); - if (assignments.StatusCode == 401 || summary.StatusCode == 401 || topology?.StatusCode == 401) + if (assignments.StatusCode == 401 || summary.StatusCode == 401 || item?.StatusCode == 401 || topology?.StatusCode == 401) return WorkContextRead.Of(WorkContextReadKind.SignedOut); switch (assignments) { @@ -47,18 +52,21 @@ public static async Task ReadAsync(IWorkContextChannel channel, return PlanGated(assignments) ?? WorkContextRead.Of(WorkContextReadKind.Unreachable, StatusDetail(assignments.StatusCode)); } + if (item is not null && PlanGated(item) is { } gatedItem) return gatedItem; if (topology is not null && PlanGated(topology) is { } gated) return gated; return new WorkContextRead( WorkContextReadKind.Ready, rows, primary, + item is { Succeeded: true } ? item.Body : null, topology is { Succeeded: true } ? topology.Body : null, summary.Succeeded ? summary.Body : null, + ItemFailed: item is { Succeeded: false }, TopologyFailed: topology is { Succeeded: false }, SummaryFailed: !summary.Succeeded, Detail: null); } - /// Both work-item routes share the plan gate, and a plan change between the two calls must not + /// Every work-item route shares the plan gate, and a plan change between the calls must not /// leave the pane ready on retained data. static WorkContextRead? PlanGated(WorkContextOutcome outcome) where T : class => outcome is { StatusCode: 403, Error: { Error: PlanGateError } gate } diff --git a/src/Capacitor.Cli.Core/WorkItems/WorkItemDtos.cs b/src/Capacitor.Cli.Core/WorkItems/WorkItemDtos.cs new file mode 100644 index 0000000000..39f23180cf --- /dev/null +++ b/src/Capacitor.Cli.Core/WorkItems/WorkItemDtos.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace Capacitor.Cli.Core.WorkItems; + +/// The body of GET /api/work-items/{id}. is the served item, which +/// differs from the requested id when an absorbed item redirected to its survivor. +/// is the tracker's title when is a key, else null. +/// counts only attached sessions the caller can see. +public sealed record WorkItemDto { + List _links = []; + List _parts = []; + List _contributors = []; + + [JsonPropertyName("work_item_id")] public required string WorkItemId { get; init; } + [JsonPropertyName("title")] public required string Title { get; init; } + [JsonPropertyName("enriched_title")] public string? EnrichedTitle { get; init; } + [JsonPropertyName("overview")] public string? Overview { get; init; } + [JsonPropertyName("is_overview_mechanical")] public bool IsOverviewMechanical { get; init; } + [JsonPropertyName("key")] public WorkItemKeyDto? Key { get; init; } + [JsonPropertyName("state")] public WorkItemStateDto? State { get; init; } + [JsonPropertyName("links")] public List Links { get => _links; init => _links = value ?? []; } + [JsonPropertyName("parts")] public List Parts { get => _parts; init => _parts = value ?? []; } + [JsonPropertyName("contributors")] public List Contributors { get => _contributors; init => _contributors = value ?? []; } + [JsonPropertyName("session_count")] public int SessionCount { get; init; } +} + +/// The item's seed key. is the link's normalized value, so it matches one row of +/// . +public sealed record WorkItemKeyDto { + [JsonPropertyName("short_key")] public required string ShortKey { get; init; } + [JsonPropertyName("provider")] public required string Provider { get; init; } + [JsonPropertyName("kind")] public required string Kind { get; init; } + [JsonPropertyName("value")] public required string Value { get; init; } +} + +/// is in_flight|shipped|closed; the server may add kinds, so an unknown one is +/// tolerated. is null while in flight. +public sealed record WorkItemStateDto { + [JsonPropertyName("kind")] public required string Kind { get; init; } + [JsonPropertyName("settled_at")] public DateTimeOffset? SettledAt { get; init; } +} + +/// One active link. is link for identity-bearing evidence or +/// reference for an ambient mention the server passes through for other consumers. +public sealed record WorkItemLinkDto { + [JsonPropertyName("kind")] public required string Kind { get; init; } + [JsonPropertyName("provider")] public required string Provider { get; init; } + [JsonPropertyName("value")] public required string Value { get; init; } + [JsonPropertyName("short_key")] public required string ShortKey { get; init; } + [JsonPropertyName("url")] public string? Url { get; init; } + [JsonPropertyName("title")] public string? Title { get; init; } + [JsonPropertyName("state")] public string? State { get; init; } + [JsonPropertyName("link_class")] public required string LinkClass { get; init; } + [JsonPropertyName("is_seed")] public bool IsSeed { get; init; } +} + +public sealed record WorkItemPartDto { + [JsonPropertyName("work_item_id")] public required string WorkItemId { get; init; } + [JsonPropertyName("title")] public required string Title { get; init; } + [JsonPropertyName("ordinal")] public int Ordinal { get; init; } + [JsonPropertyName("is_settled")] public bool IsSettled { get; init; } + [JsonPropertyName("settled_at")] public DateTimeOffset? SettledAt { get; init; } +} + +/// and are null for an owner with no user row. +public sealed record WorkItemContributorDto { + [JsonPropertyName("user_id")] public required string UserId { get; init; } + [JsonPropertyName("display_name")] public string? DisplayName { get; init; } + [JsonPropertyName("avatar_url")] public string? AvatarUrl { get; init; } + [JsonPropertyName("last_activity_at")] public DateTimeOffset? LastActivityAt { get; init; } +} diff --git a/test/Capacitor.App.Tests.Unit/RelativeTimeTests.cs b/test/Capacitor.App.Tests.Unit/RelativeTimeTests.cs new file mode 100644 index 0000000000..1124506f99 --- /dev/null +++ b/test/Capacitor.App.Tests.Unit/RelativeTimeTests.cs @@ -0,0 +1,31 @@ +using Capacitor.App.ViewModels; + +namespace Capacitor.App.Tests.Unit; + +public class RelativeTimeTests { + static readonly DateTimeOffset Now = new(2026, 9, 5, 12, 0, 0, TimeSpan.Zero); + + [Test] + [Arguments(0, "just now")] + [Arguments(59, "just now")] + [Arguments(60, "1m ago")] + [Arguments(59 * 60, "59m ago")] + [Arguments(60 * 60, "1h ago")] + [Arguments(23 * 60 * 60, "23h ago")] + [Arguments(24 * 60 * 60, "1d ago")] + [Arguments(6 * 24 * 60 * 60, "6d ago")] + public async Task Within_a_week_the_text_is_relative(int secondsAgo, string expected) { + await Assert.That(RelativeTime.Format(Now.AddSeconds(-secondsAgo), Now)).IsEqualTo(expected); + } + + [Test] + public async Task A_week_or_more_shows_the_date() { + await Assert.That(RelativeTime.Format(Now.AddDays(-7), Now)).IsEqualTo("Aug 29"); + await Assert.That(RelativeTime.Format(new DateTimeOffset(2025, 12, 24, 0, 0, 0, TimeSpan.Zero), Now)).IsEqualTo("Dec 24, 2025"); + } + + [Test] + public async Task A_future_stamp_reads_as_just_now() { + await Assert.That(RelativeTime.Format(Now.AddMinutes(5), Now)).IsEqualTo("just now"); + } +} diff --git a/test/Capacitor.App.Tests.Unit/WorkContextViewModelTests.cs b/test/Capacitor.App.Tests.Unit/WorkContextViewModelTests.cs index 3785ddaed4..ca851b4228 100644 --- a/test/Capacitor.App.Tests.Unit/WorkContextViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/WorkContextViewModelTests.cs @@ -391,20 +391,35 @@ await RunOnUiAsync(async () => { static SessionWorkItemAssignmentDto Row(string id, string label, bool primary = true) => new() { WorkItemId = id, Label = label, Source = "mcp", Confidence = 1, IsPrimary = primary }; - static WorkItemTopologyPartDto Part(string id, string title, int ordinal) => - new() { WorkItemId = id, Title = title, Ordinal = ordinal }; + static WorkItemPartDto Part(string id, string title, int ordinal, bool settled = false) => + new() { WorkItemId = id, Title = title, Ordinal = ordinal, IsSettled = settled }; + + static WorkItemLinkDto Link(string kind, string shortKey, string? url = null, string? title = null, string linkClass = "link") => + new() { Kind = kind, Provider = "github", Value = shortKey, ShortKey = shortKey, Url = url, Title = title, LinkClass = linkClass }; + + static WorkItemContributorDto Person(string userId, string? name, DateTimeOffset? at = null, string? avatar = null) => + new() { UserId = userId, DisplayName = name, LastActivityAt = at, AvatarUrl = avatar }; + + static WorkItemDto Item( + string id = "w1", string title = "WK-2198", string? enriched = "Desktop shell: work-context sidebar", string? key = "WK-2198", + string? state = "in_flight", params WorkItemPartDto[] parts) => + new() { + WorkItemId = id, Title = title, EnrichedTitle = enriched, + Key = key is null ? null : new WorkItemKeyDto { ShortKey = key, Provider = "linear", Kind = "issue", Value = key }, + State = state is null ? null : new WorkItemStateDto { Kind = state }, + Parts = [.. parts], + }; static SessionPullRequestDto Pr(string owner, string repo, int number, string? url = null, string? title = null) => new() { RepoHash = "h", Owner = owner, RepoName = repo, Number = number, Url = url, Title = title }; static WorkContextRead ReadyWith( - SessionWorkItemAssignmentDto? primary, WorkItemTopologyDto? topology = null, SessionSummaryDto? summary = null, - bool topologyFailed = false, bool summaryFailed = false, IReadOnlyList? assignments = null) => - new(WorkContextReadKind.Ready, assignments ?? (primary is null ? [] : [primary]), primary, topology, - summary ?? (summaryFailed ? null : new SessionSummaryDto { SessionId = SessionA }), topologyFailed, summaryFailed, null); + SessionWorkItemAssignmentDto? primary, WorkItemDto? item = null, WorkItemTopologyDto? topology = null, SessionSummaryDto? summary = null, + bool itemFailed = false, bool topologyFailed = false, bool summaryFailed = false, IReadOnlyList? assignments = null) => + new(WorkContextReadKind.Ready, assignments ?? (primary is null ? [] : [primary]), primary, item, topology, + summary ?? (summaryFailed ? null : new SessionSummaryDto { SessionId = SessionA }), itemFailed, topologyFailed, summaryFailed, null); - static WorkItemTopologyDto Topology(params WorkItemTopologyPartDto[] parts) => new() { - Parts = [.. parts], + static WorkItemTopologyDto Topology() => new() { Item = new WorkItemRefDto { WorkItemId = "w1", Title = "Desktop shell: work-context sidebar" }, }; @@ -413,7 +428,7 @@ static WorkContextRead ReadyWith( public async Task A_throwing_apply_still_stops_reading_and_lets_teardown_finish() { await RunOnUiAsync(async () => { var h = new Harness(); - h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), Topology(Part("p1", "First", 0)), assignments: [null!])); + h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), Item(parts: Part("p1", "First", 0)), assignments: [null!])); await h.PushAsync(Dto()); @@ -428,12 +443,13 @@ await RunOnUiAsync(async () => { public async Task The_card_shows_key_title_parts_marks_part_of_blockers_and_the_cycle_note() { await RunOnUiAsync(async () => { var h = new Harness(); - var topology = Topology(Part("p2", "Second", 1), Part("p1", "First", 0)) with { + var topology = Topology() with { PartOf = new WorkItemRefDto { WorkItemId = "w0", Title = "Parent epic" }, BlockedBy = [new WorkItemRefDto { WorkItemId = "b1", Title = "Pin the helper" }], Cycle = "indeterminate", }; - h.Source.Enqueue(ReadyWith(Row("w1", "WK-2198 — old label"), topology, + var item = Item(parts: [Part("p3", "Third", 2, settled: true), Part("p2", "Second", 1), Part("p1", "First", 0)]); + h.Source.Enqueue(ReadyWith(Row("w1", "WK-2198 — old label"), item, topology, assignments: [Row("w1", "WK-2198 — old label"), Row("p1", "part", primary: false)])); await h.PushAsync(Dto()); @@ -442,10 +458,12 @@ await RunOnUiAsync(async () => { await Assert.That(h.Vm.Key).IsEqualTo("WK-2198"); await Assert.That(h.Vm.Title).IsEqualTo("Desktop shell: work-context sidebar"); await Assert.That(h.Vm.PartOfTitle).IsEqualTo("Parent epic"); - await Assert.That(h.Vm.Parts.Select(p => p.Title)).IsEquivalentTo(new[] { "First", "Second" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(h.Vm.Parts.Select(p => p.Title)).IsEquivalentTo(new[] { "First", "Second", "Third" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); await Assert.That(h.Vm.Parts[0].Mark).IsEqualTo(WorkContextPartMark.ThisSession); await Assert.That(h.Vm.Parts[1].Mark).IsEqualTo(WorkContextPartMark.Unknown); - await Assert.That(h.Vm.PartsHeader).IsEqualTo("2 parts"); + await Assert.That(h.Vm.Parts[2].Mark).IsEqualTo(WorkContextPartMark.Settled); + await Assert.That(h.Vm.Parts[2].IsSettled).IsTrue(); + await Assert.That(h.Vm.PartsHeader).IsEqualTo("1 of 3 parts"); await Assert.That(h.Vm.BlockedBy[0]).IsEqualTo("Pin the helper"); await Assert.That(h.Vm.HasBlockers).IsTrue(); await Assert.That(h.Vm.CycleNote).IsEqualTo("Dependencies could not be fully resolved"); @@ -455,10 +473,27 @@ await RunOnUiAsync(async () => { [Test] [NotInParallel("AvaloniaSession")] - public async Task The_label_display_is_the_title_when_the_topology_has_no_item_and_a_cycle_note_needs_no_blockers() { + public async Task A_settled_part_this_session_is_attached_to_counts_as_settled_and_one_part_reads_singular() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue(ReadyWith(Row("w1", "t"), Item(parts: Part("p1", "Only", 0, settled: true)), + assignments: [Row("w1", "t"), Row("p1", "part", primary: false)])); + + await h.PushAsync(Dto()); + + await Assert.That(h.Vm.Parts[0].Mark).IsEqualTo(WorkContextPartMark.Settled); + await Assert.That(h.Vm.PartsHeader).IsEqualTo("1 of 1 part"); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task An_item_without_a_key_shows_its_title_alone_and_a_cycle_note_needs_no_blockers() { await RunOnUiAsync(async () => { var h = new Harness(); - h.Source.Enqueue(ReadyWith(Row("w1", "Daemon tests flake"), new WorkItemTopologyDto { Cycle = "cyclic" })); + h.Source.Enqueue(ReadyWith(Row("w1", "Daemon tests flake"), Item(title: "Daemon tests flake", enriched: null, key: null), + new WorkItemTopologyDto { Cycle = "cyclic" })); await h.PushAsync(Dto()); @@ -474,31 +509,240 @@ await RunOnUiAsync(async () => { [Test] [NotInParallel("AvaloniaSession")] - public async Task A_topology_blip_keeps_parts_for_the_same_primary_and_clears_them_for_a_new_one() { + public async Task Without_an_item_a_new_primary_shows_the_assignment_label_whole_with_no_key() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue(ReadyWith(Row("w1", "WK-2198 — Desktop shell"), itemFailed: true, topology: Topology())); + + await h.PushAsync(Dto()); + + await Assert.That(h.Vm.Phase).IsEqualTo(WorkContextPhase.Ready); + await Assert.That(h.Vm.Key).IsNull(); + await Assert.That(h.Vm.Title).IsEqualTo("WK-2198 — Desktop shell"); + await Assert.That(h.Vm.StateLabel).IsNull(); + await Assert.That(h.Vm.IsStale).IsTrue(); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task An_item_blip_keeps_the_card_for_the_same_primary_and_clears_it_for_a_new_one() { await RunOnUiAsync(async () => { var h = new Harness(); h.Source.Enqueue( - ReadyWith(Row("w1", "WK-1 — t"), Topology(Part("p1", "First", 0))), - ReadyWith(Row("w1", "WK-1 — t"), topology: null, topologyFailed: true), - ReadyWith(Row("w9", "WK-9 — other"), topology: null, topologyFailed: true), - ReadyWith(Row("w9", "WK-9 — other"), new WorkItemTopologyDto())); + ReadyWith(Row("w1", "WK-1 — t"), Item(parts: Part("p1", "First", 0))), + ReadyWith(Row("w1", "WK-1 — t"), itemFailed: true), + ReadyWith(Row("w9", "WK-9 — other"), itemFailed: true), + ReadyWith(Row("w9", "WK-9 — other"), Item(id: "w9", key: "WK-9", enriched: "other"))); await h.PushAsync(Dto()); await h.TickAsync(); + await Assert.That(h.Vm.Key).IsEqualTo("WK-2198"); await Assert.That(h.Vm.Parts.Count).IsEqualTo(1); await Assert.That(h.Vm.IsStale).IsTrue(); await h.TickAsync(); - await Assert.That(h.Vm.Key).IsEqualTo("WK-9"); + await Assert.That(h.Vm.Key).IsNull(); + await Assert.That(h.Vm.Title).IsEqualTo("WK-9 — other"); await Assert.That(h.Vm.Parts).IsEmpty(); await Assert.That(h.Vm.IsStale).IsTrue(); await h.TickAsync(); + await Assert.That(h.Vm.Key).IsEqualTo("WK-9"); await Assert.That(h.Vm.IsStale).IsFalse(); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_topology_blip_keeps_blockers_for_the_same_primary_and_clears_them_for_a_new_one() { + await RunOnUiAsync(async () => { + var h = new Harness(); + var blocked = Topology() with { BlockedBy = [new WorkItemRefDto { WorkItemId = "b1", Title = "Pin the helper" }] }; + h.Source.Enqueue( + ReadyWith(Row("w1", "t"), Item(), blocked), + ReadyWith(Row("w1", "t"), Item(), topologyFailed: true), + ReadyWith(Row("w9", "other"), Item(id: "w9"), topologyFailed: true)); + await h.PushAsync(Dto()); + await h.TickAsync(); + await Assert.That(h.Vm.BlockedBy.Count).IsEqualTo(1); + await Assert.That(h.Vm.IsStale).IsTrue(); + + await h.TickAsync(); + await Assert.That(h.Vm.BlockedBy).IsEmpty(); + await h.Vm.TeardownAsync(); + }); + } + + /// An absorbed primary is served under its survivor's id, and the assignments row may catch up + /// to that id a poll later; neither transition may drop the projection. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_card_identity_is_the_served_id_so_an_absorbed_primary_keeps_its_projection() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue( + ReadyWith(Row("w1", "t"), Item(id: "w9", parts: Part("p1", "First", 0))), + ReadyWith(Row("w9", "t"), itemFailed: true), + ReadyWith(Row("w9", "t"), Item(id: "w9", parts: Part("p1", "First", 0))), + ReadyWith(Row("w1", "t"), itemFailed: true)); + await h.PushAsync(Dto()); + await h.TickAsync(); + await Assert.That(h.Vm.Parts.Count).IsEqualTo(1); + await Assert.That(h.Vm.IsStale).IsTrue(); + + await h.TickAsync(); + await Assert.That(h.Vm.Parts.Count).IsEqualTo(1); + await Assert.That(h.Vm.IsStale).IsFalse(); + + await h.TickAsync(); await Assert.That(h.Vm.Parts).IsEmpty(); await h.Vm.TeardownAsync(); }); } + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_overview_shows_unless_it_is_missing_blank_or_mechanical() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue( + ReadyWith(Row("w1", "t"), Item() with { Overview = " Reads the item in one call. " }), + ReadyWith(Row("w1", "t"), Item() with { Overview = "Work item WK-2198", IsOverviewMechanical = true }), + ReadyWith(Row("w1", "t"), Item() with { Overview = " " }), + ReadyWith(Row("w1", "t"), Item() with { Overview = null })); + await h.PushAsync(Dto()); + await Assert.That(h.Vm.Overview).IsEqualTo("Reads the item in one call."); + + await h.TickAsync(); + await Assert.That(h.Vm.Overview).IsNull(); + await h.TickAsync(); + await Assert.That(h.Vm.Overview).IsNull(); + await h.TickAsync(); + await Assert.That(h.Vm.Overview).IsNull(); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_state_pill_follows_the_kind_and_tolerates_an_unknown_one() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue( + ReadyWith(Row("w1", "t"), Item(state: "in_flight")), + ReadyWith(Row("w1", "t"), Item(state: "shipped")), + ReadyWith(Row("w1", "t"), Item(state: "closed")), + ReadyWith(Row("w1", "t"), Item(state: "under_review")), + ReadyWith(Row("w1", "t"), Item(state: null))); + await h.PushAsync(Dto()); + await Assert.That(h.Vm.StateLabel).IsEqualTo("IN FLIGHT"); + await Assert.That(h.Vm.IsShipped).IsFalse(); + await Assert.That(h.Vm.IsClosed).IsFalse(); + + await h.TickAsync(); + await Assert.That(h.Vm.StateLabel).IsEqualTo("SHIPPED"); + await Assert.That(h.Vm.IsShipped).IsTrue(); + + await h.TickAsync(); + await Assert.That(h.Vm.StateLabel).IsEqualTo("CLOSED"); + await Assert.That(h.Vm.IsShipped).IsFalse(); + await Assert.That(h.Vm.IsClosed).IsTrue(); + + await h.TickAsync(); + await Assert.That(h.Vm.StateLabel).IsEqualTo("UNDER REVIEW"); + await Assert.That(h.Vm.IsClosed).IsFalse(); + + await h.TickAsync(); + await Assert.That(h.Vm.StateLabel).IsNull(); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task The_issue_card_is_the_first_link_class_issue_and_reference_rows_are_ignored() { + await RunOnUiAsync(async () => { + var h = new Harness(); + var withIssue = Item() with { + Links = [ + Link("issue", "#764", "https://github.com/kurrent-io/kcap-cli/issues/764", "Stale text", linkClass: "reference"), + Link("pull_request", "#763", "https://github.com/kurrent-io/kcap-cli/pull/763", "Sidebar"), + Link("issue", "#777", "https://github.com/kurrent-io/kcap-cli/issues/777", "Read the work item"), + Link("issue", "#778", "https://github.com/kurrent-io/kcap-cli/issues/778", "Later"), + ], + }; + var untitled = Item() with { Links = [Link("issue", "WK-2521")] }; + h.Source.Enqueue(ReadyWith(Row("w1", "t"), withIssue), ReadyWith(Row("w1", "t"), untitled), ReadyWith(Row("w1", "t"), Item())); + await h.PushAsync(Dto()); + + await Assert.That(h.Vm.HasIssue).IsTrue(); + await Assert.That(h.Vm.Issue!.Eyebrow).IsEqualTo("ISSUE"); + await Assert.That(h.Vm.Issue.Key).IsEqualTo("#777"); + await Assert.That(h.Vm.Issue.Title).IsEqualTo("Read the work item"); + await Assert.That(h.Vm.Issue.CanOpen).IsTrue(); + await h.Vm.Issue.OpenCommand.Execute(); + await Assert.That(h.Opener.Opened).IsEquivalentTo(new[] { "https://github.com/kurrent-io/kcap-cli/issues/777" }); + + await h.TickAsync(); + await Assert.That(h.Vm.Issue!.Title).IsEqualTo("Issue WK-2521"); + await Assert.That(h.Vm.Issue.CanOpen).IsFalse(); + + await h.TickAsync(); + await Assert.That(h.Vm.Issue).IsNull(); + await Assert.That(h.Vm.HasIssue).IsFalse(); + await h.Vm.TeardownAsync(); + }); + } + + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Contributors_and_the_session_count_come_from_the_item_and_the_requester_row_is_the_fallback() { + await RunOnUiAsync(async () => { + var h = new Harness(); + var now = h.Time.GetUtcNow(); + var crowded = Item() with { + Contributors = [Person("u1", " Ada Lovelace ", now.AddHours(-2)), Person("github:7", null, now.AddDays(-3)), Person("u3", "👩 Grace")], + SessionCount = 3, + }; + h.Source.Enqueue(ReadyWith(Row("w1", "t"), crowded), ReadyWith(Row("w1", "t"), Item() with { SessionCount = 1 })); + await h.PushAsync(Dto()); + + await Assert.That(h.Vm.HasContributors).IsTrue(); + await Assert.That(h.Vm.Contributors.Select(c => c.Name)).IsEquivalentTo(new[] { "Ada Lovelace", "github:7", "👩 Grace" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(h.Vm.Contributors.Select(c => c.Initial)).IsEquivalentTo(new[] { "A", "G", "👩" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(h.Vm.Contributors.Select(c => c.LastActivityText)).IsEquivalentTo(new[] { "2h ago", "3d ago", "" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(h.Vm.SessionCountText).IsEqualTo("3 sessions"); + + await h.TickAsync(); + await Assert.That(h.Vm.HasContributors).IsFalse(); + await Assert.That(h.Vm.Contributors).IsEmpty(); + await Assert.That(h.Vm.SessionCountText).IsEqualTo("1 session"); + await Assert.That(h.Vm.Requester).IsEqualTo("You"); + await h.Vm.TeardownAsync(); + }); + } + + /// Every public field of a row takes part in the "same rows" check, or a poll that changes only + /// that field leaves the bound row stale. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task An_avatar_change_alone_refreshes_the_contributor_row() { + await RunOnUiAsync(async () => { + var h = new Harness(); + h.Source.Enqueue( + ReadyWith(Row("w1", "t"), Item() with { Contributors = [Person("u1", "Ada", avatar: "https://avatars.example/u1?v=1")] }), + ReadyWith(Row("w1", "t"), Item() with { Contributors = [Person("u1", "Ada", avatar: "https://avatars.example/u1?v=2")] })); + await h.PushAsync(Dto()); + await Assert.That(h.Vm.Contributors[0].AvatarUrl).IsEqualTo("https://avatars.example/u1?v=1"); + + await h.TickAsync(); + await Assert.That(h.Vm.Contributors[0].AvatarUrl).IsEqualTo("https://avatars.example/u1?v=2"); + await h.Vm.TeardownAsync(); + }); + } + [Test] [NotInParallel("AvaloniaSession")] public async Task Links_come_from_the_list_with_the_top_level_triple_as_a_repository_aware_fallback() { @@ -574,16 +818,27 @@ await RunOnUiAsync(async () => { foreach (var kind in new[] { WorkContextReadKind.SignedOut, WorkContextReadKind.NotInPlan, WorkContextReadKind.SessionUnknown }) { var h = new Harness(); var summary = new SessionSummaryDto { SessionId = SessionA, PullRequests = [Pr("o", "r", 1, null, "One")] }; - h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), Topology(Part("p1", "First", 0)), summary), WorkContextRead.Of(kind)); + var item = Item(parts: Part("p1", "First", 0)) with { + Overview = "Overview", Links = [Link("issue", "#777", "https://github.com/o/r/issues/777", "Issue")], + Contributors = [Person("u1", "Ada")], SessionCount = 2, + }; + var blocked = Topology() with { BlockedBy = [new WorkItemRefDto { WorkItemId = "b1", Title = "Blocker" }] }; + h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), item, blocked, summary), WorkContextRead.Of(kind)); await h.PushAsync(Dto()); + await Assert.That(h.Vm.HasIssue).IsTrue(); await h.TickAsync(); await Assert.That(h.Vm.Key).IsNull(); await Assert.That(h.Vm.Title).IsEqualTo(""); + await Assert.That(h.Vm.Overview).IsNull(); + await Assert.That(h.Vm.StateLabel).IsNull(); await Assert.That(h.Vm.Parts).IsEmpty(); await Assert.That(h.Vm.BlockedBy).IsEmpty(); await Assert.That(h.Vm.CycleNote).IsNull(); await Assert.That(h.Vm.Links).IsEmpty(); + await Assert.That(h.Vm.Issue).IsNull(); + await Assert.That(h.Vm.Contributors).IsEmpty(); + await Assert.That(h.Vm.SessionCountText).IsEqualTo(""); await Assert.That(h.Vm.Repository).IsEqualTo("myproj"); await Assert.That(h.Vm.Requester).IsEqualTo("You"); await h.Vm.TeardownAsync(); @@ -597,13 +852,16 @@ public async Task A_null_primary_clears_the_card_to_no_work_item_but_keeps_the_l await RunOnUiAsync(async () => { var h = new Harness(); var summary = new SessionSummaryDto { SessionId = SessionA, PullRequests = [Pr("o", "r", 1, null, "One")] }; - h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), Topology(Part("p1", "First", 0)), summary), ReadyWith(null, summary: summary)); + var item = Item(parts: Part("p1", "First", 0)) with { Links = [Link("issue", "#777", null, "Issue")], Contributors = [Person("u1", "Ada")] }; + h.Source.Enqueue(ReadyWith(Row("w1", "WK-1 — t"), item, Topology(), summary), ReadyWith(null, summary: summary)); await h.PushAsync(Dto()); await h.TickAsync(); await Assert.That(h.Vm.Phase).IsEqualTo(WorkContextPhase.NoWorkItem); await Assert.That(h.Vm.Parts).IsEmpty(); await Assert.That(h.Vm.Key).IsNull(); + await Assert.That(h.Vm.Issue).IsNull(); + await Assert.That(h.Vm.Contributors).IsEmpty(); await Assert.That(h.Vm.Links.Count).IsEqualTo(1); await h.Vm.TeardownAsync(); }); diff --git a/test/Capacitor.App.Tests.Unit/WorkspaceViewSmokeTests.cs b/test/Capacitor.App.Tests.Unit/WorkspaceViewSmokeTests.cs index 0c4f8782a5..34342d1733 100644 --- a/test/Capacitor.App.Tests.Unit/WorkspaceViewSmokeTests.cs +++ b/test/Capacitor.App.Tests.Unit/WorkspaceViewSmokeTests.cs @@ -115,9 +115,9 @@ await RunOnUiAsync(async () => { var pane = Find(window, "WorkContextHost")!; foreach (var name in new[] { - "RefreshButton", "StaleDot", "WorkContextKey", "WorkContextTitle", "PartOfLine", "PartsToggle", "PartsList", - "BlockedByBlock", "CycleNoteText", "PhaseNoteText", "SignInButton", "RetryButton", "LinkCards", "IssueSoonCard", - "WhoToggle", "SessionToggle", "SessionSummaryText", "SessionFacts", + "RefreshButton", "StaleDot", "StatePill", "WorkContextKey", "WorkContextTitle", "OverviewText", "PartOfLine", "PartsToggle", "PartsList", + "BlockedByBlock", "CycleNoteText", "PhaseNoteText", "SignInButton", "RetryButton", "LinkCards", "IssueCard", + "WhoToggle", "ContributorStack", "ContributorList", "SessionCountText", "RequesterRow", "SessionToggle", "SessionSummaryText", "SessionFacts", }) await Assert.That(pane.FindControl(name)).IsNotNull().Because($"{name} should resolve"); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextClientTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextClientTests.cs index 827144b310..fdfd0cdadf 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextClientTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextClientTests.cs @@ -60,6 +60,19 @@ public async Task Topology_and_summary_routes_hit_their_paths() { await Assert.That(summary.Body!.SessionId).IsEqualTo("s1"); } + [Test] + public async Task The_work_item_route_hits_its_path_and_parses_the_body() { + using var server = Serve("/api/work-items/w1", 200, """{"work_item_id":"w1","title":"T","enriched_title":null,"overview":null,"is_overview_mechanical":false,"key":null,"state":{"kind":"in_flight","settled_at":null},"links":[],"parts":[],"contributors":[],"session_count":1}"""); + using var http = new HttpClient(); + + var item = await new WorkContextClient(http, server.Urls[0]).GetWorkItemAsync(" w1 ", CancellationToken.None); + + await Assert.That(item.Succeeded).IsTrue(); + await Assert.That(item.Body!.Title).IsEqualTo("T"); + await Assert.That(item.Body.State!.Kind).IsEqualTo("in_flight"); + await Assert.That(server.LogEntries.Single().RequestMessage.Path).IsEqualTo("/api/work-items/w1"); + } + [Test] [Arguments(".")] [Arguments("..")] @@ -73,10 +86,12 @@ public async Task An_id_that_would_escape_or_empty_the_route_is_refused_before_a var assignments = await client.GetSessionAssignmentsAsync(id, CancellationToken.None); var summary = await client.GetSessionSummaryAsync(id, CancellationToken.None); var topology = await client.GetTopologyAsync(id == "---" ? "." : id, CancellationToken.None); + var item = await client.GetWorkItemAsync(id == "---" ? ".." : id, CancellationToken.None); await Assert.That(assignments.StatusCode).IsEqualTo(0); await Assert.That(summary.StatusCode).IsEqualTo(0); await Assert.That(topology.StatusCode).IsEqualTo(0); + await Assert.That(item.StatusCode).IsEqualTo(0); } [Test] @@ -89,6 +104,7 @@ public async Task Ids_with_a_slash_or_percent_are_escaped_into_one_segment() { await client.GetSessionAssignmentsAsync("a/b%25c", CancellationToken.None); await client.GetTopologyAsync("x/y", CancellationToken.None); + await client.GetWorkItemAsync("x/y", CancellationToken.None); var urls = server.LogEntries.Select(e => e.RequestMessage.AbsoluteUrl).ToList(); // AbsoluteUrl is a System.Uri round-tripped through ToString(), which keeps %2F (the escaped @@ -96,6 +112,7 @@ public async Task Ids_with_a_slash_or_percent_are_escaped_into_one_segment() { // here even though EscapeDataString produced %25 on the wire. await Assert.That(urls[0]).EndsWith("/api/work-items/session/a%2Fb%25c"); await Assert.That(urls[1]).EndsWith("/api/work-items/x%2Fy/topology"); + await Assert.That(urls[2]).EndsWith("/api/work-items/x%2Fy"); } [Test] diff --git a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextDtoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextDtoTests.cs index c62db20c31..9502af2f5c 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextDtoTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextDtoTests.cs @@ -3,7 +3,7 @@ namespace Capacitor.Cli.Core.Tests.Unit.WorkItems; -/// The three server shapes the sidebar reads, pinned against literal server-shaped bodies, plus +/// The server shapes the sidebar reads, pinned against literal server-shaped bodies, plus /// the source-generated metadata for each root: a round trip alone would pass under reflection /// and only fail on the AOT binary. public class WorkContextDtoTests { @@ -70,10 +70,66 @@ public async Task Error_body_parses() { await Assert.That(error.Message).IsEqualTo("Upgrade the plan."); } + [Test] + public async Task The_work_item_read_deserializes_from_the_server_shape_and_ignores_extra_members() { + const string body = """ + {"work_item_id":"w1","title":"WK-2521","enriched_title":"Desktop pane: read the work item","overview":"One read for the pane.","is_overview_mechanical":false, + "key":{"short_key":"WK-2521","provider":"linear","kind":"issue","value":"linear:WK-2521"}, + "state":{"kind":"shipped","settled_at":"2026-09-05T10:00:00Z"}, + "links":[{"kind":"issue","provider":"github","value":"kurrent-io/kcap-cli#777","short_key":"#777","url":"https://github.com/kurrent-io/kcap-cli/issues/777","title":"Desktop pane","state":"open","link_class":"link","is_seed":true}, + {"kind":"issue","provider":"github","value":"kurrent-io/kcap-cli#764","short_key":"#764","url":null,"title":null,"state":null,"link_class":"reference","is_seed":false}], + "parts":[{"work_item_id":"p1","title":"Core DTOs","ordinal":0,"is_settled":true,"settled_at":"2026-09-05T09:00:00Z"}, + {"work_item_id":"p2","title":"View","ordinal":1,"is_settled":false,"settled_at":null}], + "contributors":[{"user_id":"u1","display_name":"Ada","avatar_url":"https://avatars.example/u1","last_activity_at":"2026-09-05T12:00:00Z"}, + {"user_id":"u2","display_name":null,"avatar_url":null,"last_activity_at":null}], + "session_count":3,"future":{"x":1}} + """; + + var item = JsonSerializer.Deserialize(body, CapacitorJsonContext.Default.WorkItemDto)!; + + await Assert.That(item.WorkItemId).IsEqualTo("w1"); + await Assert.That(item.Title).IsEqualTo("WK-2521"); + await Assert.That(item.EnrichedTitle).IsEqualTo("Desktop pane: read the work item"); + await Assert.That(item.Overview).IsEqualTo("One read for the pane."); + await Assert.That(item.IsOverviewMechanical).IsFalse(); + await Assert.That(item.Key!.ShortKey).IsEqualTo("WK-2521"); + await Assert.That(item.Key.Provider).IsEqualTo("linear"); + await Assert.That(item.State.Kind).IsEqualTo("shipped"); + await Assert.That(item.State.SettledAt).IsEqualTo(new DateTimeOffset(2026, 9, 5, 10, 0, 0, TimeSpan.Zero)); + await Assert.That(item.Links[0].Url).IsEqualTo("https://github.com/kurrent-io/kcap-cli/issues/777"); + await Assert.That(item.Links[0].LinkClass).IsEqualTo("link"); + await Assert.That(item.Links[0].IsSeed).IsTrue(); + await Assert.That(item.Links[1].LinkClass).IsEqualTo("reference"); + await Assert.That(item.Links[1].Url).IsNull(); + await Assert.That(item.Parts[0].IsSettled).IsTrue(); + await Assert.That(item.Parts[0].SettledAt).IsNotNull(); + await Assert.That(item.Parts[1].IsSettled).IsFalse(); + await Assert.That(item.Parts[1].Ordinal).IsEqualTo(1); + await Assert.That(item.Contributors[0].DisplayName).IsEqualTo("Ada"); + await Assert.That(item.Contributors[0].AvatarUrl).IsEqualTo("https://avatars.example/u1"); + await Assert.That(item.Contributors[1].DisplayName).IsNull(); + await Assert.That(item.Contributors[1].LastActivityAt).IsNull(); + await Assert.That(item.SessionCount).IsEqualTo(3); + } + + [Test] + public async Task A_work_item_with_null_lists_and_no_key_deserializes_to_empty_lists() { + const string body = """{"work_item_id":"w1","title":"Daemon tests flake","enriched_title":null,"overview":null,"is_overview_mechanical":true,"key":null,"state":{"kind":"in_flight","settled_at":null},"links":null,"parts":null,"contributors":null,"session_count":0}"""; + + var item = JsonSerializer.Deserialize(body, CapacitorJsonContext.Default.WorkItemDto)!; + + await Assert.That(item.Key).IsNull(); + await Assert.That(item.State.SettledAt).IsNull(); + await Assert.That(item.Links).IsEmpty(); + await Assert.That(item.Parts).IsEmpty(); + await Assert.That(item.Contributors).IsEmpty(); + } + [Test] public async Task Every_root_the_client_reads_has_generated_metadata() { await Assert.That(CapacitorJsonContext.Default.GetTypeInfo(typeof(List))).IsNotNull(); await Assert.That(CapacitorJsonContext.Default.GetTypeInfo(typeof(WorkItemTopologyDto))).IsNotNull(); + await Assert.That(CapacitorJsonContext.Default.GetTypeInfo(typeof(WorkItemDto))).IsNotNull(); await Assert.That(CapacitorJsonContext.Default.GetTypeInfo(typeof(SessionSummaryDto))).IsNotNull(); await Assert.That(CapacitorJsonContext.Default.GetTypeInfo(typeof(WorkItemErrorDto))).IsNotNull(); } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextLabelTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextLabelTests.cs deleted file mode 100644 index f84adad204..0000000000 --- a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextLabelTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Capacitor.Cli.Core.WorkItems; - -namespace Capacitor.Cli.Core.Tests.Unit.WorkItems; - -public class WorkContextLabelTests { - [Test] - public async Task A_keyed_label_splits_on_the_em_dash_separator() { - var (key, display) = WorkContextLabel.Split("WK-2198 — Desktop shell: work-context sidebar"); - await Assert.That(key).IsEqualTo("WK-2198"); - await Assert.That(display).IsEqualTo("Desktop shell: work-context sidebar"); - } - - [Test] - public async Task A_label_without_the_separator_is_display_only() { - var (key, display) = WorkContextLabel.Split("Daemon tests flake under the full suite"); - await Assert.That(key).IsNull(); - await Assert.That(display).IsEqualTo("Daemon tests flake under the full suite"); - } - - [Test] - public async Task A_bare_key_is_display_only() { - var (key, display) = WorkContextLabel.Split("#412"); - await Assert.That(key).IsNull(); - await Assert.That(display).IsEqualTo("#412"); - } - - [Test] - public async Task A_separator_with_an_empty_half_does_not_split() { - var (key, display) = WorkContextLabel.Split(" — only a title"); - await Assert.That(key).IsNull(); - await Assert.That(display).IsEqualTo("— only a title"); - } -} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextReaderTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextReaderTests.cs index cbdb3b08cc..83e9587684 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextReaderTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/WorkItems/WorkContextReaderTests.cs @@ -7,17 +7,26 @@ namespace Capacitor.Cli.Core.Tests.Unit.WorkItems; public class WorkContextReaderTests { sealed class ScriptedChannel : IWorkContextChannel { public WorkContextOutcome> Assignments = new(200, [], null); + public WorkContextOutcome Item = new(200, new WorkItemDto { WorkItemId = "w1", Title = "T" }, null); public WorkContextOutcome Topology = new(200, new WorkItemTopologyDto(), null); public WorkContextOutcome Summary = new(200, new SessionSummaryDto { SessionId = "s1" }, null); public readonly List Calls = []; public TaskCompletionSource SummaryGate = new(); + public TaskCompletionSource ItemGate = new(); public bool GateSummary; + public bool GateItem; public Task>> GetSessionAssignmentsAsync(string sessionId, CancellationToken ct) { Calls.Add($"assignments:{sessionId}"); return Task.FromResult(Assignments); } + public async Task> GetWorkItemAsync(string workItemId, CancellationToken ct) { + Calls.Add($"item:{workItemId}"); + if (GateItem) await ItemGate.Task; + return Item; + } + public Task> GetTopologyAsync(string workItemId, CancellationToken ct) { Calls.Add($"topology:{workItemId}"); return Task.FromResult(Topology); @@ -40,18 +49,65 @@ static Task Read(ScriptedChannel channel) => WorkContextReader.ReadAsync(channel, "s1", CancellationToken.None); [Test] - public async Task Ready_carries_the_primary_its_topology_and_the_summary() { + public async Task Ready_carries_the_primary_its_item_its_topology_and_the_summary() { var channel = new ScriptedChannel { Assignments = new(200, [Row("w2"), Row("w1", primary: true)], null) }; var read = await Read(channel); await Assert.That(read.Kind).IsEqualTo(WorkContextReadKind.Ready); await Assert.That(read.Primary!.WorkItemId).IsEqualTo("w1"); + await Assert.That(read.Item!.Title).IsEqualTo("T"); await Assert.That(read.Topology).IsNotNull(); await Assert.That(read.Summary!.SessionId).IsEqualTo("s1"); + await Assert.That(read.ItemFailed).IsFalse(); await Assert.That(read.TopologyFailed).IsFalse(); await Assert.That(read.SummaryFailed).IsFalse(); + await Assert.That(channel.Calls).Contains("item:w1"); + await Assert.That(channel.Calls).Contains("topology:w1"); + } + + [Test] + public async Task The_item_and_topology_reads_start_together_once_the_primary_is_known() { + var channel = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null), GateItem = true }; + var pending = Read(channel); + + await Task.Delay(50); + await Assert.That(pending.IsCompleted).IsFalse(); await Assert.That(channel.Calls).Contains("topology:w1"); + channel.ItemGate.SetResult(); + + await Assert.That((await pending).Item).IsNotNull(); + } + + [Test] + public async Task Without_a_primary_no_item_is_read() { + var read = await Read(new ScriptedChannel()); + + await Assert.That(read.Item).IsNull(); + await Assert.That(read.ItemFailed).IsFalse(); + await Assert.That(read.Assignments).IsEmpty(); + } + + [Test] + public async Task A_403_with_the_plan_code_on_the_item_is_not_in_plan() { + var channel = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null), Item = PlanGate() }; + var read = await Read(channel); + await Assert.That(read.Kind).IsEqualTo(WorkContextReadKind.NotInPlan); + } + + [Test] + public async Task Item_failures_degrade_the_section() { + var non2xx = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null), Item = new(500, null, null) }; + var gone = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null), Item = new(404, null, null) }; + var noBody = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null), Item = new(200, null, null) }; + + foreach (var channel in new[] { non2xx, gone, noBody }) { + var read = await Read(channel); + await Assert.That(read.Kind).IsEqualTo(WorkContextReadKind.Ready); + await Assert.That(read.Item).IsNull(); + await Assert.That(read.ItemFailed).IsTrue(); + await Assert.That(read.Topology).IsNotNull(); + } } [Test] @@ -81,12 +137,14 @@ public async Task A_2xx_assignments_response_with_no_body_is_unreachable() { [Test] [Arguments("assignments")] + [Arguments("item")] [Arguments("topology")] [Arguments("summary")] public async Task A_final_401_on_any_call_signs_the_read_out(string call) { var channel = new ScriptedChannel { Assignments = new(200, [Row("w1", primary: true)], null) }; switch (call) { case "assignments": channel.Assignments = new(401, null, null); break; + case "item": channel.Item = new(401, null, null); break; case "topology": channel.Topology = new(401, null, null); break; case "summary": channel.Summary = new(401, null, null); break; }