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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ dotnet build src/Capacitor.Cli/Capacitor.Cli.csproj
- Spawning the real `kcap` binary goes through Helpers' `KcapProcess`, which requires a `DaemonStore` and pins it for the child. The assembly-wide `KCAP_DAEMONS_DIR` pin is a path that cannot be created, so a hand-rolled spawn dies with a bare ENOTDIR `IOException` instead of quietly resolving the developer's own daemons directory.
- Capture console output with `ConsoleOutput.StartCapture()` / `StartErrorCapture()`, never a hand-rolled `Console.SetOut`/`SetError` save-restore — TUnit0055 is an error. Console is process-global, so every caller needs bare `[NotInParallel]`; a group key is not enough.
- Never assert that an environment variable is *absent* from a built `ProcessStartInfo`: its environment is seeded from the current process, and the repo's own `.envrc` exports several. Assert what the code under test contributed, by comparing against the inherited value.
- Do not write tests for test-only fakes/mocks, dedicated test files for one-line wrappers/constants already exercised by their callers, or headless XAML smoke tests that only assert `x:Name` lookups, static typography/margin values, or Avalonia layout math. Keep `*SmokeTests` to real view interactions (focus, keyboard/pointer routing, flyouts, accessibility peers) and keep data-projection permutations in ViewModel unit tests.

**Parallelism:** bare `[NotInParallel]` is exclusive against the whole assembly and its tests run last, one at a time; keyed `[NotInParallel("k")]` excludes only tests carrying `k`.

Expand Down Expand Up @@ -248,6 +249,7 @@ Description: **before writing it, open [.github/PULL_REQUEST_TEMPLATE.md](.githu
`kcapField` inputs. Fluent `MenuItem` chrome, default `Button` / `ToggleButton` presenters, and
Fluent accent on a menu or toolbar are out — Fluent paints `PART_ContentPresenter` unless the
Kcap class restyles it.
- Reactive and UI-thread discipline: filter `SourceCache.Connect()` streams *before* `.ObserveOn(RxSchedulers.MainThreadScheduler)` so irrelevant cache revisions do not schedule UI-thread work across every open workspace; use `SingleFlightReader<TState, TResult>` for session-keyed single-flight server reads rather than hand-rolling another `ReadLease` state machine; cache template parts (`ScrollViewer`, `TextPresenter`) on `TemplateApplied` instead of walking `GetVisualDescendants()` on keystrokes or layout ticks; and do not run uncached git/disk/hash I/O inside recompute loops or on the UI thread.

## Dos and donts

Expand Down
13 changes: 10 additions & 3 deletions src/Capacitor.App/Services/AgentDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,16 @@ public bool Equals(IReadOnlyDictionary<string, string>? x, IReadOnlyDictionary<s
public int GetHashCode(IReadOnlyDictionary<string, string> obj) => obj.Count;
}

RepoIdentity RepoFor(string? repoPath) => repoPath is { Length: > 0 } path
? _repoIdentity.ForLocalRoot(PlatformPaths.Normalize(_resolveLocalRepoRoot(path)))
: new RepoIdentity("path:", "No repository");
static readonly RepoIdentity NoRepositoryIdentity = new("path:", "No repository");
readonly Dictionary<string, RepoIdentity> _repoByPath = new(StringComparer.Ordinal);

RepoIdentity RepoFor(string? repoPath) {
if (repoPath is not { Length: > 0 } path) return NoRepositoryIdentity;
if (_repoByPath.TryGetValue(path, out var cached)) return cached;
var resolved = _repoIdentity.ForLocalRoot(PlatformPaths.Normalize(_resolveLocalRepoRoot(path)));
_repoByPath[path] = resolved;
return resolved;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

AgentRow ProjectLocal(AgentStatusDto dto) => AgentRow.FromLocal(dto, RepoFor(dto.RepoPath));

Expand Down
107 changes: 30 additions & 77 deletions src/Capacitor.App/ViewModels/ArtefactsTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using System.Reactive.Concurrency;
using System.Security.Cryptography;
using Avalonia.Collections;
using Avalonia.Threading;
using Capacitor.App.Services;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Plans;
Expand All @@ -15,26 +14,15 @@ namespace Capacitor.App.ViewModels;
/// id, its root, its cancellation and its pending read, a result applies only for the current lease,
/// and every lease transition happens on the UI thread.
public sealed class ArtefactsTabViewModel : ReactiveObject {
sealed class ReadLease(string sessionId, string? root) {
public string SessionId { get; } = sessionId;
public string? Root { get; } = root;
public CancellationTokenSource Cts { get; } = new();
public Task? Pending;
public bool RefreshPending;
public bool Settled;
public bool IsReading => Pending is { IsCompleted: false };
}

readonly IPlanArtifactSource? _source;
readonly SingleFlightReader<(string SessionId, string? Root), PlanArtifactsRead> _reads;
readonly PlanActivity _activity;
readonly TimeProvider _time;
const long MaxHashedBytes = 4 * 1024 * 1024;

readonly Func<string, Stream?> _openWorkingCopy;
readonly AvaloniaList<DocumentRow> _documents = [];
readonly List<ReadLease> _outstanding = [];
readonly ITimer _settle;
ReadLease? _current;
bool _tornDown;
string? _pendingPath;

Expand All @@ -43,7 +31,7 @@ sealed class ReadLease(string sessionId, string? root) {
internal bool LastReadFailed { get; private set; }
public bool HasAny => _documents.Count > 0;
/// True while a session lease is open and has not yet applied a result — distinct from a settled empty list.
public bool IsLoading => _current is { Settled: false };
public bool IsLoading => _reads.IsLoading;
public string SummaryText => _documents.Count == 1 ? "1 document" : $"{_documents.Count} documents";
/// Right-pane prompt when nothing is open: loading, settled empty, or pick a row.
public string EmptyReaderNote => IsLoading ? "Loading documents…"
Expand All @@ -66,11 +54,20 @@ sealed class ReadLease(string sessionId, string? root) {
public event Action? OpenRequested;

/// Test-only seam: the current lease's read, or the last one started.
internal Task? PendingReadForTesting => _current?.Pending ?? _outstanding.LastOrDefault()?.Pending;
internal Task? PendingReadForTesting => _reads.PendingReadForTesting;

public ArtefactsTabViewModel(IPlanArtifactSource? source, PlanActivity activity, TimeProvider time, Func<string, Stream?>? openWorkingCopy = null,
IUrlOpener? opener = null) {
_source = source;
_reads = new SingleFlightReader<(string SessionId, string? Root), PlanArtifactsRead>(
(state, ct) => _source!.ReadAsync(state.SessionId, ct),
ex => {
Console.Error.WriteLine($"kcap: artefacts tab: {ex.Message}");
return PlanArtifactsRead.Of(SessionPlansReadKind.Unreachable);
},
Apply,
"artefacts tab",
onSettled: RaiseLoading);
_activity = activity;
_time = time;
_openWorkingCopy = openWorkingCopy ?? OpenFile;
Expand All @@ -82,23 +79,24 @@ public ArtefactsTabViewModel(IPlanArtifactSource? source, PlanActivity activity,

public void SwitchSession(string? sessionId, string? root) {
if (_tornDown || _source is null) return;
var old = _current;
_current = string.IsNullOrEmpty(sessionId) ? null : new ReadLease(sessionId, root);
old?.Cts.Cancel();
_pendingPath = null;
LastReadFailed = false;
// Raise IsLoading before Clear so observers that combine HasAny || IsLoading see loading
// true while HasAny falls — otherwise a stale IsLoading=false hides a sticky tab.
RaiseLoading();
Clear();
if (_current is not null) StartRead(_current);
if (string.IsNullOrEmpty(sessionId)) {
_reads.ClearCurrent(() => {
RaiseLoading();
Clear();
});
} else {
_reads.Switch((sessionId, root), () => {
RaiseLoading();
Clear();
});
}
}

/// Reads now, or queues one follow-up behind the read in flight.
public void Refresh() {
if (_tornDown || _current is not { } lease) return;
if (lease.IsReading) lease.RefreshPending = true;
else StartRead(lease);
if (!_tornDown) _reads.Refresh();
}

public void RequestOpen() => OpenRequested?.Invoke();
Expand All @@ -108,7 +106,7 @@ public void Refresh() {
public bool OpenDocument(string path) {
var row = Find(path);
if (row is null) {
if (_current is { Settled: false }) _pendingPath = path;
if (_reads.HasCurrent && !_reads.IsCurrentSettled) _pendingPath = path;
else return false;
if (!HasAny) return false;
RequestOpen();
Expand All @@ -129,7 +127,7 @@ void Select(DocumentRow row) {
}

DriftState Drift(DocumentRow row) {
if (_current?.Root is not { Length: > 0 } root || row.Source != "declared") return DriftState.Unknown;
if (_reads.CurrentState.Root is not { Length: > 0 } root || row.Source != "declared") return DriftState.Unknown;
var relative = row.Path.Replace('\\', '/');
if (relative.Length == 0 || relative.StartsWith('/') || System.IO.Path.IsPathRooted(relative)
|| (relative.Length >= 2 && char.IsAsciiLetter(relative[0]) && relative[1] == ':')
Expand All @@ -151,59 +149,21 @@ void OnPlanWritten() {
_settle.Change(PlanSectionViewModel.SettleDelay, Timeout.InfiniteTimeSpan);
}

void StartRead(ReadLease lease) {
lease.RefreshPending = false;
lease.Pending = RunReadAsync(lease);
_outstanding.Add(lease);
}

async Task RunReadAsync(ReadLease lease) {
PlanArtifactsRead? read = null;
try {
read = await _source!.ReadAsync(lease.SessionId, lease.Cts.Token).ConfigureAwait(false);
} catch (OperationCanceledException) {
} catch (Exception ex) {
Console.Error.WriteLine($"kcap: artefacts tab: {ex.Message}");
read = PlanArtifactsRead.Of(SessionPlansReadKind.Unreachable);
}
try {
await Dispatcher.UIThread.InvokeAsync(() => Settle(lease, read));
} catch (Exception ex) {
Console.Error.WriteLine($"kcap: artefacts tab: {ex.Message}");
}
}

void Settle(ReadLease lease, PlanArtifactsRead? read) {
_outstanding.Remove(lease);
var current = ReferenceEquals(lease, _current) && !_tornDown;
if (!current) { lease.Cts.Dispose(); return; }
try {
if (read is not null) Apply(read);
} catch (Exception ex) {
Console.Error.WriteLine($"kcap: artefacts tab: {ex.Message}");
}
if (lease.RefreshPending) StartRead(lease);
}

/// Only an unreachable read is worth a retry on the poll: a signed-out or unavailable answer is
/// what sign-in and the next plan write repair, and polling it would cost a body-carrying request
/// per workspace per tick for nothing.
void Apply(PlanArtifactsRead read) {
LastReadFailed = read.Kind == SessionPlansReadKind.Unreachable;
if (_current is { } lease) lease.Settled = true;
// Populate (or clear) before RaiseLoading so HasAny || IsLoading never dips to false
// between settlement and content — that flicker hides the tab and forces Chat.
switch (read.Kind) {
case SessionPlansReadKind.Ready:
Show(read.Body?.Artifacts ?? []);
break;
return;
case SessionPlansReadKind.Unreachable:
break;
return;
default:
Clear();
break;
return;
}
RaiseLoading();
}

static int KindRank(string kind) => kind switch { "plan" => 0, "spec" => 1, "design" => 2, _ => 3 };
Expand Down Expand Up @@ -270,13 +230,6 @@ public async Task TeardownAsync() {
_settle.Dispose();
SelectCommand.Dispose();
OpenLinkCommand.Dispose();
var leases = _outstanding.ToArray();
foreach (var lease in leases) lease.Cts.Cancel();
_current = null;
foreach (var lease in leases)
if (lease.Pending is { } pending) {
try { await pending; } catch (Exception) { }
}
foreach (var lease in leases) lease.Cts.Dispose();
await _reads.TeardownAsync();
}
}
21 changes: 13 additions & 8 deletions src/Capacitor.App/ViewModels/ChatTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,11 @@ internal static string ParticipantNotice(AgentStatusDto dto) {
/// facts under a Completed status. Identical revisions are not republished.
static IObservable<ChatSessionInfo> LocalSession(string agentId, IDaemonClientService daemon) =>
daemon.Agents.Connect()
.Filter(dto => dto.Id == agentId)
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Scan((ChatSessionInfo?)null, (last, changes) => {
var info = last;
foreach (var change in changes) {
if (change.Key != agentId) continue;
if (change.Reason == ChangeReason.Remove)
info = (info ?? ChatSessionInfo.Gone) with {
Status = "Completed", StatusLabel = "Done", Ended = true, UsageLimit = null, WaitsOnUser = false,
Expand Down Expand Up @@ -725,11 +725,11 @@ void OnSession(ChatSessionInfo info) {
if (info.FeedKey != _queueKey) {
_queueKey = info.FeedKey;
foreach (var foreign in _queuedMessages.Where(q => q.IsForeign).ToList()) _queuedMessages.Remove(foreign);
RefreshQueue();
}
if (_openFeed is { } open && info.FeedKey is { } key && key != _feedKey) SwitchFeed(key, open);
var switched = _openFeed is { } open && info.FeedKey is { } key && key != _feedKey;
if (switched) SwitchFeed(info.FeedKey!, _openFeed!);
RefreshActivityNote();
RefreshQueue();
if (!switched) RefreshQueue();
}

void ApplyUsageLimit(UsageLimitNoticeDto? notice) {
Expand Down Expand Up @@ -891,12 +891,17 @@ void Apply(int generation, FeedRead read) {
Phase = ChatTabPhase.Reading;
// A send made before the transcript existed has no safe baseline. Its first successful
// read establishes one; that initial history cannot acknowledge the send.
foreach (var queued in Tracked.Where(q => !q.HasBaseline && !q.IsForeign))
var rebased = false;
foreach (var queued in Tracked.Where(q => !q.HasBaseline && !q.IsForeign)) {
queued.Rebase(_inputGeneration, read.SnapshotOffset ?? CurrentOffset ?? 0);
RefreshQueue();
rebased = true;
}
if (rebased) RefreshQueue();
if (read.Lines.Count == 0) {
SyncPendingCardItems();
RefreshActivityNote();
if (read.Status == FeedStatus.Reset) {
SyncPendingCardItems();
RefreshActivityNote();
}
return;
}

Expand Down
Loading
Loading