Skip to content

Commit b5ab97f

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@3753e65929b9efab3266811485941173c32dc088
1 parent fb4c7ea commit b5ab97f

22 files changed

Lines changed: 1030 additions & 19 deletions

‎bun.lock‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cli/src/__tests__/unit/create-run-config.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { describe, test, expect } from 'bun:test'
1+
import { describe, test, expect, mock } from 'bun:test'
22

3+
import { createStreamController } from '../../hooks/stream-state'
4+
import { createMessageUpdater } from '../../utils/message-updater'
35
import { createRunConfig, isSensitiveFile } from '../../utils/create-run-config'
46

57
import type { CreateRunConfigParams } from '../../utils/create-run-config'
@@ -15,6 +17,68 @@ describe('createRunConfig', () => {
1517
signal: new AbortController().signal,
1618
} as unknown as CreateRunConfigParams)
1719

20+
test('analytics observers cannot break SDK callbacks and ignore lost chat ownership', () => {
21+
let active = true
22+
const observeEvent = mock(() => {
23+
throw new Error('capture unavailable')
24+
})
25+
const observeChunk = mock(() => {
26+
throw new Error('capture unavailable')
27+
})
28+
const run = createRunConfig({
29+
agent: 'custom',
30+
prompt: 'hi',
31+
content: undefined,
32+
previousRunState: null,
33+
agentDefinitions: [],
34+
signal: new AbortController().signal,
35+
logger: {
36+
info: () => {},
37+
debug: () => {},
38+
warn: () => {},
39+
error: () => {},
40+
},
41+
eventHandlerState: {
42+
isActive: () => active,
43+
streaming: {
44+
streamRefs: createStreamController(),
45+
setStreamingAgents: () => {},
46+
setStreamStatus: () => {},
47+
},
48+
message: {
49+
aiMessageId: 'ai-1',
50+
updater: createMessageUpdater('ai-1', () => {}),
51+
hasReceivedContentRef: { current: false },
52+
},
53+
subagents: {
54+
addActiveSubagent: () => {},
55+
removeActiveSubagent: () => {},
56+
},
57+
mode: { agentMode: 'DEFAULT', setHasReceivedPlanResponse: () => {} },
58+
logger: {
59+
info: () => {},
60+
debug: () => {},
61+
warn: () => {},
62+
error: () => {},
63+
},
64+
setIsRetrying: () => {},
65+
},
66+
observeEvent,
67+
observeChunk,
68+
})
69+
expect(() =>
70+
run.handleEvent({ type: 'start', messageHistoryLength: 0 }),
71+
).not.toThrow()
72+
expect(() => run.handleStreamChunk('root text')).not.toThrow()
73+
expect(observeEvent).toHaveBeenCalledTimes(1)
74+
expect(observeChunk).toHaveBeenCalledTimes(1)
75+
active = false
76+
run.handleEvent({ type: 'start', messageHistoryLength: 0 })
77+
run.handleStreamChunk('late text')
78+
expect(observeEvent).toHaveBeenCalledTimes(1)
79+
expect(observeChunk).toHaveBeenCalledTimes(1)
80+
})
81+
1882
test('skips the symbol index only for a base3 root', () => {
1983
expect(config('base3-free-deepseek-flash').skipSymbolIndex).toBe(true)
2084
expect(config('base3-fast-free-deepseek-flash').skipSymbolIndex).toBe(true)

‎cli/src/components/chat-input-bar.tsx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../state/profile-survey-store'
2828
import { readClipboardText } from '../utils/clipboard-image'
2929
import { getInputModeConfig } from '../utils/input-modes'
30+
import { captureCliFunnel, cliFunnelKey } from '../utils/cli-product-funnel'
3031
import { recordTypedInput } from '../utils/input-profile'
3132
import { sponsoredRunFor } from '../utils/sponsored-run'
3233
import { BORDER_CHARS } from '../utils/ui-constants'
@@ -131,6 +132,7 @@ export const ChatInputBar = ({
131132
onInterruptStream,
132133
}: ChatInputBarProps) => {
133134
const inputMode = useChatStore((state) => state.inputMode)
135+
const chatSessionId = useChatStore((state) => state.chatSessionId)
134136
const pendingSkillName = useChatStore((state) => state.pendingSkillName)
135137

136138
const baseModeConfig = getInputModeConfig(inputMode)
@@ -155,6 +157,31 @@ export const ChatInputBar = ({
155157
(state) => state.snapshot?.account ?? null,
156158
)
157159
const hasAnyPreview = hasSuggestionMenu
160+
const composerVisible =
161+
!sponsoredAccountAsk &&
162+
!feedbackMode &&
163+
!publishMode &&
164+
!askUserState &&
165+
!['outOfCredits', 'subscriptionLimit'].includes(inputMode)
166+
React.useEffect(() => {
167+
if (composerVisible)
168+
captureCliFunnel(
169+
'composer',
170+
'shown',
171+
{ thread_id: cliFunnelKey(chatSessionId) },
172+
chatSessionId,
173+
)
174+
}, [composerVisible, chatSessionId])
175+
const recordComposerTyped = useEvent((text: string) => {
176+
recordTypedInput(text)
177+
if (text.trim() && (inputMode === 'default' || inputMode === 'skill'))
178+
captureCliFunnel(
179+
'draft',
180+
'started',
181+
{ thread_id: cliFunnelKey(chatSessionId) },
182+
chatSessionId,
183+
)
184+
})
158185

159186
// In the home directory (or an ancestor) the file tree is only scanned a few
160187
// levels deep, so tell the user why deeper files don't show up.
@@ -439,7 +466,7 @@ export const ChatInputBar = ({
439466
onChange={setInputValue}
440467
onSubmit={handleSubmit}
441468
onPaste={handleSurveyPaste}
442-
onTypedText={recordTypedInput}
469+
onTypedText={recordComposerTyped}
443470
onKeyIntercept={handleKeyIntercept}
444471
placeholder={effectivePlaceholder}
445472
focused={inputFocused && !feedbackMode}
@@ -536,7 +563,7 @@ export const ChatInputBar = ({
536563
onChange={setInputValue}
537564
onSubmit={handleSubmit}
538565
onPaste={handleSurveyPaste}
539-
onTypedText={recordTypedInput}
566+
onTypedText={recordComposerTyped}
540567
onKeyIntercept={handleKeyIntercept}
541568
placeholder={effectivePlaceholder}
542569
focused={inputFocused && !feedbackMode}

‎cli/src/components/freebuff-chat-controls.tsx‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
selectFreebuffChatModel,
2424
requestFreebuffChatAdmission,
2525
} from '../state/freebuff-chat-store'
26+
import { captureCliFunnel } from '../utils/cli-product-funnel'
2627
import { isPlainEnterKey } from '../utils/terminal-enter-detection'
2728

2829
export function FreebuffChatControls() {
@@ -36,11 +37,25 @@ export function FreebuffChatControls() {
3637
const session = useFreebuffSessionStore((s) => s.session)
3738
const { clearQueue, queuePaused } = useChatRuntime()
3839
useEffect(() => {
39-
if (pickerOpen) void refreshFreebuffSessionMetadata().catch(() => {})
40+
if (pickerOpen) {
41+
captureCliFunnel('model_picker', 'shown', { model: nextModel ?? model })
42+
void refreshFreebuffSessionMetadata().catch(() => {})
43+
}
4044
}, [pickerOpen])
45+
useEffect(() => {
46+
if (!admission) return
47+
const state =
48+
admission.phase === 'failed'
49+
? 'failed'
50+
: admission.phase === 'starting'
51+
? 'started'
52+
: 'shown'
53+
captureCliFunnel('admission', state, { model: admission.model })
54+
}, [admission?.phase, admission?.model])
4155

4256
const cancel = useCallback(async () => {
4357
if (pickerOpen) {
58+
captureCliFunnel('model_picker', 'cancelled')
4459
useFreebuffChatStore.setState({ pickerOpen: false })
4560
return
4661
}
@@ -60,6 +75,7 @@ export function FreebuffChatControls() {
6075
return
6176
}
6277
}
78+
captureCliFunnel('admission', 'cancelled', { model: current.model })
6379
const queued = clearQueue()
6480
const chat = useChatStore.getState()
6581
const text = [...queued.map((m) => m.content), chat.inputValue]
@@ -81,6 +97,11 @@ export function FreebuffChatControls() {
8197

8298
const confirm = useCallback(() => {
8399
if (!admission) return
100+
if (
101+
session?.status === 'takeover_prompt' ||
102+
['confirm', 'failed'].includes(admission.phase)
103+
)
104+
captureCliFunnel('admission', 'clicked', { model: admission.model })
84105
if (session?.status === 'takeover_prompt') {
85106
void takeOverFreebuffSession()
86107
} else if (admission.phase === 'confirm') {
@@ -117,8 +138,20 @@ export function FreebuffChatControls() {
117138
maxHeight={Math.max(4, Math.floor(terminalHeight * 0.65) - 2)}
118139
selectedModelOverride={nextModel ?? model}
119140
initialView={pickerInitialView}
120-
onSelectModel={selectFreebuffChatModel}
121-
onCancel={() => useFreebuffChatStore.setState({ pickerOpen: false })}
141+
onSelectModel={(selected) => {
142+
selectFreebuffChatModel(selected)
143+
if (!useFreebuffChatStore.getState().pickerOpen)
144+
captureCliFunnel('model_selection', 'selected', {
145+
model: selected,
146+
previous_model: nextModel ?? model,
147+
selection_kind:
148+
selected === (nextModel ?? model) ? 'reselected' : 'changed',
149+
})
150+
}}
151+
onCancel={() => {
152+
captureCliFunnel('model_picker', 'cancelled')
153+
useFreebuffChatStore.setState({ pickerOpen: false })
154+
}}
122155
/>
123156
</box>
124157
)

‎cli/src/components/login-modal.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation'
1818
import { useTheme } from '../hooks/use-theme'
1919
import { formatUrl, calculateResponsiveLayout } from '../login/utils'
2020
import { useLoginStore } from '../state/login-store'
21+
import { captureCliFunnel } from '../utils/cli-product-funnel'
2122
import { IS_FREEBUFF } from '../utils/constants'
2223
import { copyTextToClipboard, isRemoteSession } from '../utils/clipboard'
2324
import { getFingerprintId } from '../utils/fingerprint'
@@ -36,6 +37,7 @@ export const LoginModal = ({
3637
hasInvalidCredentials = false,
3738
}: LoginModalProps) => {
3839
const renderer = useRenderer()
40+
useEffect(() => captureCliFunnel('login', 'shown'), [])
3941
const theme = useTheme()
4042

4143
// Use zustand store for all state

‎cli/src/components/project-picker-screen.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os from 'os'
22

3-
import React, { useCallback, useMemo, useState } from 'react'
3+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { Button } from './button'
66
import { MultilineInput } from './multiline-input'
@@ -13,6 +13,7 @@ import { useSearchableList } from '../hooks/use-searchable-list'
1313
import { useSheenAnimation } from '../hooks/use-sheen-animation'
1414
import { useTerminalLayout } from '../hooks/use-terminal-layout'
1515
import { useTheme } from '../hooks/use-theme'
16+
import { captureCliFunnel } from '../utils/cli-product-funnel'
1617
import { exitCliCleanly } from '../utils/exit-cleanly'
1718
import { formatCwd } from '../utils/path-helpers'
1819
import { loadRecentProjects } from '../utils/recent-projects'
@@ -62,6 +63,9 @@ export const ProjectPickerScreen: React.FC<ProjectPickerScreenProps> = ({
6263
initialPath,
6364
}) => {
6465
const theme = useTheme()
66+
useEffect(() => {
67+
captureCliFunnel('context', 'shown', { project_kind: 'folder' })
68+
}, [])
6569
const [sheenPosition, setSheenPosition] = useState(0)
6670

6771
// Directory browsing state and navigation

‎cli/src/hooks/use-auth-state.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
44
import { useAuthQuery, useLogoutMutation } from './use-auth-query'
55
import { useChatStore } from '../state/chat-store'
66
import { useLoginStore } from '../state/login-store'
7+
import { captureCliFunnel } from '../utils/cli-product-funnel'
78
import { identifyUser, trackEvent } from '../utils/analytics'
89
import { getUserCredentials } from '../utils/auth'
910
import { resetCodebuffClient } from '../utils/codebuff-client'
@@ -80,6 +81,7 @@ export const useAuthState = ({
8081
// the login modal, so `cli.login` would otherwise never fire for them —
8182
// making them look like a login-step drop-off in the funnel even though
8283
// they're authenticated. Emit it here too, tagged by `via`.
84+
captureCliFunnel('login', 'succeeded', { restored: true }, 'restore')
8385
trackEvent(AnalyticsEvent.LOGIN, {
8486
userId: authQuery.data.id,
8587
via: 'session_restore',
@@ -106,6 +108,8 @@ export const useAuthState = ({
106108
})
107109
}
108110

111+
captureCliFunnel('login', 'succeeded', { restored: false })
112+
109113
// Track successful login
110114
trackEvent(AnalyticsEvent.LOGIN, {
111115
userId: loggedInUser.id,

‎cli/src/hooks/use-fetch-login-url.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { safeOpen } from '../utils/open-url'
44

55
import { LOGIN_WEBSITE_URL } from '../login/constants'
66
import { generateLoginUrl } from '../login/login-flow'
7+
import { captureCliFunnel } from '../utils/cli-product-funnel'
78
import { trackEvent } from '../utils/analytics'
89
import { logger } from '../utils/logger'
910
import { IS_FREEBUFF } from '../utils/constants'
@@ -30,6 +31,7 @@ export function useFetchLoginUrl({
3031
}: UseFetchLoginUrlParams) {
3132
const fetchLoginUrlMutation = useMutation({
3233
mutationFn: async (fingerprintId: string) => {
34+
captureCliFunnel('login', 'started')
3335
return generateLoginUrl(
3436
{
3537
logger,
@@ -44,16 +46,19 @@ export function useFetchLoginUrl({
4446
)
4547
},
4648
onSuccess: async (data) => {
49+
captureCliFunnel('login', 'ready')
4750
setLoginUrl(data.loginUrl)
4851
setFingerprintHash(data.fingerprintHash)
4952
setExpiresAt(data.expiresAt)
5053
setIsWaitingForEnter(true)
5154
setHasOpenedBrowser(true)
5255

53-
// Open browser after fetching URL
54-
await safeOpen(data.loginUrl)
56+
// OS handoff cannot confirm the browser loaded the auth page.
57+
const opened = await safeOpen(data.loginUrl)
58+
captureCliFunnel('browser', opened ? 'opened' : 'failed')
5559
},
5660
onError: (err) => {
61+
captureCliFunnel('login', 'failed', { reason: 'error' })
5762
setError(err instanceof Error ? err.message : 'Failed to get login URL')
5863
logger.error(
5964
{

‎cli/src/hooks/use-login-polling.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
22

33
import { LOGIN_WEBSITE_URL } from '../login/constants'
44
import { pollLoginStatus } from '../login/login-flow'
5+
import { captureCliFunnel } from '../utils/cli-product-funnel'
56
import { trackEvent } from '../utils/analytics'
67
import { logger } from '../utils/logger'
78

@@ -88,6 +89,7 @@ export function useLoginPolling({
8889
const user = result.user as User
8990
onSuccessRef.current(user)
9091
} else if (result.status === 'timeout') {
92+
captureCliFunnel('login', 'failed', { reason: 'timeout' })
9193
logger.warn('Login polling timed out after configured limit')
9294
onTimeoutRef.current()
9395
}

0 commit comments

Comments
 (0)