diff --git a/src/Capacitor.App/App.axaml.cs b/src/Capacitor.App/App.axaml.cs index 6320dcb9a..c9a9400dc 100644 --- a/src/Capacitor.App/App.axaml.cs +++ b/src/Capacitor.App/App.axaml.cs @@ -1237,7 +1237,8 @@ async Task RunWizardModeAsync( HeldDaemonNames: HeldDaemonNamesAsync, ProfileOverride: _serverEnv.Profile, UsageNotice: _usageNotice, - ReportCliSetup: ReportWizardCliSetupAsync); + ReportCliSetup: ReportWizardCliSetupAsync, + 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 0e6c9f55b..1f7689d0e 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 073dc5f36..7d3ebd82d 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,17 @@ 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)); + + string? _failedWorkspace; + + /// kcap.ai stopped creating a discovered workspace, or it is no longer this account's. Held rather than + /// posted, so the attempt's own result can never be handled before it. + public void ProvisioningFailed(string workspace) => Volatile.Write(ref _failedWorkspace, workspace); + + /// The workspace the last attempt saw fail to provision, or null; reading clears it. + public string? TakeProvisioningFailure() => Interlocked.Exchange(ref _failedWorkspace, null); } /// diff --git a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs index 2a73f7e52..e3e930b4a 100644 --- a/src/Capacitor.App/Services/Onboarding/WizardComposition.cs +++ b/src/Capacitor.App/Services/Onboarding/WizardComposition.cs @@ -96,11 +96,21 @@ internal sealed record WizardGraphOptions( // Null, or already acknowledged, leaves the Privacy page out of the run. UsageDataNotice? UsageNotice = null, // Tells the configured server this machine has kcap installed; true once it accepted. - Func>? ReportCliSetup = null); + Func>? ReportCliSetup = 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); @@ -126,17 +136,78 @@ 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, bridges.Progress.ProvisioningFailed, 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. One kcap-web gave up on is gone; one still not live when the wait ends is pending. + /// + internal static Func> ProbeWorkspace( + IHttpClientFactory httpFactory, AuthEndpoints endpoints, Action waiting, Action failed, + TimeProvider time) { + // The bearers of discoveries that already found a live workspace. Discovery waits on every probe, + // so a wait on one still being created would otherwise hold back the live ones. + var liveFound = new System.Collections.Concurrent.ConcurrentDictionary(StringComparer.Ordinal); + + return async (tenant, bearer, ct) => { + var answer = await AskAsync(tenant, ct); + if (answer == WorkspaceAnswer.Live) liveFound.TryAdd(bearer, 0); + if (answer != WorkspaceAnswer.Gone || tenant.Slug is not { Length: > 0 } slug) return answer; + + var provisioning = new TenantProvisioningClient( + httpFactory.CreateClient(nameof(TenantProvisioningClient)), ClientAgent.Desktop); + // The status client reads a cancelled request as a transport failure. + var status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct); + ct.ThrowIfCancellationRequested(); + if (ProvisioningPoll.Classify(status.StatusCode, status.Body?.State, status.Body?.WorkosOrgId) + is PollVerdict.Failed or PollVerdict.ActiveNoOrg) { + failed(tenant.Label); + return answer; + } + if (!ProvisioningPoll.IsPending(status.StatusCode, status.Body?.State)) return answer; + + waiting(tenant.Label); + var deadline = time.GetUtcNow() + ProvisioningWaitLimit; + while (time.GetUtcNow() < deadline) { + if (liveFound.ContainsKey(bearer)) return WorkspaceAnswer.Pending; + await Task.Delay(ProvisioningPollInterval, time, ct); + if (liveFound.ContainsKey(bearer)) return WorkspaceAnswer.Pending; + + answer = await AskAsync(tenant, ct); + if (answer == WorkspaceAnswer.Live) { + liveFound.TryAdd(bearer, 0); + return answer; + } + + // An expired bearer can no longer see a failure, so the wait ends there and sign-in offers another try. + status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct); + ct.ThrowIfCancellationRequested(); + if (status.StatusCode == 401) return WorkspaceAnswer.Pending; + if (ProvisioningPoll.Classify(status.StatusCode, status.Body?.State, status.Body?.WorkosOrgId) + is PollVerdict.Failed or PollVerdict.NotFound or PollVerdict.Forbidden or PollVerdict.ActiveNoOrg) { + failed(tenant.Label); + return WorkspaceAnswer.Gone; + } + } + + return WorkspaceAnswer.Pending; + }; + + 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); @@ -248,6 +319,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(); @@ -263,8 +338,12 @@ [.. recording.Select(r => r.Label)], !local && Connected() ? options.ResolveIdentity()?.Server : null, daemon.Problem, local, - local ? LocalEndpoints.Read(root).Active?.ServerUrl : null); - }, options.UrlOpener, PrepareWorkspaceAsync); + local ? LocalEndpoints.Read(root).Active?.ServerUrl : null, + daemon.Busy, + options.Surface.AttentionText ?? options.Surface.StatusText); + }, options.UrlOpener, PrepareWorkspaceAsync, serviceChanged, + // 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. @@ -272,8 +351,12 @@ [.. 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 && !OnLocalPath() && !Installed() + ? 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 000000000..28e0767f8 --- /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 d180141ac..0158d2305 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 f68098b79..ce4a97b12 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 eb598ca8e..df5cc35ea 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs @@ -9,38 +9,70 @@ 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"; + + static readonly string ThisDevice = OperatingSystem.IsMacOS() ? "this Mac" : "this computer"; + readonly Func _facts; readonly IUrlOpener? _opener; readonly Func>? _prepare; + readonly Action? _addAgent; - DoneFacts _current = DoneFacts.Empty; + DoneFacts _current = DoneFacts.Empty; WorkspaceReadiness _readiness; - bool _preparing; - string? _readyFor; + bool _preparing; + string? _readyFor; + bool _entered; + bool _detailsVisible; /// Brings the daemon up and reports the install, answering whether the /// dashboard may open; true asks it to try again. Null when there is nothing to wait for. - public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func>? prepare = null) { + /// Ticks when the daemon or its outcome lines change, so the page re-reads its facts. + /// Takes the wizard back to the page that connects agents. + public DoneStepViewModel( + Func facts, IUrlOpener? opener = null, Func>? prepare = null, + IObservable? restate = null, Action? addAgent = null) { _facts = facts; _opener = opener; _prepare = prepare; + _addAgent = addAgent; _readiness = prepare is null ? WorkspaceReadiness.Ready : WorkspaceReadiness.Preparing; var ready = this.WhenAnyValue(x => x.Readiness).Select(r => r == WorkspaceReadiness.Ready); - OpenWorkspaceCommand = ReactiveCommand.Create(OpenWorkspace, ready); - OpenDashboardCommand = ReactiveCommand.Create(OpenDashboard, ready); + OpenInBrowserCommand = ReactiveCommand.Create(OpenInBrowser, ready); RetryCommand = ReactiveCommand.CreateFromTask(() => PrepareAsync(retry: true)); + 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 || Readiness == WorkspaceReadiness.DaemonNotRunning) + ? 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 something failed; otherwise Open kcap closes it. + public bool OffersFinishLater => ServiceFailed || NotReadyVisible; HistoryImportRun? Import => _current.Import; @@ -51,16 +83,15 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func< 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 imported. 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 imported. Your history is still on this machine"; public bool ShowsFigures => Import is not null; @@ -103,33 +134,79 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func< : Import is { Failed: 1 } ? "1 session failed to import" : $"{Import?.Failed ?? 0} sessions failed to import"; 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"; - /// Hooks load at agent start, so an agent open since before setup records nothing until it restarts. - public string? RestartLine => CapturePromised ? "Agents load new hooks at startup. Restart any agent you already have open." : null; + /// Hooks load at agent start, so an agent open since before setup stores nothing until it is launched again. + public string? RestartLine => CapturePromised ? "Agents already open will start storing sessions the next time you launch them" : null; 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 string AddAgentLabel => _current.Recording.Count > 0 ? "Add another agent" : "Add an agent"; + + /// 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 string ServiceBody => + $"Keeps kcap storing sessions when the app is closed, and lets you start agents on {ThisDevice} from anywhere"; + + 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 ServiceDetailsAvailable => ServiceDetails.Length > 0; + + /// An outcome line the daemon reported while the service is not failed, which See details does not show. + public string? ServiceNote => ServiceFailed ? null : _current.ServiceDetail; + + public bool RetryAvailable => _prepare 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"; public WorkspaceReadiness Readiness { get => _readiness; @@ -141,38 +218,38 @@ private set { } } - bool WorkspaceKnown => !_current.Local && !string.IsNullOrEmpty(_current.WorkspaceUrl); - - bool DashboardKnown => _current.Local && !string.IsNullOrEmpty(_current.DashboardUrl); - - bool Opens => WorkspaceKnown || DashboardKnown; + // What Open in browser opens, and so which server must have the install's report. + string? Target => _current.Local ? _current.DashboardUrl : _current.WorkspaceUrl; - public bool WorkspaceVisible => WorkspaceKnown && Readiness == WorkspaceReadiness.Ready; + bool Opens => !string.IsNullOrEmpty(Target); - public bool DashboardVisible => DashboardKnown && Readiness == WorkspaceReadiness.Ready; + /// The web workspace, or the local server's dashboard, once this machine is registered with it. + public bool OpenInBrowserVisible => Opens && Readiness == WorkspaceReadiness.Ready; public bool PreparingVisible => Opens && Readiness == WorkspaceReadiness.Preparing; public string PreparingLine => _current.Local ? "Getting your dashboard ready…" : "Getting your workspace ready…"; - public bool NotReadyVisible => Opens && Readiness is WorkspaceReadiness.DaemonNotRunning or WorkspaceReadiness.Unreported; + /// A daemon that is not running is the service card's to say; this is the server not taking the report. + public bool NotReadyVisible => Opens && Readiness == WorkspaceReadiness.Unreported; public string NotReadyTitle => _current.Local ? "The dashboard can't open yet" : "Your workspace can't open yet"; - public string NotReadyBody => Readiness == WorkspaceReadiness.DaemonNotRunning - ? $"It opens once the daemon is running on {_current.MachineName}." - : _current.Local - ? "kcap couldn't reach the local server to register this machine." - : "kcap couldn't reach your workspace to register this machine. Check your connection."; + public string NotReadyBody => _current.Local + ? "kcap couldn't reach the local server to register this machine" + : "kcap couldn't reach your workspace to register this machine. Check your connection"; - public ReactiveCommand OpenWorkspaceCommand { get; } - - public ReactiveCommand OpenDashboardCommand { get; } + public ReactiveCommand OpenInBrowserCommand { get; } public ReactiveCommand RetryCommand { 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) { + _entered = true; Refresh(); // Back to sign-in can switch to a server that has not had this install's report. if (_prepare is not null && Readiness == WorkspaceReadiness.Ready && _readyFor != Target) Readiness = WorkspaceReadiness.Preparing; @@ -181,8 +258,11 @@ public Task OnEnterAsync(CancellationToken ct) { return Task.CompletedTask; } - // What the link opens, and so which server must have the report. - string? Target => _current.Local ? _current.DashboardUrl : _current.WorkspaceUrl; + // Next finishes the wizard — the shell's own last-step handling raises CloseRequested. + public Task CanLeaveAsync(WizardNavigation direction, CancellationToken ct) { + _entered = false; + return Task.FromResult(true); + } void Refresh() { if (_current.Import is INotifyPropertyChanged before) before.PropertyChanged -= OnImportChanged; @@ -196,8 +276,9 @@ void Refresh() { async Task PrepareAsync(bool retry) { if (_prepare is null || _preparing || Readiness == WorkspaceReadiness.Ready) return; - _preparing = true; - Readiness = WorkspaceReadiness.Preparing; + _preparing = true; + _detailsVisible = false; + Readiness = WorkspaceReadiness.Preparing; var target = Target; WorkspaceReadiness result; try { @@ -209,7 +290,7 @@ async Task PrepareAsync(bool retry) { _preparing = false; } - // The daemon line reads the run that just settled. + // The service card reads the run that just settled. Refresh(); // Sign-in moved to another server while this ran; its answer says nothing about that one. @@ -222,28 +303,15 @@ async Task PrepareAsync(bool retry) { if (Readiness == WorkspaceReadiness.Ready) _readyFor = target; } - // Next finishes the wizard — the shell's own last-step handling raises CloseRequested. - public Task CanLeaveAsync(WizardNavigation direction, CancellationToken ct) => Task.FromResult(true); - void OnImportChanged(object? sender, PropertyChangedEventArgs e) => Restate(); - void OpenWorkspace() { - if (Readiness != WorkspaceReadiness.Ready) return; - if (_current.WorkspaceUrl is not { Length: > 0 } url || _opener is null) return; - try { - _opener.Open(url); - } catch (Exception ex) { - Console.Error.WriteLine($"kcap: could not open the workspace: {ex.Message}"); - } - } - - void OpenDashboard() { + void OpenInBrowser() { if (Readiness != WorkspaceReadiness.Ready) return; - if (_current.DashboardUrl is not { Length: > 0 } url || _opener is null) return; + if (Target 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 d4dafdde6..9941ae987 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 1a9725917..a48c01e3b 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 ba8795aa3..6b0a58ae3 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs @@ -23,9 +23,12 @@ 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; + readonly UiAuthProgress _progress; readonly ConsentFlipClaims _claims; readonly IAppStateStore _appState; readonly IUrlOpener _urlOpener; @@ -85,6 +88,7 @@ public SignInStepViewModel( _service = service; _connect = connect; _picker = bridges.Picker; + _progress = bridges.Progress; _claims = claims; _appState = appState; _urlOpener = urlOpener; @@ -117,6 +121,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 +348,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 +356,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(); @@ -470,6 +496,7 @@ async Task RunAsync() { } void Apply(AuthResult result) { + var failedWorkspace = _progress.TakeProvisioningFailure(); switch (result) { case AuthResult.Committed { CredentialSaved: false }: SetStatus("Signed in, but the credential could not be saved.", isError: true, @@ -480,12 +507,22 @@ void Apply(AuthResult result) { _workspace = committed.CanonicalServer; this.RaisePropertyChanged(nameof(ConnectionStatus)); Satisfied = true; + RememberWebSignUp(null); SetStatus(CommittedStatus(committed), isError: false, _committedDetail); break; case AuthResult.Cancelled: SetStatus("Sign-in cancelled.", isError: false); + break; + // The wait on a workspace still being created ran out before it went live. + case AuthResult.Failed { Reason: AuthFailureReason.ProvisioningInProgress }: + SetStatus(WorkspaceSettingUp, isError: false, WorkspaceStillSettingUpDetail); + + break; + case AuthResult.Failed { Reason: AuthFailureReason.NoTenantsFound } when failedWorkspace is not null: + SetStatus(WorkspaceNotCreated, isError: true, WorkspaceNotCreatedDetail(failedWorkspace)); + break; // A browser sign-up still provisioning: the wait goes on, so this is not a failed sign-in. case AuthResult.Failed { Reason: AuthFailureReason.NoTenantsFound } when _signingUp(): @@ -502,6 +539,17 @@ void Apply(AuthResult result) { } } + internal const string WorkspaceNotCreated = "kcap.ai couldn't finish creating your workspace"; + + internal static string WorkspaceNotCreatedDetail(string workspace) => + $"{workspace} didn't finish setting up. Its page on kcap.ai says what happened"; + + internal const string WorkspaceStillSettingUpDetail = + "It's taking longer than usual. Sign in again in a few minutes, once kcap.ai says it's ready"; + + 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." @@ -574,6 +622,7 @@ void RestatePhase() { void ResetForRun(ConnectIntent intent) { _running = intent; _lastReport = null; + _progress.TakeProvisioningFailure(); Log.Clear(); HidePrompts(); ClearTransient(); diff --git a/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs b/src/Capacitor.App/ViewModels/Onboarding/WizardStep.cs index e68392d75..29c37ecc6 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 295a7e79e..293ad9788 100644 --- a/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml +++ b/src/Capacitor.App/Views/Onboarding/DonePaneView.axaml @@ -4,11 +4,39 @@ xmlns:vm="clr-namespace:Capacitor.App.ViewModels.Onboarding" x:Class="Capacitor.App.Views.Onboarding.DonePaneView" x:DataType="vm:DoneStepViewModel"> + + + + + + + + + - + - - + @@ -35,7 +63,7 @@ + Text="The import continues in the background, so you can start working now" /> @@ -49,66 +77,105 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + Text="The first run in a new folder puts up its own prompt. Answer it once, in the folder you work in" /> + + + + + + + + + + + + + + + + + + + + + - diff --git a/src/Capacitor.App/Views/Onboarding/OnboardingPaneView.axaml b/src/Capacitor.App/Views/Onboarding/OnboardingPaneView.axaml index 76c9040a7..75e6c2f2b 100644 --- a/src/Capacitor.App/Views/Onboarding/OnboardingPaneView.axaml +++ b/src/Capacitor.App/Views/Onboarding/OnboardingPaneView.axaml @@ -46,12 +46,19 @@ - - -