feat(stage): add global button analytics directive (#2146)

This commit is contained in:
RainbowBird
2026-07-29 15:59:53 +08:00
committed by GitHub
parent 2ae95253d9
commit 742bb80ca5
16 changed files with 415 additions and 36 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
## Readability, Naming, and Comments
- File names: camelCase.
- Use kebab-case for all file names.
- Prefer names that rely on the module boundary for context instead of repeating package, product, protocol, or transport prefixes inside every symbol. A well-named module should let exported functions use short action-first names; repeat the larger context only when the symbol crosses a boundary where that context is no longer obvious.
- Name functions after the domain operation they perform, not after the implementation layer that happens to contain them. This keeps call sites readable after refactors and avoids names becoming stale when code moves between files.
- Avoid names that encode multiple layers of ownership into one symbol. If a name needs several qualifiers to be understandable, reconsider the module boundary or introduce a clearer local concept.
+2
View File
@@ -6,6 +6,7 @@ import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -60,6 +61,7 @@ createApp(App)
.use(pinia)
.use(i18n)
.use(Tres)
.use(trackButtonPlugin)
.mount('#app')
if (import.meta.env.DEV && !import.meta.env.SSR) {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -21,18 +22,30 @@ const props = withDefaults(defineProps<Props>(), {
const uiStore = useControlsIslandStore()
const enabled = computed(() => uiStore.fadeOnHoverEnabled)
const { t } = useI18n()
const { trackControlsIslandAction } = useAnalytics()
const requestNotice = useElectronEventaInvoke(noticeWindowEventa.openWindow)
const NOTICE_WINDOW_ID = 'fade-on-hover'
function setFadeOnHoverEnabled(value: boolean) {
if (value)
uiStore.enableFadeOnHover()
else
uiStore.disableFadeOnHover()
trackControlsIslandAction({
action: value ? 'enable_fade_on_hover' : 'disable_fade_on_hover',
})
}
async function handleToggle() {
if (enabled.value) {
uiStore.disableFadeOnHover()
setFadeOnHoverEnabled(false)
return
}
if (uiStore.dontShowItAgainNoticeFadeOnHover) {
uiStore.enableFadeOnHover()
setFadeOnHoverEnabled(true)
return
}
@@ -42,8 +55,9 @@ async function handleToggle() {
route: '/notice/fade-on-hover',
type: 'fade-on-hover',
})
if (acknowledged)
uiStore.enableFadeOnHover()
if (acknowledged) {
setFadeOnHoverEnabled(true)
}
}
catch (error) {
console.error('Failed to open fade-on-hover notice:', error)
@@ -55,6 +69,7 @@ async function handleToggle() {
<ControlButtonTooltip>
<ControlButton
:button-style="props.buttonStyle"
:aria-label="enabled ? t('tamagotchi.stage.controls-island.fade-on-hover.disable') : t('tamagotchi.stage.controls-island.fade-on-hover.enable')"
:class="{ 'border-primary-300/70 shadow-[0_10px_24px_rgba(0,0,0,0.22)]': enabled }"
@click="handleToggle"
>
@@ -38,7 +38,7 @@ const { alwaysOnTop, controlsIslandIconSize } = storeToRefs(settingsStore)
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const openChat = useElectronEventaInvoke(electronOpenChat)
const isLinux = useElectronEventaInvoke(electron.app.isLinux)
const closeWindow = useElectronEventaInvoke(electronAppQuit)
const quitApp = useElectronEventaInvoke(electronAppQuit)
const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop)
const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow)
@@ -94,6 +94,10 @@ function toggleAlwaysOnTop() {
alwaysOnTop.value = !alwaysOnTop.value
}
function toggleControls() {
expanded.value = !expanded.value
}
// Grouped classes for icon / border / padding and combined style class
const adjustStyleClasses = computed(() => {
let isLarge: boolean
@@ -158,7 +162,12 @@ function resetMainWindowPosition() {
<div grid grid-cols-3 gap-2>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="openSettings({ route: '/settings' })">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'toggle_settings' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.open-settings')"
@click="openSettings({ route: '/settings' })"
>
<div i-solar:settings-minimalistic-outline :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
@@ -169,7 +178,12 @@ function resetMainWindowPosition() {
<ControlButtonTooltip disable-hoverable-content>
<ControlsIslandProfilePicker placement="up" :open="blockingOverlays.has('profile-picker')" @update:open="setOverlay('profile-picker', $event)">
<template #default="{ toggle }">
<ControlButton :button-style="adjustStyleClasses.button" @click="toggle">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'toggle_profile_picker' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.switch-profile')"
@click="toggle"
>
<div i-solar:emoji-funny-square-broken :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
</template>
@@ -180,7 +194,12 @@ function resetMainWindowPosition() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="refreshWindow">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'refresh_window' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.refresh')"
@click="refreshWindow"
>
<div i-solar:refresh-linear :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
@@ -189,7 +208,12 @@ function resetMainWindowPosition() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="resetMainWindowPosition()">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'center_main_window' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.center-main-window')"
@click="resetMainWindowPosition"
>
<div i-solar:target-linear :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
@@ -198,7 +222,15 @@ function resetMainWindowPosition() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="toggleDark()">
<ControlButton
v-track-button="{
name: 'controls_island_action',
action: isDark ? 'switch_to_light_mode' : 'switch_to_dark_mode',
}"
:button-style="adjustStyleClasses.button"
:aria-label="isDark ? t('tamagotchi.stage.controls-island.switch-to-light-mode') : t('tamagotchi.stage.controls-island.switch-to-dark-mode')"
@click="() => toggleDark()"
>
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
<div v-else i-solar:sun-2-outline :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
@@ -210,7 +242,15 @@ function resetMainWindowPosition() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="toggleAlwaysOnTop()">
<ControlButton
v-track-button="{
name: 'controls_island_action',
action: alwaysOnTop ? 'unpin_from_top' : 'pin_on_top',
}"
:button-style="adjustStyleClasses.button"
:aria-label="alwaysOnTop ? t('tamagotchi.stage.controls-island.unpin-from-top') : t('tamagotchi.stage.controls-island.pin-on-top')"
@click="toggleAlwaysOnTop"
>
<div v-if="alwaysOnTop" i-solar:pin-bold :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
<div v-else i-solar:pin-linear :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300 opacity-50" />
</ControlButton>
@@ -222,7 +262,14 @@ function resetMainWindowPosition() {
<ControlsIslandFadeOnHover :icon-class="adjustStyleClasses.icon" :button-style="adjustStyleClasses.button" />
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" hover:bg-red-500 hover:text-white @click="closeWindow()">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'close_app' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.close')"
hover:bg-red-500
hover:text-white
@click="() => quitApp()"
>
<div i-solar:close-circle-outline :class="adjustStyleClasses.icon" />
</ControlButton>
<template #tooltip>
@@ -236,7 +283,15 @@ function resetMainWindowPosition() {
<!-- Main Controls -->
<div flex flex-col gap-1>
<ControlButtonTooltip side="left">
<ControlButton :button-style="adjustStyleClasses.button" @click="expanded = !expanded">
<ControlButton
v-track-button="{
name: 'controls_island_action',
action: expanded ? 'collapse_controls' : 'expand_controls',
}"
:button-style="adjustStyleClasses.button"
:aria-label="expanded ? t('tamagotchi.stage.controls-island.collapse') : t('tamagotchi.stage.controls-island.expand')"
@click="toggleControls"
>
<div
:class="[adjustStyleClasses.icon, expanded ? 'rotate-180' : 'rotate-0']"
i-solar:alt-arrow-up-line-duotone scale-110 transition-all duration-300
@@ -249,7 +304,12 @@ function resetMainWindowPosition() {
</ControlButtonTooltip>
<ControlButtonTooltip side="left">
<ControlButton :button-style="adjustStyleClasses.button" @click="openChat">
<ControlButton
v-track-button="{ name: 'controls_island_action', action: 'toggle_chat' }"
:button-style="adjustStyleClasses.button"
:aria-label="t('tamagotchi.stage.controls-island.open-chat')"
@click="() => openChat()"
>
<div i-solar:chat-line-line-duotone :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
@@ -5,6 +5,7 @@ import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -53,4 +54,5 @@ createApp(App)
.use(PiniaColada)
.use(i18n)
.use(Tres)
.use(trackButtonPlugin)
.mount('#app')
@@ -33,7 +33,6 @@ const {
} = useElectronAutoUpdater()
const {
trackUpdateCheckClicked,
trackUpdateDownloaded,
trackUpdateInstallClicked,
trackSettingsChanged,
@@ -126,11 +125,6 @@ const isDowngradeUpdate = computed(() => {
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
const setUpdaterPreferences = useElectronEventaInvoke(electronSetUpdaterPreferences)
function handleCheckForUpdates() {
trackUpdateCheckClicked({ channel: selectedUpdateChannel.value })
return checkForUpdates()
}
function handleQuitAndInstall() {
trackUpdateInstallClicked({ channel: selectedUpdateChannel.value, version: updateState.value.info?.version })
quitAndInstall()
@@ -384,6 +378,7 @@ onMounted(() => {
<div :class="['flex flex-wrap gap-2']">
<Button
v-track-button="{ name: 'update_check_clicked', channel: selectedUpdateChannel }"
:variant="isError ? 'caution' : 'secondary'"
:loading="isBusy"
:disabled="isDisabled"
@@ -397,7 +392,7 @@ onMounted(() => {
: isError
? t('tamagotchi.stage.about.update.actions.retry-check')
: t('tamagotchi.stage.about.update.actions.check-for-updates')"
@click="handleCheckForUpdates()"
@click="checkForUpdates()"
/>
</div>
</div>
@@ -42,7 +42,7 @@ import {
} from './mcp-config'
const { t } = useI18n()
const { trackMcpServerAdded, trackMcpServerRemoved, trackMcpConnectionTestRun } = useAnalytics()
const { trackMcpServerRemoved, trackMcpConnectionTestRun } = useAnalytics()
const tn = (key: string, params?: Record<string, unknown>) => t(`settings.pages.modules.mcp-server.${key}`, params ?? {})
const invokeOpenConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
@@ -215,7 +215,6 @@ function formatJsonDraft() {
function addServer() {
const server = createServerForm()
servers.value.push(server)
trackMcpServerAdded()
if (!testRowId.value)
testRowId.value = server.rowId
}
@@ -476,6 +475,7 @@ onMounted(async () => {
</article>
<Button
v-track-button="{ name: 'mcp_server_added' }"
variant="secondary-muted" size="md" block :disabled="isBusy"
icon="i-solar:add-circle-bold-duotone" :label="tn('actions.add-server')"
@click="addServer"
+2
View File
@@ -7,6 +7,7 @@ import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -55,6 +56,7 @@ createApp(App)
.use(PiniaColada)
.use(i18n)
.use(Tres)
.use(trackButtonPlugin)
.mount('#app')
if (import.meta.env.DEV && !import.meta.env.SSR) {
+25
View File
@@ -2,6 +2,31 @@
Shared core for stage
## Button analytics
Register the shared plugin once in each Vue application:
```ts
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
createApp(App)
.use(trackButtonPlugin)
.mount('#app')
```
Buttons that represent a product-analysis click intent can then declare a
typed event without wrapping their business handler:
```vue
<Button
v-track-button="{ name: 'update_check_clicked', channel: selectedChannel }"
@click="checkForUpdates()"
/>
```
Keep async outcomes, confirmed state changes, impressions, and lifecycle events
in their owning business flows instead of attaching them to the initial click.
## Histoire (UI storyboard)
https://histoire.dev/
+1
View File
@@ -26,6 +26,7 @@
"./composables": "./src/composables/index.ts",
"./constants/*": "./src/constants/*.ts",
"./constants": "./src/constants/index.ts",
"./directives/*": "./src/directives/*.ts",
"./libs/inference/adapters/*": "./src/libs/inference/adapters/*.ts",
"./libs/inference": "./src/libs/inference/index.ts",
"./libs/*": "./src/libs/*.ts",
@@ -994,6 +994,28 @@ describe('useAnalytics conversation product events', () => {
})
})
it('emits stable controls-island actions and flushes reload actions immediately', () => {
analyticsMocks.isStageTamagotchiMock.mockReturnValue(true)
const analytics = useAnalytics()
analytics.trackControlsIslandAction({ action: 'toggle_chat' })
analytics.trackControlsIslandAction({ action: 'refresh_window' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
action: 'toggle_chat',
app_surface: 'electron',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
2,
'controls_island_action',
{
action: 'refresh_window',
app_surface: 'electron',
},
{ send_instantly: true, transport: 'sendBeacon' },
)
})
it('emits desktop differentiator events for spotlight, widgets, updater, MCP, and pairing', () => {
analyticsMocks.isStageTamagotchiMock.mockReturnValue(true)
const analytics = useAnalytics()
@@ -1,3 +1,4 @@
import type { ControlsIslandAction } from '../stores/analytics/button-events'
import type { SpeechOutputStopReason } from '../stores/speech-output-control'
import posthog from 'posthog-js'
@@ -7,6 +8,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSharedAnalyticsStore } from '../stores/analytics'
import { captureTrackButtonEvent } from '../stores/analytics/button-events'
import { ensurePosthogInitialized, isPosthogAvailableInBuild } from '../stores/analytics/posthog'
import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy'
import { useSettingsAnalytics } from '../stores/settings/analytics'
@@ -39,6 +41,8 @@ export type ProductAnalyticsEntry = 'app_start' | 'onboarding' | 'settings' | 'c
export type MessageInputMode = 'text' | 'voice'
export type ConversationEventSource = 'new_session' | 'fork' | 'history' | 'share_button' | 'unknown'
export type AiUsageSource = 'reported' | 'estimated' | 'unavailable'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type { ControlsIslandAction } from '../stores/analytics/button-events'
/**
* Full stage vocabulary of the cross-surface `oauth_callback_failed` event.
@@ -1203,6 +1207,10 @@ export function useAnalytics() {
// server management. Input text never leaves the device — events carry
// counts and low-cardinality ids only.
function trackControlsIslandAction(properties: { action: ControlsIslandAction }) {
captureTrackButtonEvent({ name: 'controls_island_action', ...properties })
}
function trackSpotlightUsed() {
if (!canCapture())
return
@@ -1216,9 +1224,7 @@ export function useAnalytics() {
}
function trackUpdateCheckClicked(properties: { channel: string }) {
if (!canCapture())
return
posthog.capture('update_check_clicked', properties)
captureTrackButtonEvent({ name: 'update_check_clicked', ...properties })
}
function trackUpdateDownloaded(properties: { channel: string, version?: string }) {
@@ -1229,21 +1235,15 @@ export function useAnalytics() {
/** User confirmed restart-and-install; the app quits right after. */
function trackUpdateInstallClicked(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_install_clicked', properties, { send_instantly: true, transport: 'sendBeacon' })
captureTrackButtonEvent({ name: 'update_install_clicked', ...properties })
}
function trackMcpServerAdded() {
if (!canCapture())
return
posthog.capture('mcp_server_added')
captureTrackButtonEvent({ name: 'mcp_server_added' })
}
function trackMcpServerRemoved() {
if (!canCapture())
return
posthog.capture('mcp_server_removed')
captureTrackButtonEvent({ name: 'mcp_server_removed' })
}
function trackMcpConnectionTestRun(properties: { success: boolean }) {
@@ -1380,6 +1380,7 @@ export function useAnalytics() {
trackDeviceChannelConnected,
trackDataAction,
trackControlsIslandAction,
trackSpotlightUsed,
trackWidgetOpened,
trackUpdateCheckClicked,
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import type { ObjectDirective } from 'vue'
import type { TrackButtonEvent } from './track-button'
import { describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, withDirectives } from 'vue'
import { createTrackButtonDirective, trackButtonPlugin } from './track-button'
describe('trackButtonPlugin', () => {
it('registers the directive at app level', () => {
const app = createApp({ render: () => h('div') })
app.use(trackButtonPlugin)
expect(app.directive('track-button')).toBeDefined()
})
})
describe('createTrackButtonDirective', () => {
it('captures the current descriptor before the button handler runs', async () => {
const calls: string[] = []
const event = ref<TrackButtonEvent>({
name: 'controls_island_action',
action: 'switch_to_dark_mode',
})
const capture = vi.fn((value: TrackButtonEvent) => calls.push(`track:${'action' in value ? value.action : value.name}`))
const directive = createTrackButtonDirective(capture)
const host = document.createElement('div')
const app = createApp({
render: () => withDirectives(
h('button', {
onClick: () => calls.push('handler'),
}),
[[directive, event.value]],
),
})
app.mount(host)
const button = host.querySelector('button')!
button.click()
expect(calls).toEqual(['track:switch_to_dark_mode', 'handler'])
calls.length = 0
event.value = {
name: 'controls_island_action',
action: 'switch_to_light_mode',
}
await nextTick()
button.click()
expect(calls).toEqual(['track:switch_to_light_mode', 'handler'])
app.unmount()
})
it('removes its listener when the button is unmounted', () => {
const capture = vi.fn()
const directive: ObjectDirective<HTMLElement, TrackButtonEvent> = createTrackButtonDirective(capture)
const host = document.createElement('div')
const app = createApp({
render: () => withDirectives(
h('button'),
[[directive, { name: 'mcp_server_added' } satisfies TrackButtonEvent]],
),
})
app.mount(host)
const button = host.querySelector('button')!
app.unmount()
button.click()
expect(capture).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,65 @@
import type { ObjectDirective, Plugin } from 'vue'
import type { TrackButtonEvent } from '../stores/analytics/button-events'
import { captureTrackButtonEvent } from '../stores/analytics/button-events'
export type { ControlsIslandAction, TrackButtonEvent } from '../stores/analytics/button-events'
/**
* Creates the DOM directive used by the app-level tracking plugin.
*
* The listener deliberately receives no DOM event. This keeps `MouseEvent`
* objects out of analytics callbacks and reads the latest binding when a
* reactive event descriptor changes.
*/
export function createTrackButtonDirective(capture: (event: TrackButtonEvent) => void): ObjectDirective<HTMLElement, TrackButtonEvent> {
const events = new WeakMap<HTMLElement, TrackButtonEvent>()
const listeners = new WeakMap<HTMLElement, EventListener>()
return {
mounted(element, binding) {
events.set(element, binding.value)
const listener = () => {
const event = events.get(element)
if (event)
capture(event)
}
listeners.set(element, listener)
element.addEventListener('click', listener, { capture: true })
},
updated(element, binding) {
events.set(element, binding.value)
},
beforeUnmount(element) {
const listener = listeners.get(element)
if (listener)
element.removeEventListener('click', listener, { capture: true })
events.delete(element)
listeners.delete(element)
},
}
}
const vTrackButton = createTrackButtonDirective(captureTrackButtonEvent)
/**
* Registers `v-track-button` once for a Vue application.
*
* Templates provide typed event descriptors while the directive owns DOM
* listener lifecycle and analytics opt-in checks.
*/
export const trackButtonPlugin: Plugin = {
install(app) {
app.directive('track-button', vTrackButton)
},
}
declare module 'vue' {
interface GlobalDirectives {
vTrackButton: typeof vTrackButton
}
}
@@ -0,0 +1,110 @@
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useSettingsAnalytics } from '../settings/analytics'
import { ensurePosthogInitialized, isPosthogAvailableInBuild } from './posthog'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type ControlsIslandAction
= | 'expand_controls'
| 'collapse_controls'
| 'toggle_settings'
| 'toggle_profile_picker'
| 'toggle_chat'
| 'refresh_window'
| 'center_main_window'
| 'switch_to_light_mode'
| 'switch_to_dark_mode'
| 'pin_on_top'
| 'unpin_from_top'
| 'enable_fade_on_hover'
| 'disable_fade_on_hover'
| 'close_app'
/**
* Explicit product events that may be emitted directly from a button click.
*
* Add an event here only when clicking the button is itself the fact being
* measured. Async outcomes and confirmed state changes should remain in their
* owning business flow.
*/
export type TrackButtonEvent
= | {
name: 'controls_island_action'
action: ControlsIslandAction
}
| {
name: 'update_check_clicked'
channel: string
}
| {
name: 'update_install_clicked'
channel: string
version?: string
}
| {
name: 'mcp_server_added'
}
| {
name: 'mcp_server_removed'
}
function canCapture(): boolean {
const settingsAnalytics = useSettingsAnalytics()
return isPosthogAvailableInBuild()
&& settingsAnalytics.analyticsEnabled
&& ensurePosthogInitialized(true)
}
function appSurface(): 'web' | 'mobile' | 'electron' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
/**
* Emits a typed button event through the existing analytics opt-in boundary.
*
* This is shared with the global directive and imperative analytics facade so
* both retain one event schema and transport policy.
*/
export function captureTrackButtonEvent(event: TrackButtonEvent) {
if (!canCapture())
return
switch (event.name) {
case 'controls_island_action': {
const properties = {
action: event.action,
app_surface: appSurface(),
}
if (event.action === 'refresh_window' || event.action === 'close_app') {
posthog.capture(event.name, properties, { send_instantly: true, transport: 'sendBeacon' })
return
}
posthog.capture(event.name, properties)
return
}
case 'update_check_clicked':
posthog.capture(event.name, { channel: event.channel })
return
case 'update_install_clicked':
posthog.capture(
event.name,
{ channel: event.channel, ...(event.version && { version: event.version }) },
{ send_instantly: true, transport: 'sendBeacon' },
)
return
case 'mcp_server_added':
posthog.capture(event.name)
return
case 'mcp_server_removed':
posthog.capture(event.name)
}
}
@@ -30,6 +30,7 @@ describe('stage-ui exports contract', () => {
'./composables/*',
'./constants',
'./constants/*',
'./directives/*',
'./libs',
'./libs/*',
'./libs/inference',
@@ -63,6 +64,7 @@ describe('stage-ui exports contract', () => {
expect(exportsMap['./stores']).toBe('./src/stores/index.ts')
expect(exportsMap['./stores/*']).toBe('./src/stores/*.ts')
expect(exportsMap['./directives/*']).toBe('./src/directives/*.ts')
expect(exportsMap['./services/*']).toBe('./src/services/*.ts')
expect(exportsMap['./tools/mcp']).toBe('./src/tools/mcp.ts')
expect(exportsMap['./types']).toBe('./src/types/index.ts')