mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): PostHog usage analytics across all feature surfaces
Adds feature-level instrumentation so dashboards can answer "how important is X and what do people do inside it": - view_opened fired centrally from the currentViewState effect — one event per section visit (email/meetings/bg-tasks/apps/...), plus one-shot has_used_* person properties for cohorts - Email: thread opened, compose opened, sent (mode/attachments/ ai_assisted), AI draft generated, archived, trashed, marked unread, importance/category corrections, bulk category archive, search, instructions saved, manual sync, send failures - Meetings: recording started/stopped (duration), popup action (captured in main — popup window has no PostHog), note opened, summarize failures - Calls: call_ended with duration (call_started already existed) - Background agents: created (manual/coding/copilot), updated, toggled, run clicked, stopped, deleted; run completed/failed with trigger captured in the core runner for a true failure rate - Live notes: saved, toggled, run, stopped, deleted, edit-with-copilot - Code mode: session created (direct vs rowboat, agent) and direct-drive messages (direct mode emits no llm_usage otherwise) - Billing: paywall shown / upgrade clicked by error kind - Search: opened + result selected; Settings: opened + tab changed - Notes: created; edited (deduped to one event per note per session) - Apps: app_rolled_back (rest were already captured in main) All changes are additive — existing events, identity flow, and person properties are untouched. ANALYTICS.md updated with the full catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f68f546223
commit
86589dbe50
15 changed files with 423 additions and 3 deletions
|
|
@ -96,6 +96,93 @@ All in `apps/renderer/src/lib/analytics.ts`:
|
|||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
### `view_opened` — feature-importance funnel
|
||||
|
||||
One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire.
|
||||
|
||||
This is the top of every feature funnel: unique users on `view = 'email'` ÷ all users = how many people even open email. First visit to a key view also sets a one-shot person property (`has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code`) for cohort building.
|
||||
|
||||
### Feature action events
|
||||
|
||||
All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers); the emit sites are in the components named below. Events marked **(main)** are captured in `apps/main/src/ipc.ts` via `capture()` because the operation runs there.
|
||||
|
||||
**Email** (`components/email-view.tsx`):
|
||||
|
||||
- `email_thread_opened` — a thread was expanded in the list
|
||||
- `email_compose_opened` — `{ mode: 'new' | 'reply' | 'replyAll' | 'forward' | 'draft' }` — a composer was opened
|
||||
- `email_sent` — `{ mode, has_attachments, ai_assisted }` — `ai_assisted` is true when Write-with-AI produced a draft in that composer
|
||||
- `email_ai_draft_generated` — `{ mode: 'generate' | 'rewrite' }` — the Write/Edit-with-AI bar completed
|
||||
- `email_archived` / `email_trashed` — one thread archived / moved to trash
|
||||
- `email_marked_unread` — explicit mark-as-unread (marking *read* fires automatically on open, so it's deliberately not tracked)
|
||||
- `email_importance_changed` — `{ importance: 'important' | 'other' }` — user corrected the importance verdict
|
||||
- `email_category_changed` — `{ category }` — user re-filed a thread
|
||||
- `email_category_archived` — `{ category }` — bulk "archive all in category"
|
||||
- `email_searched` — a search query executed (debounced, one per settled query)
|
||||
- `email_instructions_saved` — standing email-agent instructions saved
|
||||
- `email_sync_triggered` — manual refresh button
|
||||
|
||||
**Meetings** (`App.tsx`, `components/meetings-view.tsx`):
|
||||
|
||||
- `meeting_recording_started` — `{ has_calendar_event }` — transcription actually began (all entry points: meetings view, home, sidebar, popup funnel through one call site)
|
||||
- `meeting_recording_stopped` — `{ duration_seconds }`
|
||||
- `meeting_popup_action` — `{ action: 'take-notes' | 'dismiss' }` **(main)** — the "meeting detected" popup window runs without PostHog, so the action is captured in its IPC handler
|
||||
- `meeting_note_opened` — a past meeting note opened from the meetings list
|
||||
|
||||
**Calls** (`App.tsx`):
|
||||
|
||||
- `call_started` — (pre-existing, above) fires on every call-button press that starts a call
|
||||
- `call_ended` — `{ duration_seconds }`
|
||||
|
||||
**Background agents** (`components/bg-tasks-view.tsx`, `components/apps/app-detail.tsx`):
|
||||
|
||||
- `bg_agent_created` — `{ method: 'manual' | 'coding' | 'copilot', has_triggers }` — `copilot` means the user submitted the "describe it" form (the agent is then created by Copilot in chat)
|
||||
- `bg_agent_updated` — instructions/triggers/model saved on an existing agent
|
||||
- `bg_agent_toggled` — `{ active }`
|
||||
- `bg_agent_run_clicked` — manual Run now
|
||||
- `bg_agent_stopped` — manual stop of a run
|
||||
- `bg_agent_deleted`
|
||||
|
||||
**Live notes** (`components/live-note-sidebar.tsx`, `components/live-notes-view.tsx`):
|
||||
|
||||
- `live_note_saved` — live config created or edited via the panel
|
||||
- `live_note_toggled` — `{ active }`
|
||||
- `live_note_run_clicked` — manual Run
|
||||
- `live_note_stopped` — in-flight run stopped
|
||||
- `live_note_deleted` — live config removed from the note
|
||||
- `live_note_edit_with_copilot_clicked`
|
||||
|
||||
**Search** (`components/search-dialog.tsx`):
|
||||
|
||||
- `search_opened` — the palette opened
|
||||
- `search_executed` — (pre-existing, above)
|
||||
- `search_result_selected` — `{ type: 'knowledge' | 'chat' }`
|
||||
|
||||
**Apps** — all **(main)**, in `apps/main/src/ipc.ts` (pre-existing except `app_rolled_back`): `app_created`, `app_installed`, `app_uninstalled`, `app_updated`, `app_rolled_back`, `app_published`, `app_starred`, `app_deleted`. Plus renderer-side `app_opened` — `{ folder }` — an installed app's UI was opened (`components/apps/app-frame.tsx`).
|
||||
|
||||
**Code mode** — both **(main/core)**:
|
||||
|
||||
- `code_session_created` — `{ mode: 'direct' | 'rowboat', agent }` — captured in the `codeSession:create` IPC handler. This is the direct-vs-rowboat session split.
|
||||
- `code_session_message_sent` — `{ mode, agent }` — one per direct-drive message (`packages/core/src/code-mode/sessions/service.ts`). Direct turns bypass the agent runtime and emit no `llm_usage`, so this is the only usage-depth signal for direct mode; Rowboat-mode depth comes from `llm_usage where use_case = code_session`.
|
||||
|
||||
**Billing** (`components/billing-error-dialog.tsx`):
|
||||
|
||||
- `billing_error_shown` — `{ kind: 'subscription_required' | 'out_of_credits' | 'subscription_inactive' }` — the paywall dialog appeared
|
||||
- `billing_upgrade_clicked` — `{ kind }` — the upgrade CTA was clicked (shown → clicked = paywall conversion)
|
||||
|
||||
**Failures** — success events all have a failure sibling where the operation can fail after the click:
|
||||
|
||||
- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`)
|
||||
- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`)
|
||||
- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks
|
||||
|
||||
**Misc**:
|
||||
|
||||
- `note_created` — new note from the sidebar/knowledge actions (`App.tsx`)
|
||||
- `note_edited` — a note's autosave wrote changed content; deduped to one event per note per app session (so it counts "notes touched", not keystroke bursts)
|
||||
- `settings_opened` — `{ tab }` — settings dialog opened (tab = the initial tab)
|
||||
- `settings_tab_changed` — `{ tab }`
|
||||
- `onboarding_completed` — the onboarding flow finished (`App.tsx`)
|
||||
|
||||
## Person properties
|
||||
|
||||
Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
|
||||
|
|
@ -110,6 +197,8 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
|
|||
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
||||
| `total_notes` | renderer (init) | Workspace size signal |
|
||||
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
|
||||
| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view |
|
||||
| `has_created_bg_agent` | renderer | One-shot: user set up a background agent |
|
||||
|
||||
## How to add a new event
|
||||
|
||||
|
|
|
|||
|
|
@ -908,6 +908,8 @@ export function setupIpcHandlers() {
|
|||
return { payload: getMeetingPopupPayload() };
|
||||
},
|
||||
'meetingDetect:action': async (_event, args) => {
|
||||
// Captured here because the popup window runs without PostHog.
|
||||
capture('meeting_popup_action', { action: args.action });
|
||||
handleMeetingPopupAction(args.action);
|
||||
return {};
|
||||
},
|
||||
|
|
@ -1333,6 +1335,7 @@ export function setupIpcHandlers() {
|
|||
'codeSession:create': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
const session = await service.create(args);
|
||||
capture('code_session_created', { mode: session.mode, agent: session.agent });
|
||||
return { session };
|
||||
},
|
||||
'codeSession:list': async () => {
|
||||
|
|
@ -1845,7 +1848,9 @@ export function setupIpcHandlers() {
|
|||
return { app };
|
||||
},
|
||||
'apps:rollback': async (_event, args) => {
|
||||
return { app: await appsInstaller.rollbackApp(args.folder) };
|
||||
const app = await appsInstaller.rollbackApp(args.folder);
|
||||
capture('app_rolled_back', { folder: args.folder });
|
||||
return { app };
|
||||
},
|
||||
'apps:publish': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,8 @@ function App() {
|
|||
// the call button's main click) is "work together": screen shared, camera
|
||||
// off, floating pill — the user keeps working while the assistant watches
|
||||
// along. 'video'/'practice' open face-to-face full screen instead.
|
||||
const callStartedAtMsRef = useRef<number | null>(null)
|
||||
|
||||
const startCall = useCallback(async (preset: CallPreset) => {
|
||||
if (inCallRef.current) return
|
||||
const camera = preset === 'video' || preset === 'practice'
|
||||
|
|
@ -1261,11 +1263,15 @@ function App() {
|
|||
setCallMinimized(preset === 'voice' || preset === 'share')
|
||||
inCallRef.current = true
|
||||
setInCall(true)
|
||||
callStartedAtMsRef.current = performance.now()
|
||||
analytics.callStarted(preset)
|
||||
}, [video])
|
||||
|
||||
const endCall = useCallback(() => {
|
||||
if (!inCallRef.current) return
|
||||
const startedAt = callStartedAtMsRef.current
|
||||
callStartedAtMsRef.current = null
|
||||
analytics.callEnded(startedAt != null ? (performance.now() - startedAt) / 1000 : 0)
|
||||
voiceRef.current.cancel()
|
||||
ttsEnabledRef.current = false
|
||||
ttsModeRef.current = 'summary'
|
||||
|
|
@ -2270,6 +2276,7 @@ function App() {
|
|||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
markRecentLocalMarkdownWrite(pathToSave)
|
||||
analytics.noteEdited(pathToSave)
|
||||
// Store body-only baseline (matches what debouncedContent compares against)
|
||||
initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body)
|
||||
|
||||
|
|
@ -4082,6 +4089,12 @@ function App() {
|
|||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
|
||||
|
||||
// Feature-importance funnel: one event per view the user lands on. Keyed on
|
||||
// the view *type* so switching files/threads inside a view doesn't re-fire.
|
||||
useEffect(() => {
|
||||
analytics.viewOpened(currentViewState.type)
|
||||
}, [currentViewState.type])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
if (last && viewStatesEqual(last, entry)) return stack
|
||||
|
|
@ -5321,6 +5334,7 @@ function App() {
|
|||
} catch (err) {
|
||||
console.error('Failed to mark onboarding complete:', err)
|
||||
}
|
||||
analytics.onboardingCompleted()
|
||||
setShowOnboarding(false)
|
||||
if (opts?.startTour) {
|
||||
window.setTimeout(() => setTourActive(true), 400)
|
||||
|
|
@ -5345,6 +5359,7 @@ function App() {
|
|||
data: `# ${name}\n\n`,
|
||||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
analytics.noteCreated()
|
||||
setExpandedPaths(prev => new Set([...prev, parentPath]))
|
||||
navigateToFile(fullPath)
|
||||
} catch (err) {
|
||||
|
|
@ -5600,6 +5615,7 @@ function App() {
|
|||
}, [loadDirectory, navigateToFile, fileTabs])
|
||||
|
||||
const meetingNotePathRef = useRef<string | null>(null)
|
||||
const meetingRecordingStartedAtMsRef = useRef<number | null>(null)
|
||||
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
|
||||
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
||||
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
||||
|
|
@ -5613,6 +5629,8 @@ function App() {
|
|||
setRecordingMeetingSource(calEvent?.source ?? null)
|
||||
const notePath = await meetingTranscription.start(calEvent)
|
||||
if (notePath) {
|
||||
meetingRecordingStartedAtMsRef.current = performance.now()
|
||||
analytics.meetingRecordingStarted(Boolean(calEvent))
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
}
|
||||
|
|
@ -5638,6 +5656,9 @@ function App() {
|
|||
const handleToggleMeeting = useCallback(async () => {
|
||||
if (meetingTranscription.state === 'recording') {
|
||||
await meetingTranscription.stop()
|
||||
const recordingStartedAt = meetingRecordingStartedAtMsRef.current
|
||||
meetingRecordingStartedAtMsRef.current = null
|
||||
analytics.meetingRecordingStopped(recordingStartedAt != null ? (performance.now() - recordingStartedAt) / 1000 : 0)
|
||||
setRecordingMeetingSource(null)
|
||||
|
||||
// Read the final transcript and generate meeting notes via LLM
|
||||
|
|
@ -5685,6 +5706,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
analytics.meetingSummarizeFailed()
|
||||
console.error('[meeting] Failed to generate meeting notes:', err)
|
||||
}
|
||||
setMeetingSummarizing(false)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
|
||||
// App detail panel (spec §14): manifest info, provenance/publish state,
|
||||
// bundled agents with enable toggles, rollback when available. Update/publish
|
||||
|
|
@ -126,12 +127,14 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a)))
|
||||
try {
|
||||
await window.ipc.invoke('bg-task:patch', { slug, partial: { active } })
|
||||
analytics.bgAgentToggled(active)
|
||||
} catch {
|
||||
setReloadNonce((n) => n + 1) // revert to truth on failure
|
||||
}
|
||||
}
|
||||
|
||||
const runAgent = async (slug: string) => {
|
||||
analytics.bgAgentRunClicked()
|
||||
try {
|
||||
await window.ipc.invoke('bg-task:run', { slug })
|
||||
} catch { /* surfaced via bg-task UI */ }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
|
||||
|
|
@ -364,6 +365,7 @@ function NewTaskDialog({
|
|||
...(projectId ? { projectId } : {}),
|
||||
})
|
||||
if (result.success && result.slug) {
|
||||
analytics.bgAgentCreated({ method: 'coding', hasTriggers: Boolean(triggers) })
|
||||
onCreated(result.slug)
|
||||
} else {
|
||||
toast(result.error ?? 'Failed to create task', 'error')
|
||||
|
|
@ -377,6 +379,7 @@ function NewTaskDialog({
|
|||
|
||||
const submitDescribe = () => {
|
||||
if (!canSubmitDescribe || !onCreateWithCopilot) return
|
||||
analytics.bgAgentCreated({ method: 'copilot', hasTriggers: false })
|
||||
onCreateWithCopilot(description.trim())
|
||||
onClose()
|
||||
}
|
||||
|
|
@ -391,6 +394,7 @@ function NewTaskDialog({
|
|||
...(triggers ? { triggers } : {}),
|
||||
})
|
||||
if (result.success && result.slug) {
|
||||
analytics.bgAgentCreated({ method: 'manual', hasTriggers: Boolean(triggers) })
|
||||
onCreated(result.slug)
|
||||
} else {
|
||||
toast(result.error ?? 'Failed to create task', 'error')
|
||||
|
|
@ -1467,6 +1471,7 @@ function TaskDetail({
|
|||
if (draft.provider !== task.provider) partial.provider = draft.provider
|
||||
const result = await window.ipc.invoke('bg-task:patch', { slug, partial })
|
||||
if (result.success && result.task) {
|
||||
analytics.bgAgentUpdated()
|
||||
setTask(result.task)
|
||||
setDraft(result.task)
|
||||
setEditingInstructions(false)
|
||||
|
|
@ -1488,12 +1493,14 @@ function TaskDetail({
|
|||
if (!task) return
|
||||
const result = await window.ipc.invoke('bg-task:patch', { slug, partial: { active } })
|
||||
if (result.success && result.task) {
|
||||
analytics.bgAgentToggled(active)
|
||||
setTask(result.task)
|
||||
setDraft(result.task)
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
analytics.bgAgentRunClicked()
|
||||
const result = await window.ipc.invoke('bg-task:run', { slug })
|
||||
if (!result.success) {
|
||||
toast(result.error ?? 'Run failed', 'error')
|
||||
|
|
@ -1501,12 +1508,14 @@ function TaskDetail({
|
|||
}
|
||||
|
||||
const stopRun = async () => {
|
||||
analytics.bgAgentStopped()
|
||||
await window.ipc.invoke('bg-task:stop', { slug })
|
||||
}
|
||||
|
||||
const deleteTask = async () => {
|
||||
const result = await window.ipc.invoke('bg-task:delete', { slug })
|
||||
if (result.success) {
|
||||
analytics.bgAgentDeleted()
|
||||
onDeleted()
|
||||
} else {
|
||||
toast(result.error ?? 'Delete failed', 'error')
|
||||
|
|
@ -1665,6 +1674,7 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu
|
|||
toast(result.error ?? 'Failed to update task', 'error')
|
||||
return
|
||||
}
|
||||
analytics.bgAgentToggled(active)
|
||||
// Optimistically reflect the new state without re-fetching the whole list.
|
||||
setItems(prev => prev.map(t => t.slug === slug ? { ...t, active } : t))
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { BillingErrorMatch } from "@/lib/billing-error"
|
||||
import * as analytics from "@/lib/analytics"
|
||||
|
||||
interface BillingRowboatAccount {
|
||||
config?: {
|
||||
|
|
@ -33,9 +34,14 @@ export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDi
|
|||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && match) analytics.billingErrorShown(match.kind)
|
||||
}, [open, match])
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const handleUpgrade = () => {
|
||||
analytics.billingUpgradeClicked(match.kind)
|
||||
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Placeholder from '@tiptap/extension-placeholder'
|
|||
import type { blocks } from '@x/shared'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { prepareEmailHtml, splitPlainTextQuote, stripQuotedReplyText, QUOTED_CLASS } from '@/lib/email-quotes'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
|
|
@ -1167,6 +1168,13 @@ const ComposeBox = memo(function ComposeBox({
|
|||
// always calls the latest send closure; assigned below sendInGmail.
|
||||
const sendRef = useRef<() => void>(() => {})
|
||||
|
||||
// True once Write-with-AI produced a draft in this composer; sent with email_sent.
|
||||
const aiUsedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
analytics.emailComposeOpened(mode)
|
||||
}, [mode])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false }),
|
||||
|
|
@ -1333,6 +1341,8 @@ const ComposeBox = memo(function ComposeBox({
|
|||
}
|
||||
// Replace via a tracked transaction (selectAll + insertContent) so the AI
|
||||
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
|
||||
aiUsedRef.current = true
|
||||
analytics.emailAiDraftGenerated(aiMode)
|
||||
if (aiMode === 'generate') {
|
||||
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
|
||||
// Only new emails take the AI's subject; replies/forwards keep their
|
||||
|
|
@ -1625,9 +1635,11 @@ const ComposeBox = memo(function ComposeBox({
|
|||
})
|
||||
if (result.error) {
|
||||
sentRef.current = false // allow autosave to resume on a failed send
|
||||
analytics.emailSendFailed()
|
||||
toast(`Send failed: ${result.error}`, 'error')
|
||||
return
|
||||
}
|
||||
analytics.emailSent({ mode, hasAttachments: attachments.length > 0, aiAssisted: aiUsedRef.current })
|
||||
// Gmail only auto-cleans drafts on a threaded send; remove any draft we
|
||||
// autosaved for a brand-new message so it doesn't linger after sending.
|
||||
const leftover = draftIdRef.current
|
||||
|
|
@ -1640,6 +1652,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
onClose()
|
||||
} catch (err) {
|
||||
sentRef.current = false
|
||||
analytics.emailSendFailed()
|
||||
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
} finally {
|
||||
setSending(false)
|
||||
|
|
@ -2356,6 +2369,7 @@ function EmailInstructionsDialog({ open, onOpenChange, onSaved }: { open: boolea
|
|||
try {
|
||||
const result = await window.ipc.invoke('gmail:setEmailInstructions', { instructions: text })
|
||||
if (result.ok) {
|
||||
analytics.emailInstructionsSaved()
|
||||
toast('Instructions saved — they apply to every email from now on.', 'success')
|
||||
onOpenChange(false)
|
||||
onSaved?.()
|
||||
|
|
@ -2657,6 +2671,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('gmail:search', { query: q, limit: 100 })
|
||||
analytics.emailSearched()
|
||||
if (searchEpoch.current !== epoch) return
|
||||
setSearchError(res.error ?? null)
|
||||
setSearchResults(res.threads ?? [])
|
||||
|
|
@ -2755,6 +2770,9 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
}))
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId, read })
|
||||
// Marking read happens automatically on open; only the explicit
|
||||
// mark-as-unread gesture is worth an event.
|
||||
if (!read) analytics.emailMarkedUnread()
|
||||
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] mark-read failed:', err)
|
||||
|
|
@ -2771,6 +2789,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)),
|
||||
])
|
||||
if (result.ok) {
|
||||
analytics.emailArchived()
|
||||
removeThreadFromState(threadId)
|
||||
} else if (result.error) {
|
||||
toast(`Archive failed: ${result.error}`, 'error')
|
||||
|
|
@ -2794,6 +2813,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)),
|
||||
])
|
||||
if (result.ok) {
|
||||
analytics.emailImportanceChanged(importance)
|
||||
const from = importance === 'important' ? 'other' as const : 'important' as const
|
||||
setSection(from, (prev) => ({ ...prev, threads: prev.threads.filter((t) => t.threadId !== threadId) }))
|
||||
if (source) {
|
||||
|
|
@ -2819,6 +2839,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
try {
|
||||
const result = await window.ipc.invoke('gmail:setCategory', { threadId, category })
|
||||
if (result.ok) {
|
||||
analytics.emailCategoryChanged(category)
|
||||
updateThreadInState(threadId, (t) => ({ ...t, category }))
|
||||
setCategoryCounts((prev) => {
|
||||
const next = { ...prev }
|
||||
|
|
@ -2848,6 +2869,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)),
|
||||
])
|
||||
if (result.ok) {
|
||||
analytics.emailTrashed()
|
||||
removeThreadFromState(threadId)
|
||||
} else if (result.error) {
|
||||
toast(`Delete failed: ${result.error}`, 'error')
|
||||
|
|
@ -2864,6 +2886,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
setSelectedThreadId((current) => {
|
||||
const next = current === thread.threadId ? null : thread.threadId
|
||||
if (next) {
|
||||
analytics.emailThreadOpened()
|
||||
setOpenedThreadIds((prev) => {
|
||||
const without = prev.filter((id) => id !== next)
|
||||
return [...without, next].slice(-MAX_KEPT_OPEN)
|
||||
|
|
@ -3004,6 +3027,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
if (result.error) {
|
||||
toast(`Bulk archive failed: ${result.error}`, 'error')
|
||||
} else {
|
||||
analytics.emailCategoryArchived(category)
|
||||
toast(
|
||||
`Archived ${result.archived} thread${result.archived === 1 ? '' : 's'}${result.failed ? ` (${result.failed} failed)` : ''}. They stay searchable in Gmail.`,
|
||||
result.failed ? 'error' : 'success',
|
||||
|
|
@ -3113,6 +3137,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
|
|||
setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
analytics.emailSyncTriggered()
|
||||
await window.ipc.invoke('gmail:triggerSync', {})
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] triggerSync failed:', err)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import '@/styles/live-note-panel.css'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
|
@ -166,6 +167,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
setError(res.error ?? 'Save failed')
|
||||
return
|
||||
}
|
||||
analytics.liveNoteSaved()
|
||||
setLive(res.live ?? null)
|
||||
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
|
||||
setEditingObjective(false)
|
||||
|
|
@ -195,6 +197,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
setError(res.error ?? 'Failed')
|
||||
return
|
||||
}
|
||||
analytics.liveNoteToggled(live.active === false)
|
||||
setLive(res.live ?? null)
|
||||
setDraft(res.live ? structuredClone(res.live) as LiveNote : null)
|
||||
} catch (err) {
|
||||
|
|
@ -207,6 +210,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
const handleRun = useCallback(async () => {
|
||||
if (!knowledgeRelPath) return
|
||||
setError(null)
|
||||
analytics.liveNoteRunClicked()
|
||||
try {
|
||||
await window.ipc.invoke('live-note:run', { filePath: knowledgeRelPath })
|
||||
} catch (err) {
|
||||
|
|
@ -219,6 +223,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelPath })
|
||||
if (res.success) analytics.liveNoteStopped()
|
||||
if (!res.success && res.error) setError(res.error)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
|
|
@ -235,6 +240,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
setError(res.error ?? 'Delete failed')
|
||||
return
|
||||
}
|
||||
analytics.liveNoteDeleted()
|
||||
setLive(null)
|
||||
setDraft(null)
|
||||
setConfirmingDelete(false)
|
||||
|
|
@ -247,6 +253,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) {
|
|||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!filePath) return
|
||||
analytics.liveNoteEditWithCopilotClicked()
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-live-note', {
|
||||
detail: { filePath },
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Switch } from '@/components/ui/switch'
|
|||
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
|
||||
type LiveNoteRow = {
|
||||
|
|
@ -145,6 +146,7 @@ export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewPro
|
|||
throw new Error(result.error ?? 'Failed to update live-note state')
|
||||
}
|
||||
|
||||
analytics.liveNoteToggled(active)
|
||||
setNotes((prev) => prev.map((entry) => (
|
||||
entry.path === note.path
|
||||
? {
|
||||
|
|
@ -173,6 +175,7 @@ export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewPro
|
|||
try {
|
||||
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
|
||||
const result = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelative })
|
||||
if (result.success) analytics.liveNoteStopped()
|
||||
if (!result.success && result.error) {
|
||||
toast(result.error, 'error')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
|
||||
|
|
@ -1310,7 +1311,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
<td className="px-4 py-3 align-top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
onClick={() => { analytics.meetingNoteOpened(); onOpenNote(note.path) }}
|
||||
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
<span className="block truncate">{note.name}</span>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export function CommandPalette({
|
|||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
analytics.searchOpened()
|
||||
searchInputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ export function CommandPalette({
|
|||
}, [open])
|
||||
|
||||
const handleSelect = useCallback((result: SearchResult) => {
|
||||
analytics.searchResultSelected(result.type)
|
||||
onOpenChange(false)
|
||||
if (result.type === 'knowledge') {
|
||||
onSelectFile(result.path)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as analytics from "@/lib/analytics"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
|
||||
|
|
@ -2470,7 +2471,10 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
|
||||
// Reset to the requested default tab each time the dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab)
|
||||
if (open) {
|
||||
setActiveTab(defaultTab)
|
||||
analytics.settingsOpened(defaultTab)
|
||||
}
|
||||
}, [open, defaultTab])
|
||||
|
||||
// Check if user is signed in to Rowboat
|
||||
|
|
@ -2561,6 +2565,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
return
|
||||
}
|
||||
}
|
||||
analytics.settingsTabChanged(tab)
|
||||
setActiveTab(tab)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,3 +96,238 @@ export function searchExecuted(types: string[]) {
|
|||
export function noteExported(format: string) {
|
||||
posthog.capture('note_exported', { format })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature usage instrumentation. One `view_opened` per navigation (the
|
||||
// feature-importance funnel), plus per-feature action events. Everything below
|
||||
// answers "how many people use X and what do they do inside it".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AppView =
|
||||
| 'chat'
|
||||
| 'file'
|
||||
| 'graph'
|
||||
| 'task'
|
||||
| 'suggested-topics'
|
||||
| 'meetings'
|
||||
| 'live-notes'
|
||||
| 'email'
|
||||
| 'workspace'
|
||||
| 'knowledge-view'
|
||||
| 'chat-history'
|
||||
| 'home'
|
||||
| 'code'
|
||||
| 'bg-tasks'
|
||||
| 'apps'
|
||||
|
||||
// Views that count as "using a feature" — first visit sets a person property
|
||||
// so PostHog cohorts can answer "how many people have ever used meetings".
|
||||
const FIRST_USE_VIEWS: Partial<Record<AppView, string>> = {
|
||||
email: 'has_used_email',
|
||||
meetings: 'has_used_meetings',
|
||||
'live-notes': 'has_used_live_notes',
|
||||
'bg-tasks': 'has_used_bg_agents',
|
||||
apps: 'has_used_apps',
|
||||
code: 'has_used_code',
|
||||
}
|
||||
|
||||
export function viewOpened(view: AppView) {
|
||||
posthog.capture('view_opened', { view })
|
||||
const flag = FIRST_USE_VIEWS[view]
|
||||
if (flag) posthog.people.set_once({ [flag]: true })
|
||||
}
|
||||
|
||||
// --- Email ---
|
||||
|
||||
export function emailThreadOpened() {
|
||||
posthog.capture('email_thread_opened')
|
||||
}
|
||||
|
||||
export function emailComposeOpened(mode: string) {
|
||||
posthog.capture('email_compose_opened', { mode })
|
||||
}
|
||||
|
||||
export function emailSent(props: { mode: string; hasAttachments: boolean; aiAssisted: boolean }) {
|
||||
posthog.capture('email_sent', {
|
||||
mode: props.mode,
|
||||
has_attachments: props.hasAttachments,
|
||||
ai_assisted: props.aiAssisted,
|
||||
})
|
||||
}
|
||||
|
||||
export function emailAiDraftGenerated(mode: 'generate' | 'rewrite') {
|
||||
posthog.capture('email_ai_draft_generated', { mode })
|
||||
}
|
||||
|
||||
export function emailArchived() {
|
||||
posthog.capture('email_archived')
|
||||
}
|
||||
|
||||
export function emailTrashed() {
|
||||
posthog.capture('email_trashed')
|
||||
}
|
||||
|
||||
export function emailMarkedUnread() {
|
||||
posthog.capture('email_marked_unread')
|
||||
}
|
||||
|
||||
export function emailImportanceChanged(importance: string) {
|
||||
posthog.capture('email_importance_changed', { importance })
|
||||
}
|
||||
|
||||
export function emailCategoryChanged(category: string) {
|
||||
posthog.capture('email_category_changed', { category })
|
||||
}
|
||||
|
||||
export function emailCategoryArchived(category: string) {
|
||||
posthog.capture('email_category_archived', { category })
|
||||
}
|
||||
|
||||
export function emailSearched() {
|
||||
posthog.capture('email_searched')
|
||||
}
|
||||
|
||||
export function emailInstructionsSaved() {
|
||||
posthog.capture('email_instructions_saved')
|
||||
}
|
||||
|
||||
export function emailSyncTriggered() {
|
||||
posthog.capture('email_sync_triggered')
|
||||
}
|
||||
|
||||
// --- Meetings ---
|
||||
|
||||
export function meetingRecordingStarted(hasCalendarEvent: boolean) {
|
||||
posthog.capture('meeting_recording_started', { has_calendar_event: hasCalendarEvent })
|
||||
posthog.people.set_once({ has_used_meetings: true })
|
||||
}
|
||||
|
||||
export function meetingRecordingStopped(durationSeconds: number) {
|
||||
posthog.capture('meeting_recording_stopped', { duration_seconds: Math.round(durationSeconds) })
|
||||
}
|
||||
|
||||
// meeting_popup_action is captured in the main process (the popup window runs
|
||||
// without PostHog) — see apps/main/src/ipc.ts 'meetingDetect:action'.
|
||||
|
||||
export function meetingNoteOpened() {
|
||||
posthog.capture('meeting_note_opened')
|
||||
}
|
||||
|
||||
// --- Calls ---
|
||||
|
||||
export function callEnded(durationSeconds: number) {
|
||||
posthog.capture('call_ended', { duration_seconds: Math.round(durationSeconds) })
|
||||
}
|
||||
|
||||
// --- Background agents ---
|
||||
|
||||
export function bgAgentCreated(props: { method: 'manual' | 'coding' | 'copilot'; hasTriggers: boolean }) {
|
||||
posthog.capture('bg_agent_created', { method: props.method, has_triggers: props.hasTriggers })
|
||||
posthog.people.set_once({ has_created_bg_agent: true })
|
||||
}
|
||||
|
||||
export function bgAgentUpdated() {
|
||||
posthog.capture('bg_agent_updated')
|
||||
}
|
||||
|
||||
export function bgAgentToggled(active: boolean) {
|
||||
posthog.capture('bg_agent_toggled', { active })
|
||||
}
|
||||
|
||||
export function bgAgentRunClicked() {
|
||||
posthog.capture('bg_agent_run_clicked')
|
||||
}
|
||||
|
||||
export function bgAgentStopped() {
|
||||
posthog.capture('bg_agent_stopped')
|
||||
}
|
||||
|
||||
export function bgAgentDeleted() {
|
||||
posthog.capture('bg_agent_deleted')
|
||||
}
|
||||
|
||||
// --- Live notes ---
|
||||
|
||||
export function liveNoteSaved() {
|
||||
posthog.capture('live_note_saved')
|
||||
}
|
||||
|
||||
export function liveNoteToggled(active: boolean) {
|
||||
posthog.capture('live_note_toggled', { active })
|
||||
}
|
||||
|
||||
export function liveNoteRunClicked() {
|
||||
posthog.capture('live_note_run_clicked')
|
||||
}
|
||||
|
||||
export function liveNoteStopped() {
|
||||
posthog.capture('live_note_stopped')
|
||||
}
|
||||
|
||||
export function liveNoteDeleted() {
|
||||
posthog.capture('live_note_deleted')
|
||||
}
|
||||
|
||||
export function liveNoteEditWithCopilotClicked() {
|
||||
posthog.capture('live_note_edit_with_copilot_clicked')
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
|
||||
export function searchOpened() {
|
||||
posthog.capture('search_opened')
|
||||
}
|
||||
|
||||
export function searchResultSelected(type: string) {
|
||||
posthog.capture('search_result_selected', { type })
|
||||
}
|
||||
|
||||
// Apps install/update/publish/star/delete events are captured in the main
|
||||
// process (apps/main/src/ipc.ts) where the operations actually run.
|
||||
|
||||
// --- Billing ---
|
||||
|
||||
export function billingErrorShown(kind: string) {
|
||||
posthog.capture('billing_error_shown', { kind })
|
||||
}
|
||||
|
||||
export function billingUpgradeClicked(kind: string) {
|
||||
posthog.capture('billing_upgrade_clicked', { kind })
|
||||
}
|
||||
|
||||
// --- Failures ---
|
||||
|
||||
export function emailSendFailed() {
|
||||
posthog.capture('email_send_failed')
|
||||
}
|
||||
|
||||
export function meetingSummarizeFailed() {
|
||||
posthog.capture('meeting_summarize_failed')
|
||||
}
|
||||
|
||||
// --- Notes / settings / onboarding ---
|
||||
|
||||
export function noteCreated() {
|
||||
posthog.capture('note_created')
|
||||
}
|
||||
|
||||
// The autosave loop fires on every debounced keystroke burst, so dedupe to one
|
||||
// event per note per app session — "was this note edited", not "how many saves".
|
||||
const editedNotePaths = new Set<string>()
|
||||
export function noteEdited(path: string) {
|
||||
if (editedNotePaths.has(path)) return
|
||||
editedNotePaths.add(path)
|
||||
posthog.capture('note_edited')
|
||||
}
|
||||
|
||||
export function settingsOpened(tab: string) {
|
||||
posthog.capture('settings_opened', { tab })
|
||||
}
|
||||
|
||||
export function settingsTabChanged(tab: string) {
|
||||
posthog.capture('settings_tab_changed', { tab })
|
||||
}
|
||||
|
||||
export function onboardingCompleted() {
|
||||
posthog.capture('onboarding_completed')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { startHeadlessAgent, startWhenPossible } from '../runtime/assembly/headl
|
|||
import { buildTriggerBlock } from '../runtime/assembly/build-trigger-block.js';
|
||||
import { backgroundTaskBus } from './bus.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
import { capture } from '../analytics/posthog.js';
|
||||
|
||||
const log = new PrefixLogger('BgTask:Agent');
|
||||
|
||||
|
|
@ -210,6 +211,7 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
capture('bg_agent_run_completed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
@ -233,6 +235,7 @@ export async function runBackgroundTask(
|
|||
}
|
||||
|
||||
log.log(`${slug} — failed: ${truncate(msg)}`);
|
||||
capture('bg_agent_run_failed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import path from 'path';
|
|||
import fs from 'fs/promises';
|
||||
import z from 'zod';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { capture } from '../../analytics/posthog.js';
|
||||
import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js';
|
||||
import type { CodingAgent, ApprovalPolicy } from '@x/shared/dist/code-mode.js';
|
||||
import { RunEvent, MessageEvent } from '@x/shared/dist/runs.js';
|
||||
|
|
@ -182,6 +183,9 @@ export class CodeSessionService {
|
|||
return { accepted: false, error: 'The session is busy with a Rowboat-driven turn.' };
|
||||
}
|
||||
this.inflight.add(sessionId);
|
||||
// Direct-drive turns bypass the agent runtime, so they never show up in
|
||||
// llm_usage — this is the only signal that direct code mode is used.
|
||||
capture('code_session_message_sent', { mode: session.mode, agent: session.agent });
|
||||
const signal = this.abortRegistry.createForRun(sessionId);
|
||||
const turnId = await this.idGenerator.next();
|
||||
const toolCallId = `direct-${turnId}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue