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
26 changes: 21 additions & 5 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ public partial class App : Application {
// subscribes to both _remoteAgents and serverLane, so it goes first.
RemoteAgentsService? _remoteAgents;
AgentDirectory? _directory;
ServerVendorModelCatalog? _modelCatalog;
TrayViewModel? _trayVm;
TrayIconManager? _tray;
// No disposal needed — RefCount tears its Interval down with its last subscriber, and every
Expand Down Expand Up @@ -531,6 +532,12 @@ void BuildDaemonGraph(
_remoteAgents = remoteAgents;
_directory = directory;

// The launcher's model dropdown, fetched once from the server (same catalog the web UI
// uses). Best-effort: a miss leaves the curated per-vendor fallback in place.
var modelCatalog = new ServerVendorModelCatalog(ServerVendorModelCatalog.HttpFetch(sessionHttp, profiles));
_modelCatalog = modelCatalog;
_ = modelCatalog.LoadAsync(_shutdown.Token);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

// After the directory, which feeds it the session→agent map: a server-lane item names a
// session, and only that map turns it into the agent whose card it belongs on.
var readDetail = ServerSessionHttp.DetailReader(sessionHttp, profiles);
Expand Down Expand Up @@ -572,8 +579,13 @@ void BuildDaemonGraph(
ILaunchClient launch = serverLane;
_serverClients = serverClients;
// The single restart trigger for a completed sign-in — RestartAsync serializes rather
// than coalesces, so RefreshAfterReauthAsync deliberately does not also await it.
serverClients.SignInCompleted.Subscribe(signedIn => { _ = serverLane.RestartAsync(); });
// than coalesces, so RefreshAfterReauthAsync deliberately does not also await it. The model
// catalog reloads here too: the endpoint needs auth, so a signed-out start left it empty and
// only a successful sign-in can fill it.
serverClients.SignInCompleted.Subscribe(signedIn => {
_ = serverLane.RestartAsync();
_ = modelCatalog.LoadAsync(_shutdown.Token);
});

// One attach client per attempt, dialed at the daemon's own control socket; 80x24 is a
// placeholder only — TerminalControl resizes its model to the real pane the moment it is
Expand Down Expand Up @@ -611,7 +623,8 @@ void BuildDaemonGraph(
originOf: id => directory.Rows.Lookup($"local:{id}").HasValue ? AgentOrigin.Local
: directory.Rows.Lookup($"remote:{id}").HasValue ? AgentOrigin.Remote
: null,
remoteWorkspaceFactory: BuildRemote),
remoteWorkspaceFactory: BuildRemote,
modelCatalog: modelCatalog.Catalog),
// Both close paths release the workspace: hide-to-tray keeps the window (and its
// attach) alive, a real close discards the window the next Show() would rebuild.
releaseWorkspace: window => (window.DataContext as MainWindowViewModel)?.CloseWorkspace());
Expand Down Expand Up @@ -1078,7 +1091,8 @@ internal static MainWindow BuildAndShowMainWindow(
IServerLane? lane = null, Func<CancellationToken, Task<string?>>? viewerId = null,
string? localMachineId = null, IObservable<bool>? restartPending = null,
Func<string, AgentOrigin?>? originOf = null,
Func<string, RemoteSessionViewModel?>? remoteWorkspaceFactory = null) {
Func<string, RemoteSessionViewModel?>? remoteWorkspaceFactory = null,
IObservable<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>>? modelCatalog = null) {
// Notifier is set on the WINDOW (spec §11 toast overlay), not the ViewModel — the toast
// is a View-level concern (WindowNotificationManager lives on MainWindow) independent of
// the VM's WhenActivated-scoped projections.
Expand Down Expand Up @@ -1108,7 +1122,8 @@ internal static MainWindow BuildAndShowMainWindow(
openSessionIfCurrent: (agentId, generation) => vm?.OpenSessionIfCurrent(agentId, generation),
requestSignIn: requestSignIn,
daemons: remoteAgents?.Daemons, viewerId: viewerId, laneStatus: lane?.Status,
localMachineId: localMachineId, launchFailures: lane?.LaunchFailures, directory: resolvedDirectory);
localMachineId: localMachineId, launchFailures: lane?.LaunchFailures, directory: resolvedDirectory,
modelCatalog: modelCatalog);
// Same knot as home above, over the SAME `service` instance — its own openSession
// callback closes over `vm`, not a local, so no two-step forward-declaration is needed.
// Both rail actions route through the one call, each naming the lane of the row that was
Expand Down Expand Up @@ -1671,6 +1686,7 @@ async ValueTask DisposeLifecycleAndServiceAsync() {
async ValueTask DisposeServerClientsAsync() {
_directory?.Dispose();
_remoteAgents?.Dispose();
_modelCatalog?.Dispose();
if (_serverClients is null) return;
await _serverClients.DisposeAsync().ConfigureAwait(false);
}
Expand Down
85 changes: 85 additions & 0 deletions src/Capacitor.App/Services/ServerVendorModelCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Collections.Frozen;
using System.Net.Http.Json;
using System.Reactive.Subjects;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
using Capacitor.Remote.Models;

namespace Capacitor.App.Services;

/// The per-vendor model catalog the server offers for launches, fetched once from
/// GET api/agents/model-options — the same source the web UI's model dropdown reads. A failed or
/// signed-out fetch leaves the last snapshot (empty until the first success), and the launcher
/// falls back to its curated per-vendor list, so a launch is never blocked on this.
public sealed class ServerVendorModelCatalog : IDisposable {
public static readonly IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>> Empty =
FrozenDictionary<string, IReadOnlyList<ModelChoice>>.Empty;

readonly Func<CancellationToken, Task<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>?>> _fetch;
readonly BehaviorSubject<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>> _catalog = new(Empty);
// Guards the generation counter AND the post-fetch check+publish as one critical section, so a
// newer load can never slip its publish between an older load's check and its OnNext.
readonly Lock _gate = new();
int _generation;

public ServerVendorModelCatalog(
Func<CancellationToken, Task<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>?>> fetch) =>
_fetch = fetch;

public IObservable<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>> Catalog => _catalog;

/// Fetches and publishes on success; a null result (offline / signed out / non-success) leaves
/// the current snapshot untouched. Cancellation (shutdown) is silent; an unexpected fault is
/// logged rather than swallowed, but never propagates — the launcher's curated fallback covers a
/// miss either way. Safe to call more than once concurrently (startup plus a reload per sign-in):
/// only the newest-started load publishes, so a slow earlier fetch cannot overwrite a newer
/// snapshot with a stale one.
public async Task LoadAsync(CancellationToken ct = default) {
int generation;
lock (_gate) generation = ++_generation;
try {
if (await _fetch(ct).ConfigureAwait(false) is { } catalog)
lock (_gate)
if (_generation == generation) _catalog.OnNext(catalog);
} catch (OperationCanceledException) {
// Shutdown or a superseded reload — not a failure.
} catch (Exception ex) {
// Unexpected (e.g. a deserialization/mapping defect): keep the curated fallback, but do
// not fail silently — this is the one place such a bug would otherwise be invisible.
Console.Error.WriteLine($"kcap app: vendor model catalog load failed: {ex}");
}
}

public void Dispose() => _catalog.Dispose();

/// Builds the HTTP fetch over an authenticated client, mapping the server's {value,label} to
/// ModelChoice. Returns null for an EXPECTED miss — signed out, offline, or a non-success status.
/// An UNEXPECTED fault (a bad payload / mapping bug) propagates to LoadAsync, which logs it rather
/// than letting a real defect hide behind the curated fallback.
public static Func<CancellationToken, Task<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>?>>
HttpFetch(ICapacitorHttpClient? http, ProfileContext? profiles) => async ct => {
var serverUrl = profiles?.Resolution.ServerUrl;
if (http is null || profiles is null || string.IsNullOrEmpty(serverUrl)) return null;
try {
var (client, status, _, _) = await http.ForWaitAsync(ct).ConfigureAwait(false);
using (client) {
if (status is not (AuthStatus.Ok or AuthStatus.NoAuthRequired)) return null;
var url = $"{serverUrl.TrimEnd('/')}/{ApiRoutes.ModelOptions}";
using var response = await client.GetAsync(url, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return null;
var raw = await response.Content.ReadFromJsonAsync(
RemoteModelsJsonContext.Default.DictionaryStringVendorModelOptionDtoArray, ct).ConfigureAwait(false);
return raw is null ? null : Map(raw);
}
} catch (HttpRequestException) {
return null; // offline / transport — an expected miss
}
};

static IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>> Map(Dictionary<string, VendorModelOptionDto[]> raw) =>
raw.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<ModelChoice>)[.. kv.Value.Select(o => new ModelChoice(o.Value, o.Label))],
StringComparer.OrdinalIgnoreCase);
}
25 changes: 24 additions & 1 deletion src/Capacitor.App/ViewModels/HomeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ public string SelectedModel {
set => this.RaiseAndSetIfChanged(ref _selectedModel, value);
}

/// The server's model catalog snapshot (empty until fetched); the agent chip resolves a
/// selected model's label against it.
public IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>> ModelCatalog => _modelCatalog;

/// The model choices to offer for a vendor: the server catalog when it has any, else the
/// curated fallback. So a launch is never blocked by an empty or unreachable catalog.
public IReadOnlyList<ModelChoice> ModelChoicesFor(string vendor) =>
_modelCatalog.TryGetValue(vendor, out var models) && models.Count > 0
? models
: HostedHarnessCatalog.ModelChoicesFor(vendor);

string? _selectedEffort;
/// null = vendor default. Survives vendor changes — the effort vocabulary is shared enough
/// (low/medium/high/xhigh) that the choice usually still means what the user meant.
Expand Down Expand Up @@ -261,6 +272,10 @@ public bool RemoteMachineSelected {
static readonly TimeSpan RecentFailureTtl = TimeSpan.FromSeconds(30);
readonly IAgentDirectory? _directory;

/// The server's per-vendor model catalog (empty until the first fetch lands). The launcher's
/// model picker prefers it and falls back to HostedHarnessCatalog's curated list per vendor.
IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>> _modelCatalog = ServerVendorModelCatalog.Empty;

/// knownRepos is RepoPathStore.GetSortedPathsAsync in production — the same persisted list
/// DaemonConnect.RepoPaths feeds the server's launch dialog. Required (no defaulted overload)
/// so a test can never silently read the developer's own ~/.config/kcap/repos.json.
Expand Down Expand Up @@ -301,7 +316,8 @@ public HomeViewModel(
IObservable<IReadOnlyList<DaemonInfo>>? daemons = null,
Func<CancellationToken, Task<string?>>? viewerId = null,
IObservable<ServerLaneStatus>? laneStatus = null, string? localMachineId = null,
IObservable<LaunchFailure>? launchFailures = null, IAgentDirectory? directory = null) {
IObservable<LaunchFailure>? launchFailures = null, IAgentDirectory? directory = null,
IObservable<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>>? modelCatalog = null) {
_daemon = daemon;
_state = state;
_launch = launch;
Expand Down Expand Up @@ -334,6 +350,13 @@ public HomeViewModel(
.ToProperty(this, x => x.Harnesses, HostedHarnessCatalog.Build(null))
.DisposeWith(_disposables);

// The server model catalog arrives asynchronously; a change re-renders the agent chip and
// the next flyout open reads the new list (RebuildRows runs per open).
(modelCatalog ?? Observable.Return(ServerVendorModelCatalog.Empty))
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(catalog => { _modelCatalog = catalog; this.RaisePropertyChanged(nameof(ModelCatalog)); })
.DisposeWith(_disposables);

// A throw from viewerId (e.g. a claims-file read fault) is a missed visibility recompute,
// never a fault that kills this OAPH's subscription forever (RemoteAgentsService's
// identical philosophy for its own daemons refresh).
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.App/Views/LauncherPaneView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
<Binding Path="Harnesses" />
<Binding Path="SelectedVendor" />
<Binding Path="SelectedModel" />
<Binding Path="ModelCatalog" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
Expand Down
22 changes: 16 additions & 6 deletions src/Capacitor.App/Views/LauncherPaneView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ void AddVendorRows(HarnessOption option, Func<string, bool> matches, bool includ
rows.Children.Add(Row(
vendor, option.Label, $"Default — {option.Label} chooses", option.Available,
isCurrentVendor && vm.SelectedModel.Length == 0, () => Pick(vendor, "")));
foreach (var model in HostedHarnessCatalog.ModelChoicesFor(vendor)
foreach (var model in vm.ModelChoicesFor(vendor)
.Where(m => matches(m.Label) || matches(m.Slug)))
rows.Children.Add(Row(
vendor, option.Label, model.Label, option.Available,
Expand Down Expand Up @@ -440,7 +440,7 @@ void RebuildRows() {
}

// The escape hatch: whatever was typed, offered verbatim for the active vendor tab.
if (!vm.Harnesses.SelectMany(o => HostedHarnessCatalog.ModelChoicesFor(o.Vendor))
if (!vm.Harnesses.SelectMany(o => vm.ModelChoicesFor(o.Vendor))
.Any(m => string.Equals(m.Slug, term, StringComparison.OrdinalIgnoreCase))) {
var tabOption = vm.Harnesses.FirstOrDefault(
o => string.Equals(o.Vendor, currentTab, StringComparison.OrdinalIgnoreCase));
Expand Down Expand Up @@ -499,15 +499,25 @@ public object ConvertBack(object? value, Type targetType, object? parameter, Cul
throw new NotSupportedException();
}

/// AgentChip's label: "Claude · Fable 5" — vendor label plus the model's curated label (raw slug
/// when uncurated, "Default" for the "" sentinel). Same "left · right" shape as Effort/Permissions.
/// AgentChip's label: "Claude · Fable 5" — vendor label plus the model's label, resolved first
/// against the server catalog (4th binding), then the curated fallback (raw slug when neither
/// carries it, "Default" for the "" sentinel). Same "left · right" shape as Effort/Permissions.
public sealed class AgentChipTextConverter : IMultiValueConverter {
public static readonly AgentChipTextConverter Instance = new();

public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture) =>
values is [IReadOnlyList<HarnessOption> options, string vendor, string model]
? $"{HostedHarnessCatalog.LabelFor(options, vendor)} · {HostedHarnessCatalog.ModelLabelFor(vendor, model)}"
values is [IReadOnlyList<HarnessOption> options, string vendor, string model, ..]
? $"{HostedHarnessCatalog.LabelFor(options, vendor)} · {ModelLabel(vendor, model, values.Count > 3 ? values[3] : null)}"
: "";

static string ModelLabel(string vendor, string model, object? catalog) {
if (!string.IsNullOrWhiteSpace(model)
&& catalog is IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>> map
&& map.TryGetValue(vendor, out var models)
&& models.FirstOrDefault(m => string.Equals(m.Slug, model, StringComparison.OrdinalIgnoreCase)) is { } hit)
return hit.Label;
return HostedHarnessCatalog.ModelLabelFor(vendor, model);
}
}

/// EffortChip's label: always "Effort · …" — Default when null, otherwise the chosen rung's
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.Remote.Models/RemoteModelsJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ namespace Capacitor.Remote.Models;
[JsonSerializable(typeof(PermissionResponsePayload))]
[JsonSerializable(typeof(SessionDetailDto))]
[JsonSerializable(typeof(SessionEventDto))]
[JsonSerializable(typeof(Dictionary<string, VendorModelOptionDto[]>))]
public partial class RemoteModelsJsonContext : JsonSerializerContext;
1 change: 1 addition & 0 deletions src/Capacitor.Remote.Models/RemoteWire.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static class HubBroadcasts {
public static class ApiRoutes {
public const string AgentInstances = "api/agent-instances";
public const string Daemons = "api/daemons";
public const string ModelOptions = "api/agents/model-options";
public static string SessionDetail(string sessionId) =>
$"api/sessions/{Uri.EscapeDataString(sessionId)}/detail";
public static string PermissionResponse(string sessionId, string requestId) =>
Expand Down
10 changes: 10 additions & 0 deletions src/Capacitor.Remote.Models/VendorModelOptionDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Capacitor.Remote.Models;

/// One model the server offers for a vendor's launch dropdown, from GET api/agents/model-options.
/// Value is the exact model id carried on the launch wire; Label is the friendly display name.
public sealed record VendorModelOptionDto {
[JsonPropertyName("value")] public required string Value { get; init; }
[JsonPropertyName("label")] public required string Label { get; init; }
}
23 changes: 23 additions & 0 deletions test/Capacitor.App.Tests.Unit/HomeViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ static HomeViewModel Build(out RecordingLaunchClient launch, out AppStateStore s
return new HomeViewModel(daemon, store, launch, Known());
}

/// The launcher's model list prefers the server catalog per vendor and falls back to the
/// curated list where the server offers none — so an empty or unreachable catalog never blocks
/// a launch.
[Test]
[NotInParallel("AvaloniaSession")]
public async Task Model_choices_prefer_the_server_catalog_and_fall_back_to_the_curated_list() {
await AvaloniaSession.WithImmediateRxScheduler(async () => {
using var tmp = TempDir.WithPathTo("app-state.json", out var path);
var daemon = new FakeDaemonClientService();
Connect(daemon);
var catalog = new Dictionary<string, IReadOnlyList<ModelChoice>>(StringComparer.OrdinalIgnoreCase) {
["gemini"] = [new("gemini-3-pro", "Gemini 3 Pro")],
};
using var vm = new HomeViewModel(
daemon, new AppStateStore(path), new RecordingLaunchClient(), Known(),
modelCatalog: Observable.Return<IReadOnlyDictionary<string, IReadOnlyList<ModelChoice>>>(catalog));

await Assert.That(vm.ModelChoicesFor("gemini").Select(m => m.Slug)).Contains("gemini-3-pro");
await Assert.That(vm.ModelChoicesFor("claude").Select(m => m.Slug)).Contains("claude-opus-5");
await Assert.That(vm.ModelChoicesFor("cursor")).IsEmpty();
});
}

/// Repo keys compare the way the filesystem does — so the SAME repository reached under
/// different casing restores its harness on Windows/macOS, and stays distinct on Linux where
/// two such paths really are two repositories. Asserting the platform's own answer rather than
Expand Down
Loading