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;
}