From b7011e2b55d28e04135379a0c11575931fe79345 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:25:17 -0400 Subject: [PATCH 1/9] app: Rework the setup Ready step and sign-in from the website A discovered workspace whose host 404s is now asked about at kcap-web before it is left out, so sign-in waits on one still provisioning instead of failing with no workspaces. Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.App/App.axaml.cs | 3 +- src/Capacitor.App/Services/AppStateStore.cs | 4 +- .../Services/Onboarding/WizardAuthBridges.cs | 5 + .../Services/Onboarding/WizardComposition.cs | 79 ++++++- .../Onboarding/BackgroundServiceState.cs | 4 + .../ViewModels/Onboarding/CaptureItem.cs | 4 +- .../ViewModels/Onboarding/DoneFacts.cs | 10 +- .../Onboarding/DoneStepViewModel.cs | 188 ++++++++++++----- .../Onboarding/EditionStepViewModel.cs | 7 +- .../Onboarding/OnboardingViewModel.cs | 41 ++-- .../Onboarding/SignInStepViewModel.cs | 28 +++ .../ViewModels/Onboarding/WizardStep.cs | 4 + .../Views/Onboarding/DonePaneView.axaml | 197 +++++++++++------- .../Views/Onboarding/OnboardingPaneView.axaml | 28 +-- .../Views/Onboarding/OnboardingStyles.axaml | 2 + .../DoneStepViewModelTests.cs | 157 +++++++++++--- .../OnboardingAccessibilityTests.cs | 9 +- .../OnboardingViewModelTests.cs | 19 +- .../SignInStepViewModelTests.cs | 47 +++++ .../WizardCompositionCloudSignUpTests.cs | 76 +++++++ .../WizardSimpleStepsTests.cs | 16 +- .../WizardStartupTests.cs | 87 ++------ 22 files changed, 709 insertions(+), 306 deletions(-) create mode 100644 src/Capacitor.App/ViewModels/Onboarding/BackgroundServiceState.cs diff --git a/src/Capacitor.App/App.axaml.cs b/src/Capacitor.App/App.axaml.cs index 6c1a9a4559..d64feb8ee8 100644 --- a/src/Capacitor.App/App.axaml.cs +++ b/src/Capacitor.App/App.axaml.cs @@ -1237,7 +1237,8 @@ async Task RunWizardModeAsync( ConfiguredDaemonName: () => ResolveConfiguredDaemonName(_config, _serverEnv, _localRoot), HeldDaemonNames: HeldDaemonNamesAsync, ProfileOverride: _serverEnv.Profile, - UsageNotice: _usageNotice); + UsageNotice: _usageNotice, + ResumeWebSignUp: WizardComposition.ResumesWebSignUp(await _appState.LoadAsync(), _time.GetUtcNow())); _localInstall ??= WizardComposition.NewInstallController(options); var graph = WizardComposition.BuildGraph(options with { Install = _localInstall }); diff --git a/src/Capacitor.App/Services/AppStateStore.cs b/src/Capacitor.App/Services/AppStateStore.cs index 0e6c9f55b7..1f7689d0e7 100644 --- a/src/Capacitor.App/Services/AppStateStore.cs +++ b/src/Capacitor.App/Services/AppStateStore.cs @@ -20,7 +20,9 @@ public sealed record AppState( int? WindowX = null, int? WindowY = null, bool WindowMaximized = false, - double? UiScale = null); + double? UiScale = null, + // When setup last sent the user to kcap.ai's sign-up; cleared once a sign-in commits. + DateTimeOffset? WebSignUpOpenedAt = null); public interface IAppStateStore { Task LoadAsync(); diff --git a/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs b/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs index 073dc5f36b..8ad3414c4e 100644 --- a/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs +++ b/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs @@ -16,6 +16,9 @@ public sealed class UiAuthProgress(Action post) : IAuthProgress { public event Action? DeviceCodeReceived; public event Action? PollTicked; + /// A discovered workspace is still being created; sign-in waits for it rather than leaving it out. + public event Action? WorkspaceProvisioning; + public void Notice(string message) => post(() => NoticeReceived?.Invoke(message)); public void Error(string message) => post(() => ErrorReceived?.Invoke(message)); @@ -28,6 +31,8 @@ public void DeviceCode(string code, string verificationUri, string? provider, bo post(() => DeviceCodeReceived?.Invoke(code, verificationUri, prefilled)); public void PollTick() => post(() => PollTicked?.Invoke()); + + public void ProvisioningWait(string workspace) => post(() => WorkspaceProvisioning?.Invoke(workspace)); } /// diff --git a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs index 16cde05946..b916bd90c6 100644 --- a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs +++ b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs @@ -94,11 +94,21 @@ internal sealed record WizardGraphOptions( // The app's one install controller, shared by every wizard it shows; null builds a graph-local one. LocalInstallController? Install = null, // Null, or already acknowledged, leaves the Privacy page out of the run. - UsageDataNotice? UsageNotice = null); + UsageDataNotice? UsageNotice = null, + // This app sent the user to kcap.ai's sign-up and no sign-in has committed since. + bool ResumeWebSignUp = false); /// The wizard half of the composition root, split out of App so it can be driven /// with fakes: nothing here touches a daemon, a socket or the network until a step is used. internal static class WizardComposition { + /// How long after setup opened kcap.ai's sign-up a relaunch still opens on sign-in. + internal static readonly TimeSpan WebSignUpResumeWindow = TimeSpan.FromDays(1); + + /// A sign-up this app opened in the browser, recent enough that a relaunch is the user coming back + /// with a workspace to sign in to. + internal static bool ResumesWebSignUp(AppState state, DateTimeOffset now) => + state.WebSignUpOpenedAt is { } opened && now >= opened && now - opened <= WebSignUpResumeWindow; + /// Production bridges: one marshalling boundary, Avalonia's dispatcher in the app. internal static WizardBridges BuildBridges(Action post, CliTelemetry telemetry, AuthEndpoints endpoints) => new(post, telemetry, endpoints); @@ -124,17 +134,53 @@ internal static Func> BuildOp operation(new WizardFacadeSpec( root, tokenStore, httpFactory, proxy, github, workos, profile, bridges.Progress, bridges.Picker, bridges.Telemetry, bridges.Endpoints, time, - WizardAuthService.ArmingHook(claims), ProbeWorkspace(httpFactory, time), precondition)); + WizardAuthService.ArmingHook(claims), + ProbeWorkspace(httpFactory, bridges.Endpoints, bridges.Progress.ProvisioningWait, time), precondition)); + + internal static readonly TimeSpan ProvisioningPollInterval = TimeSpan.FromSeconds(5); + + /// How long sign-in waits on a workspace kcap.ai is still creating, the same as the browser sign-up's own wait. + internal static readonly TimeSpan ProvisioningWaitLimit = SignInStepViewModel.SignUpWait; + /// /// kcap-web adds the owner to a workspace's organization before the workspace is live, so discovery - /// can list one still provisioning. Its host 404s until then, and discovery leaves it out. - static Func> ProbeWorkspace( - IHttpClientFactory httpFactory, TimeProvider time) => - async (tenant, _, ct) => { + /// can list one still provisioning, whose host 404s until then. kcap-web's own status for it tells + /// the two apart: a workspace still being created is waited for, so sign-in finishes on its own once + /// it is live, and only one that is gone or failed is left out. + /// + internal static Func> ProbeWorkspace( + IHttpClientFactory httpFactory, AuthEndpoints endpoints, Action waiting, TimeProvider time) { + return async (tenant, bearer, ct) => { + var answer = await AskAsync(tenant, ct); + if (answer != WorkspaceAnswer.Gone || tenant.Slug is not { Length: > 0 } slug) return answer; + + var provisioning = new TenantProvisioningClient( + httpFactory.CreateClient(nameof(TenantProvisioningClient)), ClientAgent.Desktop); + var status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct); + if (!ProvisioningPoll.IsPending(status.StatusCode, status.Body?.State)) return answer; + + waiting(tenant.Label); + var deadline = time.GetUtcNow() + ProvisioningWaitLimit; + while (time.GetUtcNow() < deadline) { + await Task.Delay(ProvisioningPollInterval, time, ct); + answer = await AskAsync(tenant, ct); + if (answer == WorkspaceAnswer.Live) return answer; + + // The bearer outlives its few minutes only as a 401, which reads as still waiting. + status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct); + if (ProvisioningPoll.Classify(status.StatusCode, status.Body?.State, status.Body?.WorkosOrgId) + is PollVerdict.Failed or PollVerdict.NotFound or PollVerdict.Forbidden) return WorkspaceAnswer.Gone; + } + + return WorkspaceAnswer.Gone; + }; + + async Task AskAsync(DiscoveredTenant tenant, CancellationToken ct) { using var client = httpFactory.CreateClient(CapacitorClients.Anonymous); return await WorkspaceProbe.AskAsync(client, tenant.Origin, time, ct); - }; + } + } internal static LocalInstallController NewInstallController(WizardGraphOptions options) { var cli = new LateBoundKcapCli(options.ResolveCli, options.CliPath); @@ -246,6 +292,10 @@ void OnInstallSettled(LocalInstallOutcome outcome) { install.Settled += OnInstallSettled; if (committedLocalAtStart) _ = daemon.EnsureAsync(options.ShutdownToken); + OnboardingViewModel? shell = null; + // The daemon and its outcome lines change while Done is open; the page re-reads its facts on each. + var serviceChanged = daemon.WhenAnyValue(x => x.Satisfied, x => x.Busy, x => x.Status, x => x.Message, (_, _, _, _) => Unit.Default) + .Merge(options.Surface.WhenAnyValue(x => x.StatusText, x => x.AttentionText, (_, _) => Unit.Default)); // Read on every entry, so a Back-then-forward re-render sees each step's current state. var done = new DoneStepViewModel(() => { var recording = harnesses.Rows.Where(r => r is { Record: true, Succeeded: true }).ToList(); @@ -261,8 +311,13 @@ [.. recording.Select(r => r.Label)], !local && Connected() ? options.ResolveIdentity()?.Server : null, daemon.Problem, local, - local ? LocalEndpoints.Read(root).Active?.ServerUrl : null); - }, options.UrlOpener); + local ? LocalEndpoints.Read(root).Active?.ServerUrl : null, + daemon.Busy, + options.Surface.AttentionText ?? options.Surface.StatusText); + }, options.UrlOpener, serviceChanged, + retryService: () => daemon.EnsureAsync(options.ShutdownToken), + // The same page Help and support → Set up kcap leads through to connect an agent. + addAgent: () => shell?.TryGoTo(WizardStepId.Harnesses)); IWizardStep[] steps = [edition, installStep, signIn, harnesses, history, new UsageDataStepViewModel(options.UsageNotice), done]; // Before a server is connected the app has nothing to show, so deferring is offered only for the optional steps after. @@ -270,8 +325,10 @@ [.. recording.Select(r => r.Label)], .Merge(install.WhenAnyValue(x => x.Outcome).Select(_ => Unit.Default)) .Merge(durable.Select(_ => Unit.Default)) .Select(_ => Connected()); - var wizard = new OnboardingViewModel( - steps, options.ShutdownToken, options.Surface, canFinishLater: canFinishLater, restate: durable.Select(_ => Unit.Default)); + // Someone coming back from kcap.ai's sign-up already has a workspace; everyone else chooses first. + var startAt = options.ResumeWebSignUp && !signedInAtStart && !committedLocalAtStart ? WizardStepId.SignIn : (WizardStepId?)null; + var wizard = shell = new OnboardingViewModel( + steps, options.ShutdownToken, options.Surface, startAt, canFinishLater, durable.Select(_ => Unit.Default)); signIn.SignUpExpired += () => { edition.ShowNotice(SignInStepViewModel.SignUpExpiredNotice); wizard.TryGoTo(WizardStepId.Edition); diff --git a/src/Capacitor.App/ViewModels/Onboarding/BackgroundServiceState.cs b/src/Capacitor.App/ViewModels/Onboarding/BackgroundServiceState.cs new file mode 100644 index 0000000000..28e0767f84 --- /dev/null +++ b/src/Capacitor.App/ViewModels/Onboarding/BackgroundServiceState.cs @@ -0,0 +1,4 @@ +namespace Capacitor.App.ViewModels.Onboarding; + +/// Where the daemon's service stands on the Ready page. +public enum BackgroundServiceState { Installing, Running, NotRunning } diff --git a/src/Capacitor.App/ViewModels/Onboarding/CaptureItem.cs b/src/Capacitor.App/ViewModels/Onboarding/CaptureItem.cs index d180141acb..0158d23058 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/CaptureItem.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/CaptureItem.cs @@ -1,7 +1,7 @@ namespace Capacitor.App.ViewModels.Onboarding; -/// One harness on the Done page's capture line: "turned on", or "needs you" while the login shell +/// One agent in the Done page's Connected agents card: connected, or "needs you" while the login shell /// cannot find kcap. public sealed record CaptureItem(string Label, bool NeedsYou) { - public string State => NeedsYou ? "needs you" : "turned on"; + public string State => NeedsYou ? "Needs you" : "Connected"; } diff --git a/src/Capacitor.App/ViewModels/Onboarding/DoneFacts.cs b/src/Capacitor.App/ViewModels/Onboarding/DoneFacts.cs index f68098b792..ce4a97b12b 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/DoneFacts.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/DoneFacts.cs @@ -1,13 +1,15 @@ namespace Capacitor.App.ViewModels.Onboarding; /// What the earlier pages did, as the Done page reads it on every entry. -/// The harnesses whose capture installed. -/// The login shell still cannot find kcap, so hooks record nothing. +/// The agents whose hooks installed. +/// The login shell still cannot find kcap, so hooks store nothing. /// The history import, while it runs and after; null when none started. /// The workspace this machine signed in to, or null. /// Why the daemon is not running, or null while it is being set up. /// The machine runs its own local server, so the page names no terminal command. /// The local server's web dashboard, or null when unknown. +/// An attempt to start the service is in flight. +/// What the daemon's last outcome reported beyond the problem itself, or null. public sealed record DoneFacts( IReadOnlyList Recording, bool CodexRecording, @@ -18,6 +20,8 @@ public sealed record DoneFacts( string? WorkspaceUrl, string? DaemonProblem = null, bool Local = false, - string? DashboardUrl = null) { + string? DashboardUrl = null, + bool DaemonBusy = false, + string? ServiceDetail = null) { public static readonly DoneFacts Empty = new([], false, false, null, false, "this machine", null); } diff --git a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs index 4ebe7657a2..6e92a7e8da 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs @@ -8,28 +8,64 @@ namespace Capacitor.App.ViewModels.Onboarding; /// /// The last page: what this machine is set up to do, said by its outcome rather than as a checklist. -/// The headline follows what actually happened; while the history import runs, its figures fill in -/// here and the page can be left at any time — the import carries on in the main window. +/// The headline follows what actually happened and the eyebrow follows the background service; while +/// the history import runs, its figures fill in here and the page can be left at any time. /// public sealed class DoneStepViewModel : ReactiveObject, IWizardStep { + public const string ServiceTitle = "Background service"; + public const string FirstSessionLine = "Your next session will be the first one stored"; + public const string RestartNote = "Agents already open will start storing sessions the next time you launch them"; + + static readonly string ThisDevice = OperatingSystem.IsMacOS() ? "this Mac" : "this computer"; + readonly Func _facts; readonly IUrlOpener? _opener; + readonly Func? _retryService; + readonly Action? _addAgent; DoneFacts _current = DoneFacts.Empty; - - public DoneStepViewModel(Func facts, IUrlOpener? opener = null) { - _facts = facts; - _opener = opener; - - OpenWorkspaceCommand = ReactiveCommand.Create(OpenWorkspace); - OpenDashboardCommand = ReactiveCommand.Create(OpenDashboard); + bool _entered; + bool _detailsVisible; + + /// Ticks when the daemon or its outcome lines change, so the page re-reads its facts. + /// Runs the daemon enable again. + /// Takes the wizard back to the page that connects agents. + public DoneStepViewModel( + Func facts, IUrlOpener? opener = null, IObservable? restate = null, + Func? retryService = null, Action? addAgent = null) { + _facts = facts; + _opener = opener; + _retryService = retryService; + _addAgent = addAgent; + + OpenInBrowserCommand = ReactiveCommand.Create(OpenInBrowser); + RetryServiceCommand = ReactiveCommand.CreateFromTask(RetryServiceAsync); + ToggleDetailsCommand = ReactiveCommand.Create(() => { DetailsVisible = !DetailsVisible; }); + AddAgentCommand = ReactiveCommand.Create(() => _addAgent?.Invoke()); + + restate?.Subscribe(_ => { + if (_entered) Refresh(); + }); } public WizardStepId Id => WizardStepId.Done; - public string Eyebrow => "Ready"; public bool Applicable => true; public bool Satisfied => true; // a summary step is never itself incomplete - public string? NextLabel => _current.Local ? "Continue" : "Open kcap"; + public string? NextLabel => "Open kcap"; + + public BackgroundServiceState ServiceState => + _current.DaemonRunning ? BackgroundServiceState.Running + : !_current.DaemonBusy && _current.DaemonProblem is not null ? BackgroundServiceState.NotRunning + : BackgroundServiceState.Installing; + + public string Eyebrow => ServiceState switch { + BackgroundServiceState.Running => "Ready", + BackgroundServiceState.NotRunning => "One thing left", + _ => "Finishing up", + }; + + /// Setup can only be left unfinished while the service is not running; otherwise Open kcap closes it. + public bool OffersFinishLater => ServiceState == BackgroundServiceState.NotRunning; HistoryImportRun? Import => _current.Import; @@ -40,16 +76,15 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null) { public string Title => Imported ? "Your work so far" : CapturePromised ? "From now on, your agents remember" - : "No new recording connections"; + : "No new agents connected"; public string Lede => - _current.Local && Import is null && !CapturePromised ? "Setup hasn't added recording connections. You can connect more harnesses whenever you're ready." - : _current.Local && Import is null ? $"kcap keeps what your coding agents do on {_current.MachineName}, and gives them tools to search it back — so the next time you hit something, what you already worked out is there to find." - : Import is null && !CapturePromised ? "Setup hasn't added recording connections. Any connections you already had are unchanged. You can connect more harnesses whenever you're ready." - : Import is null ? $"kcap keeps what your coding agents do on {_current.MachineName}, and gives them tools to search it back — so the next time you hit something, what you already worked out is there to find." - : Import.Running ? "What has landed is already searchable." - : Import.Imported > 0 ? "Your agents can search all of it — ask one what you have already tried, and it looks here first." - : "No sessions were uploaded. Your history is still on this machine."; + _current.Local && Import is null && !CapturePromised ? "Setup didn't connect any agents. You can add one whenever you're ready" + : Import is null && !CapturePromised ? "Setup didn't connect any new agents, and any you connected before are unchanged. You can add one whenever you're ready" + : Import is null ? $"kcap stores what your coding agents do on {_current.MachineName} and lets them search it later. Next time you hit a problem, the fix you already found is stored in kcap" + : Import.Running ? "What has landed is already searchable" + : Import.Imported > 0 ? "Your agents can search all of it. Ask one what you have already tried, and it looks here first" + : "No sessions were uploaded. Your history is still on this machine"; public bool ShowsFigures => Import is not null; @@ -92,45 +127,89 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null) { : Import is { Failed: 1 } ? "1 session failed to upload" : $"{Import?.Failed ?? 0} sessions failed to upload"; public string FailedBody => _current.Local - ? $"Your history is still on {_current.MachineName}. Go back to History to retry." - : $"Your history is still on {_current.MachineName}. Go back to History to retry, or run kcap import later."; - - public bool NothingToCountVisible => Import is null && CapturePromised; - - public string? FooterLine => - _current.Local ? "You can connect more harnesses from Help and support → Set up kcap." - : _current.Recording.Count == 0 ? "Connect a harness from Help and support → Set up kcap." - : Import is null ? "kcap import brings your existing history over whenever you want." - : null; + ? $"Your history is still on {_current.MachineName}. Go back to History to retry" + : $"Your history is still on {_current.MachineName}. Go back to History to retry, or run kcap import later"; public IReadOnlyList Capture => [.. _current.Recording.Select(label => new CaptureItem(label, _current.PathHazard))]; public bool CaptureVisible => _current.Recording.Count > 0; + public bool NoAgentsVisible => _current.Recording.Count == 0; + + public bool FirstSessionVisible => Import is null && CapturePromised; + public bool PathHazardVisible => _current.PathHazard && _current.Recording.Count > 0; public bool CodexNoteVisible => _current.CodexRecording; - public string DaemonLine => _current.DaemonRunning - ? $"Running as a service on {_current.MachineName}. It is reachable now, and after a restart." - : _current.DaemonProblem is { } problem - ? $"{problem} The app tries again once setup closes." - : $"The daemon is being set up as a service on {_current.MachineName}. The app keeps it running from here."; + public bool AddAgentVisible => _addAgent is not null; - public bool WorkspaceVisible => !_current.Local && !string.IsNullOrEmpty(_current.WorkspaceUrl); + public string AddAgentLabel => _current.Recording.Count > 0 ? "Add another agent" : "Add an agent"; - public bool DashboardVisible => _current.Local && !string.IsNullOrEmpty(_current.DashboardUrl); + /// The CLI's own import is named only where the machine has a terminal command to offer. + public string? ImportHint => + !_current.Local && _current.Recording.Count > 0 && Import is null + ? "kcap import brings your existing history over whenever you want" + : null; - public ReactiveCommand OpenWorkspaceCommand { get; } + public string ServiceBody => + $"Keeps kcap storing sessions when the app is closed, and lets you start agents on {ThisDevice} from anywhere"; - public ReactiveCommand OpenDashboardCommand { get; } + public string ServicePill => ServiceState switch { + BackgroundServiceState.Running => "Running", + BackgroundServiceState.NotRunning => "Not running", + _ => "Installing…", + }; + + public bool ServiceRunning => ServiceState == BackgroundServiceState.Running; + + public bool ServiceInstalling => ServiceState == BackgroundServiceState.Installing; + + public bool ServiceFailed => ServiceState == BackgroundServiceState.NotRunning; + + public string ServiceProblemTitle => $"kcap couldn't start the background service on {_current.MachineName}"; + + /// The daemon's own account of what stopped it, behind See details. + public string ServiceDetails { + get { + var lines = new List(2); + if (_current.DaemonProblem is { Length: > 0 } problem) lines.Add(problem); + if (_current.ServiceDetail is { Length: > 0 } detail && !lines.Contains(detail)) lines.Add(detail); + + return string.Join(Environment.NewLine, lines); + } + } + + public bool RetryAvailable => _retryService is not null; + + public bool DetailsVisible { + get => _detailsVisible && ServiceFailed; + private set { + _detailsVisible = value; + this.RaisePropertyChanged(); + this.RaisePropertyChanged(nameof(DetailsLabel)); + } + } + + public string DetailsLabel => DetailsVisible ? "Hide details" : "See details"; + + /// The web workspace, or the local server's dashboard. + string? BrowserUrl => _current.Local ? _current.DashboardUrl : _current.WorkspaceUrl; + + public bool OpenInBrowserVisible => !string.IsNullOrEmpty(BrowserUrl); + + public ReactiveCommand OpenInBrowserCommand { get; } + + public ReactiveCommand RetryServiceCommand { get; } + + public ReactiveCommand ToggleDetailsCommand { get; } + + public ReactiveCommand AddAgentCommand { get; } /// Re-read on every entry: Back-then-forward can change what the earlier pages did. public Task OnEnterAsync(CancellationToken ct) { - if (_current.Import is INotifyPropertyChanged before) before.PropertyChanged -= OnImportChanged; - _current = _facts(); - if (_current.Import is INotifyPropertyChanged after) after.PropertyChanged += OnImportChanged; - Restate(); + _entered = true; + Refresh(); return Task.CompletedTask; } @@ -138,23 +217,34 @@ public Task OnEnterAsync(CancellationToken ct) { // Next finishes the wizard — the shell's own last-step handling raises CloseRequested. public Task CanLeaveAsync(WizardNavigation direction, CancellationToken ct) => Task.FromResult(true); + void Refresh() { + if (_current.Import is INotifyPropertyChanged before) before.PropertyChanged -= OnImportChanged; + _current = _facts(); + if (_current.Import is INotifyPropertyChanged after) after.PropertyChanged += OnImportChanged; + Restate(); + } + void OnImportChanged(object? sender, PropertyChangedEventArgs e) => Restate(); - void OpenWorkspace() { - if (_current.WorkspaceUrl is not { Length: > 0 } url || _opener is null) return; + async Task RetryServiceAsync() { + if (_retryService is null || !ServiceFailed) return; + + _detailsVisible = false; try { - _opener.Open(url); + await _retryService().ConfigureAwait(true); } catch (Exception ex) { - Console.Error.WriteLine($"kcap: could not open the workspace: {ex.Message}"); + Console.Error.WriteLine($"kcap: retrying the background service failed unexpectedly: {ex.Message}"); } + + Refresh(); } - void OpenDashboard() { - if (_current.DashboardUrl is not { Length: > 0 } url || _opener is null) return; + void OpenInBrowser() { + if (BrowserUrl is not { Length: > 0 } url || _opener is null) return; try { _opener.Open(url); } catch (Exception ex) { - Console.Error.WriteLine($"Could not open the dashboard: {ex.Message}"); + Console.Error.WriteLine($"kcap: could not open {url}: {ex.Message}"); } } diff --git a/src/Capacitor.App/ViewModels/Onboarding/EditionStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/EditionStepViewModel.cs index d4dafdde6d..9941ae9870 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/EditionStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/EditionStepViewModel.cs @@ -17,6 +17,9 @@ public sealed class EditionStepViewModel : ReactiveObject, IWizardStep { public const string CloudTitle = "For your team, cloud"; public const string CloudDetail = "A Team workspace on kcap.ai that your team shares. Sign in with your work account; Team is free for 60 days."; + public const string ExistingTitle = "Already have a workspace?"; + public const string ExistingDetail = + "Sign in to one you created on kcap.ai or a teammate invited you to"; public const string UnsupportedPlatform = "The local edition isn't available on this platform yet."; public const string CloudProfilePresent = "This Mac is set up for a cloud workspace. Moving it to local comes in a later release."; @@ -53,8 +56,8 @@ public EditionStepViewModel(SetupFunnel funnel, Func localUnavailableRe public bool PassThrough => _locked(); public string Lede => - "kcap records the sessions your coding agents run on this machine and gives them tools to search " + - "that history, so what you already worked out is there the next time you need it."; + "kcap stores the sessions your coding agents run on this machine and gives them tools to search " + + "that history, so what you already worked out is there the next time you need it"; public EditionChoice? Choice { get => _choice; diff --git a/src/Capacitor.App/ViewModels/Onboarding/OnboardingViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/OnboardingViewModel.cs index 1a97259177..a48c01e3b5 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/OnboardingViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/OnboardingViewModel.cs @@ -22,7 +22,7 @@ public sealed class OnboardingViewModel : ReactiveObject { public IReadOnlyList Steps { get; } /// Wizard-first mode builds no tray and no main window, so the outcome consumer's Status/ - /// Attention lines are rendered here. Null in tests that don't need them. + /// Attention lines are rendered by the Done page's service card. Null in tests that don't need them. public WizardLifecycleSurface? Surface { get; } int _index; @@ -64,11 +64,10 @@ public string Eyebrow { public bool NextVisible => !Current.OwnsPrimaryAction; public bool SkipVisible => _index < Steps.Count - 1 && Current.Skippable && !Current.OwnsPrimaryAction; public bool BackVisible => Current.BackVisible && PreviousShownIndex() >= 0; - public bool FooterVisible => BackVisible || NextVisible || SkipVisible || TenantActionsVisible - || ShowLifecycleStatus || ShowLifecycleAttention; - /// Hidden, not disabled, where setup cannot be left half-done, and on Done, whose own primary + public bool FooterVisible => BackVisible || NextVisible || SkipVisible || TenantActionsVisible; + /// Hidden, not disabled, where setup cannot be left half-done, and where the step's own primary /// action already closes setup. - public bool FinishLaterVisible => _finishLaterAllowed && Current.Id != WizardStepId.Done; + public bool FinishLaterVisible => _finishLaterAllowed && Current.OffersFinishLater; public bool CanGoNext => !Navigating && !_closed && Current.CanContinue; public bool CanGoSkip => !Navigating && !_closed && Current.CanSkip && _index < Steps.Count - 1; @@ -81,19 +80,18 @@ public string Eyebrow { public System.Windows.Input.ICommand? CancelTenantCommand => Current is SignInStepViewModel signIn ? signIn.CancelTenantCommand : null; + /// The Done page's web workspace or local dashboard, as a link beside its primary action. + public bool OpenInBrowserVisible => Current is DoneStepViewModel { OpenInBrowserVisible: true }; + + public System.Windows.Input.ICommand? OpenInBrowserCommand => + Current is DoneStepViewModel done ? done.OpenInBrowserCommand : null; + /// One filled button per screen. Next is that button on a step with no action of its own, and /// once the step's own action has finished. While the action is on screen, Next is secondary. public bool NextIsPrimary => !Current.ShowsOwnPrimary; public bool NextIsSecondary => Current.ShowsOwnPrimary; - /// The daemon is enabled beside the steps; its outcome lines land on the page that closes setup. - public bool ShowLifecycleStatus => - Current.Id == WizardStepId.Done && !string.IsNullOrEmpty(Surface?.StatusText); - - public bool ShowLifecycleAttention => - Current.Id == WizardStepId.Done && !string.IsNullOrEmpty(Surface?.AttentionText); - // Shared across Back/Next/Skip: only one of the three may be mid-transition at a time. internal bool Navigating { get => _navigating; @@ -155,19 +153,6 @@ public OnboardingViewModel( // State other steps' PassThrough reads, changed outside the current step. restate?.Subscribe(_ => RestateActions()); - if (surface is not null) { - surface.WhenAnyValue(x => x.StatusText) - .Subscribe(_ => { - this.RaisePropertyChanged(nameof(ShowLifecycleStatus)); - this.RaisePropertyChanged(nameof(FooterVisible)); - }); - surface.WhenAnyValue(x => x.AttentionText) - .Subscribe(_ => { - this.RaisePropertyChanged(nameof(ShowLifecycleAttention)); - this.RaisePropertyChanged(nameof(FooterVisible)); - }); - } - PendingEnterForTesting = SafeEnterAsync(Current); } @@ -196,6 +181,8 @@ int PreviousShownIndex() { void OnCurrentChanged(object? sender, PropertyChangedEventArgs e) { // A choice on this page, such as the edition, can change which later pages pass through. this.RaisePropertyChanged(nameof(Eyebrow)); + this.RaisePropertyChanged(nameof(FinishLaterVisible)); + this.RaisePropertyChanged(nameof(OpenInBrowserVisible)); switch (e.PropertyName) { case nameof(IWizardStep.NextLabel): this.RaisePropertyChanged(nameof(NextLabel)); break; case nameof(IWizardStep.SkipLabel): this.RaisePropertyChanged(nameof(SkipLabel)); break; @@ -225,8 +212,8 @@ void RestateActions() { this.RaisePropertyChanged(nameof(FooterVisible)); this.RaisePropertyChanged(nameof(NextIsPrimary)); this.RaisePropertyChanged(nameof(NextIsSecondary)); - this.RaisePropertyChanged(nameof(ShowLifecycleStatus)); - this.RaisePropertyChanged(nameof(ShowLifecycleAttention)); + this.RaisePropertyChanged(nameof(OpenInBrowserVisible)); + this.RaisePropertyChanged(nameof(OpenInBrowserCommand)); this.RaisePropertyChanged(nameof(TenantActionsVisible)); this.RaisePropertyChanged(nameof(ConfirmTenantCommand)); this.RaisePropertyChanged(nameof(CancelTenantCommand)); diff --git a/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs index ba8795aa3e..7179c9e959 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs @@ -23,6 +23,8 @@ public sealed class SignInStepViewModel : ReactiveObject, IWizardStep { public const string WorkspaceNotReady = "Your workspace isn't ready yet."; + public const string WorkspaceSettingUp = "Your workspace is still being set up"; + readonly WizardAuthService _service; readonly ConnectChoiceViewModel _connect; readonly WizardTenantPicker _picker; @@ -117,6 +119,12 @@ public SignInStepViewModel( : $"Enter the code {code} at {verificationUri}"); }; bridges.Progress.PollTicked += () => WaitingText = "Waiting for you to authorize…"; + bridges.Progress.WorkspaceProvisioning += workspace => { + if (!Busy) return; + + ClearTransient(); + SetStatus(WorkspaceSettingUp, isError: false, WorkspaceSettingUpDetail(workspace)); + }; _picker.SelectionRequested += tenants => _post(() => { Tenants.Clear(); @@ -338,6 +346,7 @@ public Task OnEnterAsync(CancellationToken ct) { void OpenSignUp() { try { _urlOpener.Open(_signUpUrl); + RememberWebSignUp(_time.GetUtcNow()); } catch (Exception ex) { Append($"Couldn't open the browser: {ex.Message}"); SetStatus("Couldn't open your browser.", isError: true, @@ -345,6 +354,21 @@ void OpenSignUp() { } } + // A relaunch while the workspace is being created on kcap.ai opens on sign-in rather than on the edition choice. + void RememberWebSignUp(DateTimeOffset? openedAt) { + _ = PersistAsync(); + + async Task PersistAsync() { + try { + if (openedAt is null && (await _appState.LoadAsync().ConfigureAwait(false)).WebSignUpOpenedAt is null) return; + + await _appState.UpdateAsync(state => state with { WebSignUpOpenedAt = openedAt }).ConfigureAwait(false); + } catch (Exception ex) { + Console.Error.WriteLine($"kcap: could not record the browser sign-up: {ex.Message}"); + } + } + } + /// The wizard closed: nothing is left to return to the edition screen. internal void EndSignUpWait() => StopSignUpClock(); @@ -480,6 +504,7 @@ void Apply(AuthResult result) { _workspace = committed.CanonicalServer; this.RaisePropertyChanged(nameof(ConnectionStatus)); Satisfied = true; + RememberWebSignUp(null); SetStatus(CommittedStatus(committed), isError: false, _committedDetail); break; @@ -502,6 +527,9 @@ void Apply(AuthResult result) { } } + internal static string WorkspaceSettingUpDetail(string workspace) => + $"{workspace} is still being created on kcap.ai. It usually takes a few minutes, and sign-in carries on by itself once it's ready"; + static string CommittedStatus(AuthResult.Committed committed) => committed.Provider == AuthProvider.None ? "No sign-in required for this server." diff --git a/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs b/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs index e68392d751..29c37ecc68 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs @@ -42,6 +42,10 @@ public interface IWizardStep { /// Already done with nothing left to ask: forward navigation steps over it. bool PassThrough => false; + /// False hides the shell's Finish later while this step is current. A step that changes it raises + /// PropertyChanged for it. + bool OffersFinishLater => true; + bool CanContinue => true; bool CanSkip => true; diff --git a/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml b/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml index 0e8124f08b..a1bf037f82 100644 --- a/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml +++ b/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml @@ -4,11 +4,40 @@ xmlns:vm="clr-namespace:Capacitor.App.ViewModels.Onboarding" x:Class="Capacitor.App.Views.Onboarding.DonePaneView" x:DataType="vm:DoneStepViewModel"> + + + + + + + + + - + + - - + @@ -35,7 +64,7 @@ + Text="The import continues in the background, so you can start working now" /> @@ -49,85 +78,103 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + Text="The first run in a new folder puts up its own prompt. Answer it once, in the folder you work in" /> - - - - - + + + + + + + + + + + + + + + + + + + + - - -