Fix onboarding folder agent startup seeding

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-17 21:23:17 -07:00
co-authored by Orca
parent 5375c90b93
commit eb2f436977
6 changed files with 120 additions and 23 deletions
@@ -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',
@@ -1,6 +0,0 @@
export {
buildDismissedOnboardingFolderAgentStartup,
buildOnboardingFolderAgentStartup,
shouldSeedFolderAgentAfterDismissedOnboarding,
type OnboardingFolderAgentStartup
} from '@/lib/onboarding-folder-agent-startup'
@@ -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),
@@ -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)
+66 -1
View File
@@ -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',
+6 -1
View File
@@ -155,6 +155,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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