Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/Capacitor.App/ViewModels/RelativeTime.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
31 changes: 26 additions & 5 deletions src/Capacitor.App/ViewModels/WorkContextItems.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
using System.Globalization;
using System.Reactive;
using System.Reactive.Linq;
using Capacitor.App.Services;
using ReactiveUI;

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; }
Expand All @@ -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. <see cref="AvatarUrl"/> 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();
}
140 changes: 124 additions & 16 deletions src/Capacitor.App/ViewModels/WorkContextViewModel.Projections.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Globalization;
using System.Reactive;
using Avalonia.Collections;
using Capacitor.Cli.Core.LocalIpc;
Expand All @@ -14,24 +13,69 @@ public sealed partial class WorkContextViewModel {
readonly AvaloniaList<WorkContextPartViewModel> _parts = new();
readonly AvaloniaList<string> _blockedBy = new();
readonly AvaloniaList<WorkContextLinkViewModel> _links = new();
readonly AvaloniaList<WorkContextPersonViewModel> _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<WorkContextPartViewModel> Parts => _parts;
public IAvaloniaReadOnlyList<string> BlockedBy => _blockedBy;
public IAvaloniaReadOnlyList<WorkContextLinkViewModel> Links => _links;
public IAvaloniaReadOnlyList<WorkContextPersonViewModel> 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); }
Expand Down Expand Up @@ -66,7 +110,7 @@ ReactiveCommand<Unit, Unit> 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) {
Expand All @@ -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();
}
Expand All @@ -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) {
Expand All @@ -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<SessionWorkItemAssignmentDto> assignments) {
static bool Same(string a, string? b) => string.Equals(a, b, StringComparison.Ordinal);

void ApplyItem(WorkItemDto item, IReadOnlyList<SessionWorkItemAssignmentDto> 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<string>(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<string> 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",
Expand Down
4 changes: 3 additions & 1 deletion src/Capacitor.App/ViewModels/WorkContextViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -42,6 +42,7 @@ sealed class ReadLease(string sessionId) {

readonly IWorkContextSource _source;
readonly IUrlOpener _opener;
readonly TimeProvider _time;
readonly CompositeDisposable _disposables = new();
readonly List<ReadLease> _outstanding = [];
ReadLease? _current;
Expand Down Expand Up @@ -135,6 +136,7 @@ public WorkContextViewModel(
Action? requestSignIn = null, IObservable<Unit>? signInCompleted = null) {
_source = source;
_opener = opener;
_time = time;
InitializeProjections();
_disposables.Add(_hasSessionChanges);

Expand Down
Loading