diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index df0aab728e3..dc2c49e855b 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -4,7 +4,7 @@ import { buildDismissedOnboardingFolderAgentStartup, buildOnboardingFolderAgentStartup, shouldSeedFolderAgentAfterDismissedOnboarding -} from './onboarding-folder-agent-startup' +} from '@/lib/onboarding-folder-agent-startup' describe('buildOnboardingFolderAgentStartup', () => { it('queues the persisted default agent with onboarding telemetry', () => { @@ -43,20 +43,38 @@ describe('buildOnboardingFolderAgentStartup', () => { it('seeds after a dismissed onboarding run before any project was added', () => { expect( - shouldSeedFolderAgentAfterDismissedOnboarding({ - ...getDefaultOnboardingState(), - outcome: 'dismissed' - }) + shouldSeedFolderAgentAfterDismissedOnboarding( + { + ...getDefaultOnboardingState(), + outcome: 'dismissed' + }, + false + ) ).toBe(true) }) + it('does not seed after another project was already added outside onboarding', () => { + expect( + shouldSeedFolderAgentAfterDismissedOnboarding( + { + ...getDefaultOnboardingState(), + outcome: 'dismissed' + }, + true + ) + ).toBe(false) + }) + it('does not seed after onboarding already added a project', () => { expect( - shouldSeedFolderAgentAfterDismissedOnboarding({ - ...getDefaultOnboardingState(), - outcome: 'dismissed', - checklist: { ...getDefaultOnboardingState().checklist, addedFolder: true } - }) + shouldSeedFolderAgentAfterDismissedOnboarding( + { + ...getDefaultOnboardingState(), + outcome: 'dismissed', + checklist: { ...getDefaultOnboardingState().checklist, addedFolder: true } + }, + false + ) ).toBe(false) }) @@ -68,7 +86,8 @@ describe('buildOnboardingFolderAgentStartup', () => { defaultTuiAgent: 'codex', agentCmdOverrides: { codex: 'echo onboarding-folder-agent' } }, - { ...getDefaultOnboardingState(), outcome: 'dismissed' } + { ...getDefaultOnboardingState(), outcome: 'dismissed' }, + false ) ).toEqual({ command: 'echo onboarding-folder-agent', diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.ts deleted file mode 100644 index 49049f92fd8..00000000000 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - buildDismissedOnboardingFolderAgentStartup, - buildOnboardingFolderAgentStartup, - shouldSeedFolderAgentAfterDismissedOnboarding, - type OnboardingFolderAgentStartup -} from '@/lib/onboarding-folder-agent-startup' diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 98e94c19a7b..c3ce7d0a9db 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -11,6 +11,7 @@ import { import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -26,6 +27,8 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { if (connectionId && folderPath) { void (async () => { try { + const stateBeforeAdd = useAppStore.getState() + const hadProjectBeforeAdd = stateBeforeAdd.repos.length > 0 const result = await window.api.repos.addRemote({ connectionId, remotePath: folderPath, @@ -46,7 +49,15 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { // worktree reveals it in the sidebar and opens the workspace. const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0] if (folderWorktree) { - activateAndRevealWorktree(folderWorktree.id) + const onboarding = await window.api.onboarding.get().catch(() => null) + // Why: SSH users can hit this dialog from Add Project after + // dismissing onboarding, bypassing the local addNonGitFolder path. + const startup = buildDismissedOnboardingFolderAgentStartup( + useAppStore.getState().settings, + onboarding, + hadProjectBeforeAdd + ) + activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } } catch (err) { // This code path calls addRemote directly (not through the store), diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index db1dedc322f..1d8d68d637a 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -47,10 +47,12 @@ export function buildOnboardingFolderAgentStartup( } export function shouldSeedFolderAgentAfterDismissedOnboarding( - onboarding: OnboardingState | null + onboarding: OnboardingState | null, + hasExistingProject: boolean ): boolean { return ( onboarding?.outcome === 'dismissed' && + !hasExistingProject && !onboarding.checklist.addedRepo && !onboarding.checklist.addedFolder ) @@ -58,9 +60,10 @@ export function shouldSeedFolderAgentAfterDismissedOnboarding( export function buildDismissedOnboardingFolderAgentStartup( settings: GlobalSettings | null, - onboarding: OnboardingState | null + onboarding: OnboardingState | null, + hasExistingProject: boolean ): OnboardingFolderAgentStartup | undefined { - if (!shouldSeedFolderAgentAfterDismissedOnboarding(onboarding)) { + if (!shouldSeedFolderAgentAfterDismissedOnboarding(onboarding, hasExistingProject)) { return undefined } return buildOnboardingFolderAgentStartup(settings) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index b9b0e3ad102..90eabcea062 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -2,12 +2,21 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' import type { Repo } from '../../../../shared/types' +import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +const worktreeActivation = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('../../lib/worktree-activation', () => ({ + activateAndRevealWorktree: worktreeActivation.activateAndRevealWorktree +})) + const localRepo: Repo = { id: 'local-repo', path: '/local', @@ -32,6 +41,7 @@ const reposUpdate = vi.fn() const reposReorder = vi.fn() const worktreesList = vi.fn() const onboardingGet = vi.fn() +const onboardingUpdate = vi.fn() const ptyKill = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -46,6 +56,8 @@ beforeEach(() => { reposReorder.mockReset() worktreesList.mockReset() onboardingGet.mockReset() + onboardingUpdate.mockReset() + worktreeActivation.activateAndRevealWorktree.mockReset() ptyKill.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() @@ -66,7 +78,8 @@ beforeEach(() => { list: worktreesList }, onboarding: { - get: onboardingGet + get: onboardingGet, + update: onboardingUpdate }, pty: { kill: ptyKill }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } @@ -178,6 +191,58 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('only seeds the onboarding default agent for the first dismissed-onboarding folder', async () => { + const firstFolderRepo = { + ...localRepo, + id: 'folder-1', + path: '/first', + displayName: 'First' + } + const secondFolderRepo = { + ...localRepo, + id: 'folder-2', + path: '/second', + displayName: 'Second' + } + reposAdd + .mockResolvedValueOnce({ repo: firstFolderRepo }) + .mockResolvedValueOnce({ repo: secondFolderRepo }) + worktreesList.mockImplementation(({ repoId }: { repoId: string }) => [ + makeWorktree({ id: `${repoId}::/folder`, repoId }) + ]) + onboardingGet.mockResolvedValue({ ...getDefaultOnboardingState(), outcome: 'dismissed' }) + const store = createTestStore() + store.setState({ + settings: { + ...getDefaultSettings('/tmp/orca-workspaces'), + defaultTuiAgent: 'codex' + } + }) + + await store.getState().addNonGitFolder('/first') + await store.getState().addNonGitFolder('/second') + + expect(worktreeActivation.activateAndRevealWorktree).toHaveBeenNthCalledWith( + 1, + 'folder-1::/folder', + { + startup: { + command: 'codex', + telemetry: { + agent_kind: 'codex', + launch_source: 'onboarding', + request_kind: 'new' + } + } + } + ) + expect(worktreeActivation.activateAndRevealWorktree).toHaveBeenNthCalledWith( + 2, + 'folder-2::/folder', + undefined + ) + }) + it('removes repos through the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-3', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index e1ac7c4fd7a..7f658ba7464 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -155,6 +155,7 @@ export const createRepoSlice: StateCreator = (set, addNonGitFolder: async (path) => { try { + const hadProjectBeforeAdd = get().repos.length > 0 const repo = await get().addRepoPath(path, 'folder') if (!repo) { return null @@ -173,7 +174,11 @@ export const createRepoSlice: StateCreator = (set, // Why: a new user can dismiss the wizard, then immediately add their // first folder from Landing. That path skips onboarding's completeRepo // hook, so carry the selected default agent into the first terminal here. - const startup = buildDismissedOnboardingFolderAgentStartup(get().settings, onboarding) + const startup = buildDismissedOnboardingFolderAgentStartup( + get().settings, + onboarding, + hadProjectBeforeAdd + ) activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } return repo