Add existing workspace migration telemetry (#2359)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-19 15:44:39 -07:00 committed by GitHub
parent fb4069be52
commit c856413d23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 373 additions and 18 deletions

View File

@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup'
@ -24,7 +25,8 @@ export function useCreateRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
setStep: (step: DialogStep) => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void
closeModal: () => void,
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void
) {
const [createName, setCreateName] = useState('')
const [createParent, setCreateParent] = useState('')
@ -128,6 +130,7 @@ export function useCreateRepo(
// Why: setAddedRepo only drives the git "setup" step; the folder
// branch closes the dialog, which resets addedRepo to null anyway.
setAddedRepo(repo)
setExistingWorkspaceSource?.('create_project')
await fetchWorktrees(repo.id)
if (gen !== createGenRef.current) {
return
@ -159,7 +162,16 @@ export function useCreateRepo(
setIsCreating(false)
}
}
}, [createName, createParent, createKind, fetchWorktrees, setStep, setAddedRepo, closeModal])
}, [
createName,
createParent,
createKind,
fetchWorktrees,
setStep,
setAddedRepo,
closeModal,
setExistingWorkspaceSource
])
return {
createName,

View File

@ -20,8 +20,16 @@ import { SetupStep } from './AddRepoSetupStep'
import { getDefaultCloneParent } from './clone-defaults'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type {
AddRepoExistingWorkspaceSource,
AddRepoSetupStepAction
} from '../../../../shared/telemetry-events'
import type { Repo, Worktree } from '../../../../shared/types'
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
import {
buildAddRepoExistingWorkspacesTelemetry,
shouldTrackAddRepoExistingWorkspacesDetected
} from './add-repo-existing-workspaces-telemetry'
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@ -38,6 +46,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
const [existingWorkspaceSource, setExistingWorkspaceSource] =
useState<AddRepoExistingWorkspaceSource | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [serverPath, setServerPath] = useState('')
const [isAddingServerPath, setIsAddingServerPath] = useState(false)
@ -68,7 +78,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
handleOpenRemoteStep,
handleAddRemoteRepo,
handleConnectTarget
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource)
const {
createName,
@ -83,7 +93,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
resetCreateState,
handlePickParent,
handleCreate
} = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
} = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource)
useEffect(() => {
if (!isCloning) {
return
@ -137,6 +147,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
void window.api.repos.cloneAbort()
setStep('add')
setAddedRepo(null)
setExistingWorkspaceSource(null)
setIsAdding(false)
setServerPath('')
setIsAddingServerPath(false)
@ -164,6 +175,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const repo = await addRepo()
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
setExistingWorkspaceSource('local_folder_picker')
await fetchWorktrees(repo.id)
setStep('setup')
} else if (repo) {
@ -186,6 +198,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const repo = await addRepoPath(path, kind)
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
setExistingWorkspaceSource('runtime_server_path')
await fetchWorktrees(repo.id)
setStep('setup')
} else if (repo) {
@ -257,6 +270,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
useAppStore.setState({ repos: updated })
}
setAddedRepo(repo)
setExistingWorkspaceSource('clone_url')
await fetchWorktrees(repo.id)
setStep('setup')
} catch (err) {
@ -272,18 +286,55 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
}, [cloneUrl, cloneDestination, fetchWorktrees])
const existingWorkspaceTelemetry = useMemo(
() => buildAddRepoExistingWorkspacesTelemetry(existingWorkspaceSource, sortedWorktrees),
[existingWorkspaceSource, sortedWorktrees]
)
const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (
step !== 'setup' ||
!repoId ||
!existingWorkspaceTelemetry ||
!shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) ||
detectedTelemetryTrackedRef.current.has(repoId)
) {
return
}
detectedTelemetryTrackedRef.current.add(repoId)
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
}, [existingWorkspaceSource, existingWorkspaceTelemetry, repoId, step])
const trackSetupAction = useCallback(
(action: AddRepoSetupStepAction): void => {
track('add_repo_setup_step_action', {
action,
...(existingWorkspaceTelemetry
? {
source: existingWorkspaceTelemetry.source,
existing_workspace_count: existingWorkspaceTelemetry.existing_workspace_count,
existing_linked_workspace_count:
existingWorkspaceTelemetry.existing_linked_workspace_count
}
: {})
})
},
[existingWorkspaceTelemetry]
)
const handleOpenWorktree = useCallback(
(worktree: Worktree) => {
track('add_repo_setup_step_action', { action: 'open_existing' })
trackSetupAction('open_existing')
activateAndRevealWorktree(worktree.id)
closeModal()
},
[closeModal]
[closeModal, trackSetupAction]
)
const handleCreateWorktree = useCallback(() => {
// Why: Setup-step "Create" affordance — fires on click intent, not on IPC arrival, mirroring the other 4 actions in this dialog.
track('add_repo_setup_step_action', { action: 'create_worktree' })
trackSetupAction('create_worktree')
// Why: small delay so the Add Project dialog close animation finishes before
// the composer modal takes focus; otherwise the dialog teardown can steal
// the first focus frame from the composer's prompt textarea.
@ -291,14 +342,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setTimeout(() => {
openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' })
}, 150)
}, [closeModal, openModal, repoId])
}, [closeModal, openModal, repoId, trackSetupAction])
const handleConfigureRepo = useCallback(() => {
track('add_repo_setup_step_action', { action: 'configure' })
trackSetupAction('configure')
closeModal()
openSettingsTarget({ pane: 'repo', repoId })
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, repoId])
}, [closeModal, openSettingsTarget, openSettingsPage, repoId, trackSetupAction])
const finishImportedRepoWithoutOpening = useCallback(async () => {
const importedRepoId = repoId
@ -317,18 +368,18 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const handleBack = resetState
const handleSkip = useCallback(() => {
track('add_repo_setup_step_action', { action: 'skip' })
trackSetupAction('skip')
void finishImportedRepoWithoutOpening()
}, [finishImportedRepoWithoutOpening])
}, [finishImportedRepoWithoutOpening, trackSetupAction])
// Why: only the Setup step's "Add another project" back arrow counts as a
// funnel event — the in-flight Back arrows on clone/remote/create are not
// a Setup-step affordance. Keeping the emit scoped to this handler avoids
// also tagging mid-clone backs.
const handleSetupStepBack = useCallback(() => {
track('add_repo_setup_step_action', { action: 'back' })
trackSetupAction('back')
handleBack()
}, [handleBack])
}, [handleBack, trackSetupAction])
return (
<Dialog
@ -340,7 +391,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
// Skip is handled on its own renderer-side click handler. Implicit closes
// on the Setup step are funnel-equivalent to Skip.
if (step === 'setup') {
track('add_repo_setup_step_action', { action: 'skip' })
trackSetupAction('skip')
void finishImportedRepoWithoutOpening()
return
}

View File

@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
@ -23,7 +24,8 @@ export function useRemoteRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void
closeModal: () => void,
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void
) {
const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([])
const [selectedTargetId, setSelectedTargetId] = useState<string | null>(null)
@ -126,6 +128,7 @@ export function useRemoteRepo(
toast.success('Remote project added', { description: repo.displayName })
setAddedRepo(repo)
setExistingWorkspaceSource?.('ssh_remote_path')
await fetchWorktrees(repo.id)
setStep('setup')
} catch (err) {
@ -145,7 +148,15 @@ export function useRemoteRepo(
} finally {
setIsAddingRemote(false)
}
}, [selectedTargetId, remotePath, fetchWorktrees, setStep, setAddedRepo, closeModal])
}, [
selectedTargetId,
remotePath,
fetchWorktrees,
setStep,
setAddedRepo,
closeModal,
setExistingWorkspaceSource
])
return {
sshTargets,

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import {
buildAddRepoExistingWorkspacesTelemetry,
shouldTrackAddRepoExistingWorkspacesDetected
} from './add-repo-existing-workspaces-telemetry'
function worktree(overrides: Partial<Worktree>): Worktree {
return {
id: 'repo::/repo',
repoId: 'repo',
path: '/repo',
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
describe('add repo existing workspace telemetry', () => {
it('builds count-only payloads without raw workspace names', () => {
const payload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [
worktree({ path: '/repo', displayName: 'main', branch: 'refs/heads/main' }),
worktree({
id: 'repo::/repo-feature',
path: '/repo-feature',
displayName: 'Feature With User Text',
branch: 'refs/heads/feature/private-task',
isMainWorktree: false,
isSparse: true
}),
worktree({
id: 'repo::/detached',
path: 'C:\\workspaces\\detached',
displayName: 'detached',
branch: '',
isMainWorktree: false
})
])
expect(payload).toEqual({
source: 'local_folder_picker',
existing_workspace_count: 3,
existing_linked_workspace_count: 2,
main_workspace_count: 1,
branch_named_workspace_count: 2,
detached_workspace_count: 1,
custom_named_workspace_count: 1,
sparse_workspace_count: 1
})
expect(JSON.stringify(payload)).not.toContain('private-task')
expect(JSON.stringify(payload)).not.toContain('Feature With User Text')
})
it('tracks detection only for imported linked workspaces', () => {
expect(buildAddRepoExistingWorkspacesTelemetry(null, [worktree({})])).toBeNull()
expect(buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [])).toBeNull()
const mainOnlyPayload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [
worktree({})
])
expect(mainOnlyPayload?.existing_linked_workspace_count).toBe(0)
expect(shouldTrackAddRepoExistingWorkspacesDetected(mainOnlyPayload)).toBe(false)
const importedLocalPayload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [
worktree({}),
worktree({ id: 'repo::/repo-existing', path: '/repo-existing', isMainWorktree: false })
])
const importedRemotePayload = buildAddRepoExistingWorkspacesTelemetry('ssh_remote_path', [
worktree({}),
worktree({ id: 'repo::/remote-existing', path: '/remote-existing', isMainWorktree: false })
])
const clonePayload = buildAddRepoExistingWorkspacesTelemetry('clone_url', [
worktree({}),
worktree({ id: 'repo::/clone-existing', path: '/clone-existing', isMainWorktree: false })
])
const createPayload = buildAddRepoExistingWorkspacesTelemetry('create_project', [
worktree({}),
worktree({ id: 'repo::/create-existing', path: '/create-existing', isMainWorktree: false })
])
expect(shouldTrackAddRepoExistingWorkspacesDetected(importedLocalPayload)).toBe(true)
expect(shouldTrackAddRepoExistingWorkspacesDetected(importedRemotePayload)).toBe(true)
expect(shouldTrackAddRepoExistingWorkspacesDetected(clonePayload)).toBe(false)
expect(shouldTrackAddRepoExistingWorkspacesDetected(createPayload)).toBe(false)
})
it('derives linked and detached counts before clamping reported values', () => {
const mainOnlyPayload = buildAddRepoExistingWorkspacesTelemetry(
'local_folder_picker',
Array.from({ length: 60 }, (_, index) =>
worktree({
id: `repo::/repo-main-${index}`,
path: `/repo-main-${index}`,
isMainWorktree: true
})
)
)
expect(mainOnlyPayload).toMatchObject({
existing_workspace_count: 50,
existing_linked_workspace_count: 0,
main_workspace_count: 50,
detached_workspace_count: 0
})
expect(shouldTrackAddRepoExistingWorkspacesDetected(mainOnlyPayload)).toBe(false)
})
})

View File

@ -0,0 +1,79 @@
import type {
AddRepoExistingWorkspaceSource,
EventProps
} from '../../../../shared/telemetry-events'
import type { Worktree } from '../../../../shared/types'
type ExistingWorkspacesDetectedProps = EventProps<'add_repo_existing_workspaces_detected'>
const MAX_REPORTED_WORKSPACES = 50
function countWorkspaces(count: number): number {
return Math.min(MAX_REPORTED_WORKSPACES, Math.max(0, count))
}
function branchDisplayName(worktree: Worktree): string {
return worktree.branch.replace(/^refs\/heads\//, '')
}
function pathBasename(pathValue: string): string {
return (
pathValue
.replace(/[\\/]+$/, '')
.split(/[\\/]/)
.filter(Boolean)
.at(-1) ?? ''
)
}
function isCustomDisplayName(worktree: Worktree): boolean {
const branchName = branchDisplayName(worktree)
const pathName = pathBasename(worktree.path)
return Boolean(
worktree.displayName && worktree.displayName !== branchName && worktree.displayName !== pathName
)
}
export function buildAddRepoExistingWorkspacesTelemetry(
source: AddRepoExistingWorkspaceSource | null,
worktrees: readonly Worktree[]
): ExistingWorkspacesDetectedProps | null {
if (!source || worktrees.length === 0) {
return null
}
const mainWorkspaceCount = worktrees.filter((worktree) => worktree.isMainWorktree).length
const branchNamedWorkspaceCount = worktrees.filter((worktree) =>
Boolean(branchDisplayName(worktree))
).length
const sparseWorkspaceCount = worktrees.filter((worktree) => worktree.isSparse === true).length
return {
source,
existing_workspace_count: countWorkspaces(worktrees.length),
existing_linked_workspace_count: countWorkspaces(worktrees.length - mainWorkspaceCount),
main_workspace_count: countWorkspaces(mainWorkspaceCount),
branch_named_workspace_count: countWorkspaces(branchNamedWorkspaceCount),
detached_workspace_count: countWorkspaces(worktrees.length - branchNamedWorkspaceCount),
custom_named_workspace_count: countWorkspaces(worktrees.filter(isCustomDisplayName).length),
sparse_workspace_count: countWorkspaces(sparseWorkspaceCount)
}
}
export function shouldTrackAddRepoExistingWorkspacesDetected(
payload: ExistingWorkspacesDetectedProps | null
): boolean {
// Track the import/discovery signal, not mere setup-modal exposure: the main
// checkout is always a worktree, but only non-main worktrees imply migration.
if (!payload || payload.existing_linked_workspace_count === 0) {
return false
}
// Clone/create produce a new project during this flow, so their setup step is
// not evidence of a pre-existing workspace migration opportunity.
return (
payload.source === 'local_folder_picker' ||
payload.source === 'runtime_server_path' ||
payload.source === 'ssh_remote_path'
)
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { eventSchemas } from './telemetry-events'
describe('add_repo_existing_workspaces_detected schema', () => {
it('accepts count-only workspace migration context', () => {
const parsed = eventSchemas.add_repo_existing_workspaces_detected.safeParse({
source: 'local_folder_picker',
existing_workspace_count: 3,
existing_linked_workspace_count: 2,
main_workspace_count: 1,
branch_named_workspace_count: 2,
detached_workspace_count: 1,
custom_named_workspace_count: 1,
sparse_workspace_count: 0,
nth_repo_added: 1
})
expect(parsed.success).toBe(true)
})
it('rejects raw workspace names via .strict()', () => {
const parsed = eventSchemas.add_repo_existing_workspaces_detected.safeParse({
source: 'local_folder_picker',
existing_workspace_count: 1,
existing_linked_workspace_count: 0,
main_workspace_count: 1,
branch_named_workspace_count: 1,
detached_workspace_count: 0,
custom_named_workspace_count: 0,
sparse_workspace_count: 0,
nth_repo_added: 1,
workspace_names: ['secret-customer-branch']
})
expect(parsed.success).toBe(false)
})
it('accepts setup start choices with bounded existing-workspace context', () => {
for (const action of ['open_existing', 'create_worktree'] as const) {
const parsed = eventSchemas.add_repo_setup_step_action.safeParse({
action,
source: 'ssh_remote_path',
existing_workspace_count: 4,
existing_linked_workspace_count: 3,
nth_repo_added: 1
})
expect(parsed.success).toBe(true)
}
})
})

View File

@ -101,6 +101,15 @@ export const addRepoSetupStepActionSchema = z.enum([
])
export type AddRepoSetupStepAction = z.infer<typeof addRepoSetupStepActionSchema>
export const addRepoExistingWorkspaceSourceSchema = z.enum([
'local_folder_picker',
'runtime_server_path',
'ssh_remote_path',
'clone_url',
'create_project'
])
export type AddRepoExistingWorkspaceSource = z.infer<typeof addRepoExistingWorkspaceSourceSchema>
// Deliberately a separate enum from `errorClassSchema` (PTY-spawn taxonomy):
// different domain — this one buckets git/filesystem failures thrown by
// `createLocalWorktree` / `createRemoteWorktree`. Merging the two would lock
@ -286,8 +295,32 @@ const featureWallTileClickedSchema = z
})
.strict()
const existingWorkspaceCountSchema = z.number().int().min(1).max(50)
const addRepoExistingWorkspaceContextSchema = {
source: addRepoExistingWorkspaceSourceSchema,
existing_workspace_count: existingWorkspaceCountSchema,
existing_linked_workspace_count: z.number().int().min(0).max(50)
} as const
const addRepoSetupStepActionEventSchema = z
.object({ action: addRepoSetupStepActionSchema, nth_repo_added: nthRepoAddedSchema })
.object({
action: addRepoSetupStepActionSchema,
source: addRepoExistingWorkspaceSourceSchema.optional(),
existing_workspace_count: existingWorkspaceCountSchema.optional(),
existing_linked_workspace_count: z.number().int().min(0).max(50).optional(),
nth_repo_added: nthRepoAddedSchema
})
.strict()
const addRepoExistingWorkspacesDetectedSchema = z
.object({
...addRepoExistingWorkspaceContextSchema,
main_workspace_count: z.number().int().min(0).max(50),
branch_named_workspace_count: z.number().int().min(0).max(50),
detached_workspace_count: z.number().int().min(0).max(50),
custom_named_workspace_count: z.number().int().min(0).max(50),
sparse_workspace_count: z.number().int().min(0).max(50),
nth_repo_added: nthRepoAddedSchema
})
.strict()
// Why: same enum-only discipline as `agent_error` — `.strict()` rejects raw
@ -710,6 +743,7 @@ export const eventSchemas = {
repo_added: repoAddedSchema,
add_repo_setup_step_action: addRepoSetupStepActionEventSchema,
add_repo_existing_workspaces_detected: addRepoExistingWorkspacesDetectedSchema,
workspace_created: workspaceCreatedSchema,
workspace_create_failed: workspaceCreateFailedSchema,
@ -791,6 +825,7 @@ type _CohortExtendedRoster =
| 'app_opened'
| 'repo_added'
| 'add_repo_setup_step_action'
| 'add_repo_existing_workspaces_detected'
| 'workspace_created'
| 'workspace_create_failed'
| 'agent_started'