Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a2bb618
AI-1651 design: tray presence, agents list, stop (desktop supervisor …
alexeyzimarev Aug 6, 2026
7354fcc
AI-1651 spec: address reviewer round-2 findings
alexeyzimarev Aug 6, 2026
74c1ec1
AI-1651 spec: address reviewer round-3 findings
alexeyzimarev Aug 6, 2026
2c6c825
AI-1651 spec: address reviewer round-4 findings
alexeyzimarev Aug 6, 2026
6fe5742
AI-1651 spec: address reviewer round-5 findings
alexeyzimarev Aug 6, 2026
dc3be25
AI-1651 spec: address reviewer round-6 findings
alexeyzimarev Aug 6, 2026
7bf8781
AI-1651 implementation plan: 7 tasks from the signed-off spec
alexeyzimarev Aug 6, 2026
ec6d95f
feat: LocalControlOps one-shot stop/consent IPC operations
alexeyzimarev Aug 6, 2026
378fd76
feat: tray state projection and menu model
alexeyzimarev Aug 6, 2026
ecf3b95
fix: defend TrayViewModel's OAPH seed against a non-synchronous IPaus…
alexeyzimarev Aug 6, 2026
aa75f38
feat: pause-launches controller with serialized consent lane
alexeyzimarev Aug 6, 2026
d261a8e
fix: never leak the PauseController lane on an unmapped exception
alexeyzimarev Aug 6, 2026
b7b9d0d
feat: agent stop and open-in-web actions with banner surfacing
alexeyzimarev Aug 6, 2026
1880646
feat: agents grid with uptime, actions and transient banner
alexeyzimarev Aug 6, 2026
408aec1
fix: dispose recreated AgentRowViewModel instances via DisposeMany
alexeyzimarev Aug 6, 2026
f183064
feat: tray icon and native menu adapter
alexeyzimarev Aug 6, 2026
8f21371
fix: assign pause item IsEnabled after Command in TrayMenuBuilder
alexeyzimarev Aug 6, 2026
26b67a6
feat: hide-to-tray lifecycle and tray composition
alexeyzimarev Aug 6, 2026
d1db4b5
fix: flag QuitInProgress on every shutdown pass
alexeyzimarev Aug 6, 2026
723dbe2
test: close final-review §12 coverage gaps; fix two comments
alexeyzimarev Aug 6, 2026
c107f37
test: carry capability into the disconnected push to isolate connected
alexeyzimarev Aug 6, 2026
8b28078
fix: stable Agents binding, scrollable grid, serialized notifier, sto…
alexeyzimarev Aug 7, 2026
6fdfc4a
polish: main window status area, action visibility, grid density
alexeyzimarev Aug 7, 2026
07a26d4
polish: align section divider; worktree-aware repo labels
alexeyzimarev Aug 7, 2026
7bebbd6
fix: kick pause refresh from NeedsUpdate (macOS never raises Opening)…
alexeyzimarev Aug 7, 2026
7edeaa3
fix: subscribe the uptime ticker on the UI thread; off-thread Interva…
alexeyzimarev Aug 7, 2026
a26dfea
polish: brand tray/window icon from the product mark; friendly app ex…
alexeyzimarev Aug 7, 2026
e7a403c
feat: confirm-then-force stop for review participants; toast notifica…
alexeyzimarev Aug 7, 2026
5ba016d
polish: repo labels show the repo name only; ellipsis-trim grid cells
alexeyzimarev Aug 7, 2026
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
399 changes: 399 additions & 0 deletions docs/superpowers/plans/2026-08-06-ai1651-tray-agents-stop.md

Large diffs are not rendered by default.

480 changes: 480 additions & 0 deletions docs/superpowers/specs/2026-08-06-ai1651-tray-agents-stop-design.md

Large diffs are not rendered by default.

226 changes: 204 additions & 22 deletions src/Capacitor.App/App.axaml.cs

Large diffs are not rendered by default.

Binary file added src/Capacitor.App/Assets/kcap-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/Capacitor.App/Capacitor.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- This is an unbundled `dotnet run` executable, so macOS shows the binary name (not an
app-bundle display name) on dock hover — give it a human-friendly one. RootNamespace
stays Capacitor.App: only the output/assembly identity changes here. -->
<AssemblyName>Kurrent Capacitor</AssemblyName>
<RootNamespace>Capacitor.App</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Capacitor.Cli.Core\Capacitor.Cli.Core.csproj" />
Expand All @@ -14,6 +19,12 @@
<PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="DynamicData" />
</ItemGroup>
<ItemGroup>
<!-- .axaml files are auto-included as AvaloniaXaml by Avalonia's build props; plain
assets (the product-mark bitmap, spec §4) need an explicit AvaloniaResource entry
to ship inside the assembly's avares:// resource set. -->
<AvaloniaResource Include="Assets\kcap-icon.png" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Capacitor.App.Tests.Unit" />
</ItemGroup>
Expand Down
138 changes: 138 additions & 0 deletions src/Capacitor.App/Services/AgentActionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Collections.Immutable;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Capacitor.Cli.Core.LocalIpc;

namespace Capacitor.App.Services;

/// Shared by the tray menu and the main-window rows (spec §7: one code path) — the single place
/// that calls ILocalControlOps.StopAgentAsync and builds the open-in-web URL, so both surfaces
/// get identical in-flight gating, toast text, and link construction. No local cache mutation:
/// a stopped agent's disappearance comes only from the next daemon snapshot (spec §7).
public sealed class AgentActionService {
const string DaemonUnreachableReason = "daemon_unreachable";
const string UnreachableCopy = "The daemon is not reachable";

readonly ILocalControlOps _ops;
readonly IAppNotifier _notifier;
readonly IUrlOpener _opener;
readonly CancellationToken _shutdownToken;
readonly Func<string, Task<bool>> _confirmForceStop;

// ONE lock guards both the in-flight set and the latest server URL — both are cheap,
// occasional writes, never held across the async stop call itself.
readonly Lock _lock = new();
ImmutableHashSet<string> _inFlight = ImmutableHashSet<string>.Empty;
string? _serverUrl;

readonly BehaviorSubject<IReadOnlySet<string>> _stopsInFlight;

/// <param name="confirmForceStop">
/// The confirm-then-force seam for a protected kind (decision 5): invoked with the agent's
/// label, resolves true to proceed with force:true, false to no-op. The composed delegate is
/// UI glue (a dialog Window) — this service only awaits it, never marshals to the UI thread
/// itself; that is the caller's job (App.axaml.cs, via Dispatcher.UIThread.InvokeAsync).
/// </param>
public AgentActionService(
ILocalControlOps ops, IAppNotifier notifier, IUrlOpener opener,
IObservable<DaemonStatusDto> snapshots, CancellationToken shutdownToken,
Func<string, Task<bool>> confirmForceStop) {
_ops = ops;
_notifier = notifier;
_opener = opener;
_shutdownToken = shutdownToken;
_confirmForceStop = confirmForceStop;
_stopsInFlight = new BehaviorSubject<IReadOnlySet<string>>(_inFlight);

// Held for the service's lifetime, same as TrayViewModel's constructor-scoped
// subscriptions — this service is a singleton for the app's lifetime, never disposed
// mid-run, so there is no unsubscribe seam to wire up.
snapshots.Subscribe(s => { lock (_lock) _serverUrl = s.Daemon.ServerUrl; });
}

/// Replay-1, starts empty. Consumed by TrayViewModel (and later the main-window grid) to
/// disable a Stop control while its op is pending (spec §7).
public IObservable<IReadOnlySet<string>> StopsInFlight => _stopsInFlight.AsObservable();

/// A kind other than exactly "agent" is protected (KindText vocabulary: agent|review|
/// review-flow). Any non-"agent" value — including one this build doesn't recognise — fails
/// safe as protected, mirroring the daemon's own `Kind != LaunchKind.Default` check and the
/// CLI's `IsProtectedKind`.
internal static bool IsProtectedKind(string kind) => kind is not "agent";

/// Per-id gating: a second Stop for the same id no-ops while one is pending — including while
/// a protected kind's confirm-then-force dialog is still open, since the id stays in-flight
/// for the whole RunStopAsync call — different ids run concurrently (spec §7, decision 5).
/// Never throws — this is a UI command target, not a Task the caller awaits.
public void RequestStop(string agentId, string label, string kind) {
lock (_lock) {
if (_inFlight.Contains(agentId)) return;
_inFlight = _inFlight.Add(agentId);
_stopsInFlight.OnNext(_inFlight);
}
_ = Task.Run(() => RunStopAsync(agentId, label, kind));
}

async Task RunStopAsync(string agentId, string label, string kind) {
try {
var force = false;
if (IsProtectedKind(kind)) {
// false (dialog cancelled) is a quiet no-op — the finally below still clears the
// in-flight entry, but nothing is sent to the daemon and no toast is shown.
if (!await _confirmForceStop(label).ConfigureAwait(false)) return;
force = true;
}

var result = await _ops.StopAgentAsync(agentId, force, _shutdownToken).ConfigureAwait(false);
switch (result.Status) {
case "stopped": break; // no toast — disappearance from the next snapshot is the confirmation
case "failed": _notifier.Notify($"Couldn't stop {label}"); break;
case "skipped": _notifier.Notify($"The daemon declined to stop {label}"); break;
case "error":
// The daemon's Error text may name an id or CLI-speak ("Pass --force…") the
// app cannot act on (spec §7) — never surfaced verbatim in the UI. The full
// text goes to stderr only; the toast stays generic. With force-after-confirm
// above, a legitimate protected-refusal Error should no longer occur — this
// now covers unknown-id/stale cases.
Console.Error.WriteLine($"kcap: stop {agentId} failed: {result.Error}");
_notifier.Notify($"Couldn't stop {label}");
break;
}
} catch (OperationCanceledException) {
// Deliberate shutdown: absorbed quietly, no toast, no log.
} catch (LocalControlOpsException ex) {
_notifier.Notify(ex.Reason == DaemonUnreachableReason ? UnreachableCopy : $"Couldn't stop {label}: {ex.Message}");
} catch (Exception ex) {
// An unmapped exception still gets a toast (never a silent drop) — AppNotifier.Notify
// covers both the toast AND stderr (spec §11), so this one call satisfies both
// without a separate Console.Error write. The finally below still runs either way.
_notifier.Notify($"Couldn't stop {label}: {ex.Message}");
} finally {
// A completion into an already-vanished row/entry is naturally a no-op — the set
// just loses a member nothing is rendering against anymore.
lock (_lock) {
_inFlight = _inFlight.Remove(agentId);
_stopsInFlight.OnNext(_inFlight);
}
}
}

/// Opens {ServerUrl trimmed of trailing '/'}/agents/{Uri.EscapeDataString(id)} from the
/// latest snapshot in the default browser (spec §7). Never throws.
public void OpenInWeb(string agentId) {
string? serverUrl;
lock (_lock) serverUrl = _serverUrl;

if (serverUrl is null) {
_notifier.Notify("Not connected to a daemon yet"); // cannot happen from live UI; defensive only
return;
}

var url = $"{serverUrl.TrimEnd('/')}/agents/{Uri.EscapeDataString(agentId)}";
try {
_opener.Open(url);
} catch (Exception ex) {
_notifier.Notify($"Couldn't open the browser: {ex.Message}");
}
}
}
31 changes: 31 additions & 0 deletions src/Capacitor.App/Services/AppNotifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;

namespace Capacitor.App.Services;

public interface IAppNotifier {
IObservable<string> Messages { get; }
void Notify(string message);
}

/// Replay-0: a message emitted before a subscriber attaches is lost to the UI — the accepted
/// missed-banner-while-hidden limitation (spec §11). Notify ALSO writes to Console.Error, which
/// is the only channel that survives a hidden main window (spec §11).
public sealed class AppNotifier : IAppNotifier {
readonly Subject<string> _messages = new();

// Producers are concurrent Task.Run bodies (AgentActionService's per-agent stops, pause ops,
// etc.) — Rx's grammar requires OnNext calls to a single Subject be serialized (never
// overlapping), and a bare Subject does not do this itself. ONE lock around both effects also
// keeps the stderr line and the pushed message in the same relative order across threads.
readonly Lock _lock = new();

public IObservable<string> Messages => _messages.AsObservable();

public void Notify(string message) {
lock (_lock) {
Console.Error.WriteLine($"kcap: {message}");
_messages.OnNext(message);
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
12 changes: 12 additions & 0 deletions src/Capacitor.App/Services/IPauseController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Capacitor.App.Services;

public sealed record PauseState(bool Checked, bool Verified, bool Busy);

/// Implemented in a later task; this task consumes only the contract. State is replay-1,
/// seeded with (Checked: false, Verified: false, Busy: false) — unverified until the first
/// successful refresh.
public interface IPauseController {
IObservable<PauseState> State { get; }
void RequestRefresh(); // passive; DROPPED while the lane is busy (spec §6)
void RequestToggle(bool desired); // desired checked value, single-flight + one queued slot (spec §6)
}
46 changes: 46 additions & 0 deletions src/Capacitor.App/Services/MainWindowCoordinator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Capacitor.App.Views;

namespace Capacitor.App.Services;

/// Owns the app's single main window across hide-to-tray cycles (spec §9). Closing the window
/// hides it — the app keeps living in the tray — and the tray's "Open Kurrent Capacitor" re-shows
/// that same instance. A window is only ever really closed during a quit, and a later
/// ShowMainWindow then builds a fresh one from the factory: Avalonia refuses to Show() a closed
/// window, and nothing is lost because no state lives in the view (everything displayed is
/// projected from the live service).
public sealed class MainWindowCoordinator(Func<MainWindow> windowFactory) {
MainWindow? _window;

/// Set by App.OnShutdownRequested's FIRST (deferring) pass, so the second pass's real window
/// teardown is never cancelled by the interception below.
public bool QuitInProgress { get; set; }

/// The tracked window — live (visible or hidden) from the first ShowMainWindow until a real
/// close. Null outside that window; App reads it once to assign desktop.MainWindow.
public MainWindow? Window => _window;

public void ShowMainWindow() {
if (_window is null) {
var window = windowFactory(); // the factory shows it (App.BuildAndShowMainWindow)
window.CloseInterceptor = OnWindowClosing;
// Only a REAL close reaches this (an intercepted one is cancelled before Closed) —
// the identity check keeps a late event from a superseded window from clearing a
// newer one.
window.Closed += (_, _) => {
if (ReferenceEquals(_window, window)) _window = null;
};
_window = window;
}

_window.Show(); // no-op when already visible
_window.Activate(); // bring it forward when it was merely behind other windows
}

/// Called from MainWindow.Closing. True → the close must be cancelled; the window is hidden
/// here instead.
public bool OnWindowClosing() {
if (QuitInProgress) return false;
_window?.Hide();
return true;
}
}
Loading
Loading