From ca82b869b5a20fd385bb9c8585b0bd07d47da186 Mon Sep 17 00:00:00 2001 From: kt Date: Tue, 15 Sep 2026 13:11:17 -0700 Subject: [PATCH] show existing users only the trace onboarding question (#2368) --- .../existing-user-onboarding-trace-only.md | 2 + .../components/prime-onboarding-splash.ts | 5 + .../src/modes/interactive/interactive-mode.ts | 31 ++- .../test/interactive-mode-status.test.ts | 36 ++-- ...xisting-user-onboarding-trace-only.test.ts | 176 ++++++++++++++++++ 5 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/.changes/existing-user-onboarding-trace-only.md create mode 100644 packages/coding-agent/test/suite/regressions/existing-user-onboarding-trace-only.test.ts diff --git a/packages/coding-agent/.changes/existing-user-onboarding-trace-only.md b/packages/coding-agent/.changes/existing-user-onboarding-trace-only.md new file mode 100644 index 000000000..968bcbbc1 --- /dev/null +++ b/packages/coding-agent/.changes/existing-user-onboarding-trace-only.md @@ -0,0 +1,2 @@ +- Changed onboarding for existing users (working model with configured auth) to show only the trace-sharing question, skipping Prime login and the provider picker entirely. +- Changed onboarding for existing users who already have traces enabled to complete silently without showing any questions. diff --git a/packages/coding-agent/src/modes/interactive/components/prime-onboarding-splash.ts b/packages/coding-agent/src/modes/interactive/components/prime-onboarding-splash.ts index de38dbc61..3b243a838 100644 --- a/packages/coding-agent/src/modes/interactive/components/prime-onboarding-splash.ts +++ b/packages/coding-agent/src/modes/interactive/components/prime-onboarding-splash.ts @@ -17,6 +17,8 @@ interface PrimeOnboardingSplashOptions { animationIntervalMs?: number; /** Quits the app: the editor that normally owns Ctrl+C has no focus yet. */ onExit?: () => void; + /** Skip the welcome text and login action; the flow starts immediately. */ + immediate?: boolean; } const LOGO_LINES = PRIME_COMPACT_BUTTERFLY_LOGO.split("\n"); @@ -70,6 +72,9 @@ export class PrimeOnboardingSplashComponent implements Component { private readonly onSelect: () => void, private readonly options: PrimeOnboardingSplashOptions = {}, ) { + if (options.immediate) { + this.flowStarted = true; + } if (options.requestRender) { this.animationInterval = setInterval(() => { this.frame++; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9aa83a717..5474ed06c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1927,6 +1927,28 @@ export class InteractiveMode { /** Runs the first-launch sequence. Resolves true only when every step ran. */ private async runOnboardingFlow(): Promise { this.modelRegistry.refresh(); + + // Existing users (working model with configured auth) skip login and + // the provider picker entirely. They see only the trace question, or + // nothing at all when traces are already enabled. + if (isOnboardingModelReady(this.getOnboardingState())) { + if (this.settingsManager.getAgentTracesEnabled()) { + return true; + } + const abort = new AbortController(); + this.onboardingFlowAbort = abort; + const splash = await this.showOnboardingSplash({ immediate: true }); + if (!splash) { + return false; + } + await this.askOnboardingTraceOptIn(); + if (abort.signal.aborted) { + return false; + } + splash.dismiss(); + return true; + } + const abort = new AbortController(); this.onboardingFlowAbort = abort; const splash = await this.showOnboardingSplash(); @@ -8989,7 +9011,7 @@ export class InteractiveMode { } } - private showOnboardingSplash(): Promise { + private showOnboardingSplash(options?: { immediate?: boolean }): Promise { return new Promise((resolve) => { let settled = false; let dismissed = false; @@ -9027,6 +9049,7 @@ export class InteractiveMode { // Nothing else owns Ctrl+C yet, so the block exits the app itself. onExit: () => void this.shutdown(), requestRender: () => this.ui.requestRender(), + immediate: options?.immediate, }, ); // The block owns the pane while onboarding runs: the brand header hides @@ -9048,6 +9071,12 @@ export class InteractiveMode { row: 0, col: 0, }); + // Immediate mode: settle without waiting for the user to press Enter + // on the login action. The flow panels mount directly under the brand + // mark with no welcome text or action row flash. + if (options?.immediate) { + settle({ dismiss }); + } }); } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 3087d1569..8ed7e1e92 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -3501,6 +3501,7 @@ describe("InteractiveMode Prime CLI onboarding", () => { getOnboardingShown: () => boolean; setOnboardingShown: (shown: boolean) => void; setDefaultModelAndProvider: (provider: string, modelId: string) => void; + getAgentTracesEnabled: () => boolean; flush: () => Promise; }; }; @@ -4179,6 +4180,7 @@ describe("InteractiveMode Prime CLI onboarding", () => { getOnboardingShown: vi.fn(() => shown), setOnboardingShown: vi.fn(), setDefaultModelAndProvider: vi.fn(), + getAgentTracesEnabled: vi.fn(() => false), flush: vi.fn(async () => {}), }, }; @@ -4240,26 +4242,21 @@ describe("InteractiveMode Prime CLI onboarding", () => { expect(fakeThis.showConfigurationMenu).not.toHaveBeenCalled(); }); - test("runs the sign-in flow even when Prime CLI credentials are on disk", async () => { + test("skips the sign-in flow when the model already works, even with Prime CLI credentials on disk", async () => { const fakeThis = createPrimeCliHarness(false); const dismiss = vi.fn(); fakeThis.showOnboardingSplash = vi.fn(async () => ({ dismiss })); - fakeThis.createAuthFlows = vi.fn(() => ({ - runPrimeInferenceLogin: vi.fn(async () => ({ - status: "success" as const, - providerId: PRIME_INFERENCE_PROVIDER_ID, - providerName: "Prime Inference", - authType: "api_key" as const, - kind: "provider" as const, - })), - })); + fakeThis.askOnboardingTraceOptIn = vi.fn(async () => {}); + fakeThis.createAuthFlows = vi.fn(); fakeThis.prepareForModelSelectionAfterLogin = vi.fn(async () => true); fakeThis.showConfigurationMenu = vi.fn(async () => {}); await expect(runOnboardingFlow.call(fakeThis)).resolves.toBe(true); - expect(fakeThis.showOnboardingSplash).toHaveBeenCalledWith(); - expect(fakeThis.prepareForModelSelectionAfterLogin).toHaveBeenCalledTimes(1); + expect(fakeThis.showOnboardingSplash).toHaveBeenCalledWith({ immediate: true }); + expect(fakeThis.createAuthFlows).not.toHaveBeenCalled(); + expect(fakeThis.prepareForModelSelectionAfterLogin).not.toHaveBeenCalled(); + expect(fakeThis.askOnboardingTraceOptIn).toHaveBeenCalledTimes(1); // The model picker is no longer part of first launch. expect(fakeThis.showConfigurationMenu).not.toHaveBeenCalled(); expect(dismiss).toHaveBeenCalledOnce(); @@ -4302,20 +4299,9 @@ describe("InteractiveMode Prime CLI onboarding", () => { expect(fakeThis.uiServices.settingsManager.flush).not.toHaveBeenCalled(); }); - test("reports no completion when a reset interrupts the last question", async () => { + test("reports no completion when a reset interrupts the trace question", async () => { const fakeThis = createPrimeCliHarness(false); fakeThis.showOnboardingSplash = vi.fn(async () => ({ dismiss: vi.fn() })); - fakeThis.createAuthFlows = vi.fn(() => ({ - runPrimeInferenceLogin: vi.fn(async () => ({ - status: "success" as const, - providerId: PRIME_INFERENCE_PROVIDER_ID, - providerName: "Prime Inference", - authType: "api_key" as const, - kind: "provider" as const, - })), - })); - fakeThis.prepareForModelSelectionAfterLogin = vi.fn(async () => true); - fakeThis.askOnboardingProviders = vi.fn(async () => {}); // The reset settles the trace question and aborts the flow behind it. fakeThis.askOnboardingTraceOptIn = vi.fn(async () => { (fakeThis as unknown as { onboardingFlowAbort?: AbortController }).onboardingFlowAbort?.abort(); @@ -4377,7 +4363,9 @@ describe("InteractiveMode Prime CLI onboarding", () => { }); test("ends the flow when the sign-in does not succeed", async () => { + // Without a working model the user still needs the full sign-in flow. const fakeThis = createPrimeCliHarness(false); + fakeThis.connectionState = createConnectionState({ model: undefined }); const dismiss = vi.fn(); fakeThis.showOnboardingSplash = vi.fn(async () => ({ dismiss })); fakeThis.createAuthFlows = vi.fn(() => ({ diff --git a/packages/coding-agent/test/suite/regressions/existing-user-onboarding-trace-only.test.ts b/packages/coding-agent/test/suite/regressions/existing-user-onboarding-trace-only.test.ts new file mode 100644 index 000000000..965eace8e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/existing-user-onboarding-trace-only.test.ts @@ -0,0 +1,176 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { KeybindingsManager } from "../../../src/core/keybindings.js"; +import type { ModelRegistry } from "../../../src/core/model-registry.js"; +import type { SettingsManager } from "../../../src/core/settings-manager.js"; +import type { AgentConnectionModel } from "../../../src/modes/agent-connection/types.js"; +import type { AuthenticationResult } from "../../../src/modes/interactive/auth-flows.js"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; +import { createHarness, type Harness } from "../harness.js"; + +interface OnboardingSplashHandle { + dismiss(): void; +} + +interface ExistingUserOnboardingHarness { + runOnboardingFlow(): Promise; + uiServices: { + modelRegistry: ModelRegistry; + settingsManager: SettingsManager; + }; + connectionState: { model: AgentConnectionModel } | undefined; + onboardingFlowAbort: AbortController | undefined; + showOnboardingSplash(options?: { immediate?: boolean }): Promise; + askOnboardingTraceOptIn(): Promise; + createAuthFlows(): { + runPrimeInferenceLogin(): Promise; + }; + prepareForModelSelectionAfterLogin(authResult: AuthenticationResult): Promise; + askOnboardingProviders(signal: AbortSignal): Promise; +} + +describe("existing user onboarding shows only trace question", () => { + const harnesses: Harness[] = []; + + beforeEach(() => { + initTheme("dark"); + setKeybindings(new KeybindingsManager()); + }); + + afterEach(() => { + for (const harness of harnesses.splice(0)) { + harness.cleanup(); + } + }); + + test("existing user sees only the trace question, not login or provider picker", async () => { + const harness = await createHarness({ provider: "prime-inference", withConfiguredAuth: true }); + harnesses.push(harness); + const order: string[] = []; + const splash: OnboardingSplashHandle = { + dismiss: () => order.push("dismiss"), + }; + const fakeThis = Object.create(InteractiveMode.prototype) as ExistingUserOnboardingHarness; + fakeThis.uiServices = { + modelRegistry: harness.session.modelRegistry, + settingsManager: harness.settingsManager, + }; + fakeThis.connectionState = { model: harness.getModel() as AgentConnectionModel }; + fakeThis.onboardingFlowAbort = undefined; + fakeThis.showOnboardingSplash = vi.fn(async () => { + order.push("splash"); + return splash; + }); + fakeThis.askOnboardingTraceOptIn = vi.fn(async () => { + order.push("trace"); + }); + fakeThis.createAuthFlows = vi.fn(() => ({ + runPrimeInferenceLogin: vi.fn(async (): Promise => { + order.push("login"); + return { + status: "success", + providerId: "prime-inference", + providerName: "Prime Inference", + authType: "api_key", + kind: "provider", + }; + }), + })); + fakeThis.prepareForModelSelectionAfterLogin = vi.fn(async () => { + order.push("prepare"); + return true; + }); + fakeThis.askOnboardingProviders = vi.fn(async () => { + order.push("providers"); + }); + + const result = await fakeThis.runOnboardingFlow(); + + expect(result).toBe(true); + expect(fakeThis.showOnboardingSplash).toHaveBeenCalledWith({ immediate: true }); + expect(fakeThis.createAuthFlows).not.toHaveBeenCalled(); + expect(fakeThis.prepareForModelSelectionAfterLogin).not.toHaveBeenCalled(); + expect(fakeThis.askOnboardingProviders).not.toHaveBeenCalled(); + expect(fakeThis.askOnboardingTraceOptIn).toHaveBeenCalled(); + expect(order).toEqual(["splash", "trace", "dismiss"]); + }); + + test("existing user with traces already enabled completes silently", async () => { + const harness = await createHarness({ provider: "prime-inference", withConfiguredAuth: true }); + harnesses.push(harness); + harness.settingsManager.setAgentTracesEnabled(true); + const fakeThis = Object.create(InteractiveMode.prototype) as ExistingUserOnboardingHarness; + fakeThis.uiServices = { + modelRegistry: harness.session.modelRegistry, + settingsManager: harness.settingsManager, + }; + fakeThis.connectionState = { model: harness.getModel() as AgentConnectionModel }; + fakeThis.onboardingFlowAbort = undefined; + fakeThis.showOnboardingSplash = vi.fn(async () => ({ dismiss: vi.fn() })); + fakeThis.askOnboardingTraceOptIn = vi.fn(); + fakeThis.createAuthFlows = vi.fn(() => ({ + runPrimeInferenceLogin: vi.fn(), + })); + + const result = await fakeThis.runOnboardingFlow(); + + expect(result).toBe(true); + expect(fakeThis.showOnboardingSplash).not.toHaveBeenCalled(); + expect(fakeThis.askOnboardingTraceOptIn).not.toHaveBeenCalled(); + expect(fakeThis.createAuthFlows).not.toHaveBeenCalled(); + }); + + test("new user without configured auth runs the full flow unchanged", async () => { + const harness = await createHarness({ provider: "prime-inference", withConfiguredAuth: false }); + harnesses.push(harness); + const order: string[] = []; + const splash: OnboardingSplashHandle = { + dismiss: () => order.push("dismiss"), + }; + const fakeThis = Object.create(InteractiveMode.prototype) as ExistingUserOnboardingHarness; + fakeThis.uiServices = { + modelRegistry: harness.session.modelRegistry, + settingsManager: harness.settingsManager, + }; + fakeThis.connectionState = undefined; + fakeThis.onboardingFlowAbort = undefined; + fakeThis.showOnboardingSplash = vi.fn(async () => { + order.push("splash"); + return splash; + }); + fakeThis.askOnboardingTraceOptIn = vi.fn(async () => { + order.push("trace"); + }); + fakeThis.createAuthFlows = vi.fn(() => ({ + runPrimeInferenceLogin: async (): Promise => { + order.push("login"); + return { + status: "success", + providerId: "prime-inference", + providerName: "Prime Inference", + authType: "api_key", + kind: "provider", + }; + }, + })); + fakeThis.prepareForModelSelectionAfterLogin = vi.fn(async () => { + order.push("prepare"); + return true; + }); + fakeThis.askOnboardingProviders = vi.fn(async () => { + order.push("providers"); + }); + + const result = await fakeThis.runOnboardingFlow(); + + expect(result).toBe(true); + // New user: splash waits for Enter (no immediate option) + expect(fakeThis.showOnboardingSplash).toHaveBeenCalledWith(); + expect(fakeThis.createAuthFlows).toHaveBeenCalled(); + expect(fakeThis.prepareForModelSelectionAfterLogin).toHaveBeenCalled(); + expect(fakeThis.askOnboardingProviders).toHaveBeenCalled(); + expect(fakeThis.askOnboardingTraceOptIn).toHaveBeenCalled(); + expect(order).toEqual(["splash", "login", "prepare", "providers", "trace", "dismiss"]); + }); +}); -- 2.54.0