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
30 changes: 30 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ 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.

## A launch shows in the rail before the daemon publishes it

A hosted runtime's agent instance exists only once its handshake ends, which for an ACP vendor can
take many seconds per stage, and the workspace the launch auto-opened was a blank shell until then.
The daemon now lists its in-flight launches, with the runtime's latest stage, in the local status
snapshot it already pushes, and the app adds its own placeholder row the moment the server accepts
the request, so the gap is covered from both ends. Both render as one pending row keyed by the
agent id: the daemon's entry hides the placeholder, and a published row on the local lane retires
both — a same-id row on the remote lane is a different agent. A pending row never settles the launch — only a published row clears the failure tracking,
or a late `LaunchFailed` would be lost — and a launch failure removes the placeholder, with a
ten-minute expiry behind it for a failure notice that never arrives.

## The rail colours a worktree's branch glyph by its pull request

The rail knows nothing of pull requests on its own: PR state was read only for the open workspace,
per session, behind the access window that masks the reader. A tone cache reads every listed
session's links and overviews on a slow cadence and reduces each to one tone, keeping the
overview's own denial semantics — a denied read clears the tone, a transient miss keeps the last
one. A worktree shows the strongest tone across its sessions, ordered by how much the state needs
the user, and the card's lifecycle and checks labels share the same vocabulary so the two never
disagree. "Merge conflicts" waits on the server: the overview carries no mergeability field yet,
so the client reads an optional `mergeable` that is null until the server sends it.

## A triple click selects the line, not the whole box

Avalonia's `TextBox` and `SelectableTextBlock` answer the third click with `SelectAll` in their own
class handler on the bubbling route. One application-wide handler on the tunnel route selects the
logical line under the pointer and marks the press handled before that handler runs. Markdown
bodies are outside its reach: MarkView's selection layer is internal and takes no click count.

## Desktop prompts carry attachments

The launcher's goal box and the session composer stage files and send ids, never bytes. The
Expand Down
17 changes: 17 additions & 0 deletions src/Capacitor.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<SolidColorBrush x:Key="KcapDangerDimBrush" Color="#321F29" />
<SolidColorBrush x:Key="KcapInfoBrush" Color="#7BA7F7" />
<SolidColorBrush x:Key="KcapInfoDimBrush" Color="#1B2740" />
<SolidColorBrush x:Key="KcapSelectionBrush" Color="#1E2E52" />
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://Kurrent Capacitor/Views/PendingCardTemplates.axaml" />
</ResourceDictionary.MergedDictionaries>
Expand Down Expand Up @@ -300,6 +301,22 @@
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource KcapTextBrush}" />
</Style>
<!-- In-flight state markers (a starting session's dot, a running check) share one pulse. -->
<Style Selector=":is(Control).pulsing">
<Style.Animations>
<Animation Duration="0:0:1.1" IterationCount="Infinite">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0.35" />
</KeyFrame>
<KeyFrame Cue="50%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0.35" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<!-- A prompt card that takes dropped files. The resting border lives here rather than on
the card so the drag-over ring can outrank it: a local value on the control beats
every style, and a style closer to the control beats one declared here — which is why
Expand Down
19 changes: 13 additions & 6 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ public partial class App : Application {
ServerPermissionFeed? _permissionFeed;
SessionAttentionTracker? _attention;
SessionAccessService? _sessionAccess;
PullRequestToneCache? _pullRequestTones;
ConsentPromptCoordinator? _promptCoordinator;
// Disposed with the other UI services below: it holds a constructor-scoped subscription to
// the shared ticker, which is RefCount'd — an undisposed subscriber keeps the Interval (and
Expand Down Expand Up @@ -204,6 +205,7 @@ static IAppUpdater CreateUpdater() {

public override void Initialize() {
AvaloniaXamlLoader.Load(this);
LineSelection.Install();
// Here, not later: Avalonia exports the app menu right after Initialize, substituting its own
// "About Avalonia" when there is none.
NativeMenu.SetMenu(this, _appMenu.Menu);
Expand Down Expand Up @@ -295,7 +297,7 @@ async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) {
await HandleStartupFailureAsync(
desktop, ex, _service, _shutdown,
[_tray, _trayVm, _promptCoordinator, _consent, _permissionFeed, _attention, _permissions, _sessionAccess,
_activity, _home, _rail, _pause, _restartPending],
_pullRequestTones, _activity, _home, _rail, _pause, _restartPending],
_lifecycle, _lane);
await DisposeServerClientsAsync(); // after _home above
// all already disposed above — never let a later OnShutdownRequested (e.g. Cmd+Q
Expand All @@ -313,6 +315,7 @@ await HandleStartupFailureAsync(
_attention = null;
_permissions = null;
_sessionAccess = null;
_pullRequestTones = null;
_pause = null;
_activity = null;
_home = null;
Expand Down Expand Up @@ -558,6 +561,8 @@ void BuildDaemonGraph(
serverLane, sessionAccess, permissions, readDetail, directory.VendorOfSession, TimeProvider.System);
var attention = new SessionAttentionTracker(serverLane, readDetail, TimeProvider.System);
_sessionAccess = sessionAccess;
var pullRequestTones = new PullRequestToneCache(directory, readers, TimeProvider.System);
_pullRequestTones = pullRequestTones;
_permissionFeed = permissionFeed;
_attention = attention;

Expand Down Expand Up @@ -606,7 +611,7 @@ void BuildDaemonGraph(
linkGitHub: () => {
if (profiles?.Resolution.ServerUrl is { Length: > 0 } url) LinkPolicy.Open(opener, url.TrimEnd('/') + "/auth/github-link/start");
},
access: sessionAccess, localDaemonOnAppServer: directory.LocalDaemonOnAppServer);
access: sessionAccess, localDaemonOnAppServer: directory.LocalDaemonOnAppServer, directory: directory);
// The origin lookup below and this call are two reads of a cache the directory's own
// background recompute mutates, so the row can be gone by the time this runs: no row, no
// host, and the click opens nothing.
Expand All @@ -624,12 +629,12 @@ void BuildDaemonGraph(
// The tenant slug the rail footer shows — profiles are named after it at sign-in.
tenantName: profiles?.Resolution?.ProfileName, agentsWithPending: agentsWithPending,
requestSignIn: requestSignIn,
lifecycleAttention: lifecycleAttention,
lifecycleAttention: lifecycleAttention, pullRequestTones: pullRequestTones.Tones,
directory: directory, remoteAgents: remoteAgents, lane: serverLane,
viewerId: viewerId, localMachineId: machineId, restartPending: restartPending.Pending,
// A row present on both lanes is the local one: the local socket is the richer
// workspace, and the directory only keeps both rows when the twin is unproven.
originOf: id => directory.Rows.Lookup($"local:{id}").HasValue ? AgentOrigin.Local
originOf: id => directory.Rows.Lookup($"local:{id}").HasValue || directory.Rows.Lookup($"pending:{id}").HasValue ? AgentOrigin.Local
: directory.Rows.Lookup($"remote:{id}").HasValue ? AgentOrigin.Remote
: null,
remoteWorkspaceFactory: BuildRemote,
Expand Down Expand Up @@ -1096,6 +1101,7 @@ internal static MainWindow BuildAndShowMainWindow(
Func<string, WorkspaceViewModel>? workspaceFactory = null, string? tenantName = null,
IObservable<IReadOnlySet<string>>? agentsWithPending = null, Action? requestSignIn = null,
IObservable<string?>? lifecycleAttention = null,
IObservable<IReadOnlyDictionary<string, PullRequestTone>>? pullRequestTones = null,
IAgentDirectory? directory = null, IRemoteAgentsService? remoteAgents = null,
IServerLane? lane = null, Func<CancellationToken, Task<string?>>? viewerId = null,
string? localMachineId = null, IObservable<bool>? restartPending = null,
Expand Down Expand Up @@ -1141,7 +1147,8 @@ internal static MainWindow BuildAndShowMainWindow(
// own lookup would open the local one for both.
var rail = new SessionRailViewModel(
resolvedDirectory, openLocalSession: agentId => vm?.OpenSession(agentId, AgentOrigin.Local),
openRemoteSession: agentId => vm?.OpenSession(agentId, AgentOrigin.Remote), agentsWithPending: agentsWithPending);
openRemoteSession: agentId => vm?.OpenSession(agentId, AgentOrigin.Remote), agentsWithPending: agentsWithPending,
pullRequestTones: pullRequestTones);
vm = new MainWindowViewModel(
service, shutdownToken, activity, startAction, lifecycleStatus, home: home,
navigation: navigation, trackWorkspaceTeardown: trackWorkspaceTeardown, workspaceFactory: workspaceFactory,
Expand Down Expand Up @@ -1659,7 +1666,7 @@ async Task DisposeAndShutdownAsync() {
// OnShutdownRequested and settles on the ViewModel's silent-abort path.
await DisposeUiThenConfirmShutdownAsync(
[_tray, _trayVm, _promptCoordinator, _consent, _permissionFeed, _attention, _permissions, _sessionAccess,
_activity, _home, _rail, _pause, _restartPending],
_pullRequestTones, _activity, _home, _rail, _pause, _restartPending],
DisposeLifecycleAndServiceAsync, () => _shutdownConfirmed = true, desktop, _exitCode,
applyOnExit: () => _updates?.ApplyPendingOnExit());
} else {
Expand Down
74 changes: 67 additions & 7 deletions src/Capacitor.App/Services/AgentDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public interface IAgentDirectory {
/// Whether the local daemon has proven it hosts this agent — the daemon proved to be its
/// server twin registers it. A shared agent id proves nothing: the dedup fails open.
bool IsProvenLocalTwin(string agentId);
/// A row for a launch the server accepted, standing until the local lane publishes the id;
/// a same-id row on the remote lane is a different agent and leaves it in place. It is
/// dropped by the caller on a launch failure, and expires on its own after ten minutes.
void AddPlaceholder(string agentId, string vendor, string repoPath, string? title, string? model);
void RemovePlaceholder(string agentId);
}

/// Merges the local daemon's agents with the server registry's into source-scoped rows.
Expand All @@ -42,6 +47,9 @@ public sealed class AgentDirectory : IAgentDirectory, IDisposable {
readonly Func<string, string> _resolveLocalRepoRoot;
readonly string? _localMachineId;
readonly string? _appServerUrl;
readonly TimeProvider _time;
// Nothing else need ever change in an idle directory, so expiry is this timer's job alone.
readonly ITimer _placeholderExpiry;
readonly object _lock = new();
readonly BehaviorSubject<bool> _onAppServer = new(false);

Expand All @@ -50,17 +58,23 @@ public sealed class AgentDirectory : IAgentDirectory, IDisposable {
string? _localServerUrl;
List<AgentInstanceDto> _remoteAgents = [];
List<AgentStatusDto> _localAgents = [];
List<PendingLaunchDto> _pendingLaunches = [];
readonly Dictionary<string, AgentRow> _placeholders = new(StringComparer.Ordinal);
static readonly TimeSpan PlaceholderTtl = TimeSpan.FromMinutes(10);
FrozenSet<string> _twinAgents = FrozenSet<string>.Empty;
bool _disposed;

public AgentDirectory(
IDaemonClientService local, IRemoteAgentsService remote, IServerLane lane,
RepoIdentityResolver repoIdentity, Func<string, string> resolveLocalRepoRoot,
string? localMachineId, string? appServerUrl) {
string? localMachineId, string? appServerUrl, TimeProvider? time = null) {
_local = local;
_repoIdentity = repoIdentity;
_resolveLocalRepoRoot = resolveLocalRepoRoot;
_localMachineId = localMachineId;
_appServerUrl = appServerUrl;
_time = time ?? TimeProvider.System;
_placeholderExpiry = _time.CreateTimer(_ => Recompute(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);

RemoteStale = lane.Status.Select(s => s.State != ServerLaneState.Connected).DistinctUntilChanged();

Expand All @@ -71,6 +85,10 @@ public AgentDirectory(
.Subscribe(items => { lock (_lock) _localAgents = [.. items]; Recompute(); })
.DisposeWith(_subscriptions);

local.Pending.Connect().ToCollection()
.Subscribe(items => { lock (_lock) _pendingLaunches = [.. items]; Recompute(); })
.DisposeWith(_subscriptions);

remote.Agents.Connect().ToCollection()
.Subscribe(items => { lock (_lock) _remoteAgents = [.. items]; Recompute(); })
.DisposeWith(_subscriptions);
Expand Down Expand Up @@ -105,6 +123,20 @@ public AgentDirectory(

public bool IsProvenLocalTwin(string agentId) => _twinAgents.Contains(agentId);

// Placeholders are keyed by the normalized id: the server accepts a launch under one spelling
// and the daemon can publish it under another.
public void AddPlaceholder(string agentId, string vendor, string repoPath, string? title, string? model) {
if (AgentIds.Normalize(agentId) is not { } key) return;
lock (_lock) _placeholders[key] = AgentRow.Placeholder(key, vendor, repoPath, title, model, _time.GetUtcNow().UtcDateTime, RepoFor(repoPath));
Recompute();
}

public void RemovePlaceholder(string agentId) {
if (AgentIds.Normalize(agentId) is not { } key) return;
lock (_lock) _placeholders.Remove(key);
Recompute();
}

/// Both session lookups answer for a server-lane session id. While the local daemon reports
/// another server, its rows carry that server's ids and a match here is coincidence: the id
/// names a different session, whose agent is not the local one.
Expand All @@ -127,12 +159,11 @@ public bool Equals(IReadOnlyDictionary<string, string>? x, IReadOnlyDictionary<s
public int GetHashCode(IReadOnlyDictionary<string, string> obj) => obj.Count;
}

AgentRow ProjectLocal(AgentStatusDto dto) {
var repo = dto.RepoPath is { Length: > 0 } path
? _repoIdentity.ForLocalRoot(PlatformPaths.Normalize(_resolveLocalRepoRoot(path)))
: new RepoIdentity("path:", "No repository");
return AgentRow.FromLocal(dto, repo);
}
RepoIdentity RepoFor(string? repoPath) => repoPath is { Length: > 0 } path
? _repoIdentity.ForLocalRoot(PlatformPaths.Normalize(_resolveLocalRepoRoot(path)))
: new RepoIdentity("path:", "No repository");

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

// The compute-then-edit pair must be one atomic unit under _lock: two triggers (e.g. a
// socket-thread Status flip racing a SignalR-thread Daemons refresh) that read-then-edit as
Expand All @@ -145,6 +176,9 @@ AgentRow ProjectLocal(AgentStatusDto dto) {
// seed gap after every connect), so an unpaired local row stands as display-only history.
void Recompute() {
lock (_lock) {
// The expiry timer's callback can land after Dispose ran; the lock serialises the two
// and the flag turns the late callback into a no-op instead of an edit of a disposed cache.
if (_disposed) return;
var twin = LocalDaemonTwin.Find(_daemons, _localMachineId, _local.DaemonName, _localServerUrl, _appServerUrl);
var twinProven = twin is not null;
bool OnTwin(AgentInstanceDto a) =>
Expand Down Expand Up @@ -174,6 +208,27 @@ bool OnTwin(AgentInstanceDto a) =>
.Select(ProjectLocal);
var next = localRows.Concat(remote.Select(AgentRow.FromRemote)).ToList();

// A published local row retires the launch's stand-ins for good; the daemon's own
// pending entry only hides the app's placeholder, which returns should the entry vanish
// without a row, until the failure notice or the TTL removes it. Only the local lane
// counts: a same-id row on the remote lane is a different agent, and ids compare in
// their normalized form because the two lanes spell a Guid differently.
var published = next.Where(r => r.Origin == AgentOrigin.Local).Select(r => AgentIds.Normalize(r.Id)).OfType<string>().ToHashSet(StringComparer.Ordinal);
foreach (var id in _placeholders.Keys.Where(published.Contains).ToList()) _placeholders.Remove(id);
var now = _time.GetUtcNow().UtcDateTime;
foreach (var id in _placeholders.Where(kv => kv.Value.CreatedAt + PlaceholderTtl <= now).Select(kv => kv.Key).ToList()) _placeholders.Remove(id);
// Infinite when nothing is pending: a negative due time would fire at once and re-arm forever.
var remaining = _placeholders.Count == 0 ? Timeout.InfiniteTimeSpan : _placeholders.Values.Min(r => r.CreatedAt) + PlaceholderTtl - now;
var nextExpiry = _placeholders.Count == 0 ? Timeout.InfiniteTimeSpan : remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
_placeholderExpiry.Change(nextExpiry, Timeout.InfiniteTimeSpan);
var pendingRows = _pendingLaunches
.Where(p => AgentIds.Normalize(p.Id) is { } id && !published.Contains(id))
.Select(p => AgentRow.FromPending(p, RepoFor(p.RepoPath)))
.ToList();
var starting = pendingRows.Select(r => AgentIds.Normalize(r.Id)).OfType<string>().ToHashSet(StringComparer.Ordinal);
next.AddRange(pendingRows);
next.AddRange(_placeholders.Values.Where(r => !starting.Contains(r.Id)));

_rows.Edit(cache => {
foreach (var key in cache.Keys.Where(k => !next.Any(r => r.Key == k)).ToList())
cache.RemoveKey(key);
Expand All @@ -192,6 +247,11 @@ bool OnTwin(AgentInstanceDto a) =>
}

public void Dispose() {
lock (_lock) {
if (_disposed) return;
_disposed = true;
_placeholderExpiry.Dispose();
}
_subscriptions.Dispose();
_rows.Dispose();
}
Expand Down
12 changes: 12 additions & 0 deletions src/Capacitor.App/Services/AgentIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Capacitor.App.Services;

/// Id shapes differ across the stack and across daemon versions: the server hub returns dashed
/// Guids while a daemon keys its status cache on the "N" form or a short id. A Guid in any format
/// normalizes to "N"; any other non-empty id passes through verbatim to match what the daemon
/// sent. Only a null or blank id is unusable.
public static class AgentIds {
public static string? Normalize(string? agentId) =>
Guid.TryParse(agentId, out var parsed) ? parsed.ToString("N")
: string.IsNullOrWhiteSpace(agentId) ? null
: agentId;
}
Loading