diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml
index 200ba4f9e9..0847f8f241 100644
--- a/packages/client/product-analytics/README.i18n.yaml
+++ b/packages/client/product-analytics/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 2fee9973f19f9401
zh: 8ab4b55413cb8408
/deepseek-ai-dsh-client-product-analytics/use-this-package:
- en: c8c05edf30b3959d
- zh: 39c2147cf01d43b5
+ en: 6bd7bf60e279ba1c
+ zh: afd55f06585c6462
/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
en: 416b7e925f0852c9
zh: c0b83bacf18e91b1
diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md
index 84c67e30e2..307534adb4 100644
--- a/packages/client/product-analytics/README.md
+++ b/packages/client/product-analytics/README.md
@@ -29,9 +29,9 @@ Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Devic
Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
-The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
+The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
-Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`.
+Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`.
## Understand the implementation
diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md
index f997f49dc3..1a87f9fd0c 100644
--- a/packages/client/product-analytics/README.zh.md
+++ b/packages/client/product-analytics/README.zh.md
@@ -29,9 +29,9 @@ kind: "package-reference"
Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
-认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
+认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
-插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。
+只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。
## 了解实现
diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts
index 18e7c72e77..7fc64a6bcc 100644
--- a/packages/client/product-analytics/src/events.ts
+++ b/packages/client/product-analytics/src/events.ts
@@ -11,11 +11,11 @@ export interface ProductEventMap {
onboarding_page_view: { page_name: OnboardingPage }
onboarding_page_click: {
page_name: OnboardingPage
- button_name: 'next' | 'back' | 'skip' | 'charge' | 'later'
+ button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue'
selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process'
}
onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' }
- onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' }
+ onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' }
desktop_upgrade_click: Record
desktop_upgrade_download_result: { is_success: boolean; error_reason?: string }
desktop_upgrade_install_restart_click: Record
@@ -24,8 +24,7 @@ export interface ProductEventMap {
model_name?: string
thinking_effort?: string
run_mode: 'plan' | 'goal' | 'default'
- submit_source?: 'click' | 'enter'
- submit_type: 'normal' | 'steer' | 'queue'
+ msg_type: 'default' | 'steer' | 'queue'
}
model_switch: { session_id?: SessionId; switch_from: string; switch_to: string }
thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string }
@@ -39,17 +38,15 @@ export interface ProductEventMap {
sidebar_menu_click: { menu_name: 'plugin' | 'cron' }
plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean }
plugin_add_button_click: Record
- plugin_install_click: { input_value: string; plugin_type: 'bundle' }
+ plugin_install_click: { input_value: string }
install_plugin_result: {
input_value: string
- plugin_type: 'bundle'
is_success: boolean
- result_status: 'success' | 'failed' | 'cancelled' | 'unknown'
error_reason?: string
duration: number
plugin_name?: string
}
- confirm_uninstall_plugin: { plugin_name: string; plugin_type: 'bundle' }
+ confirm_uninstall_plugin: { plugin_name: string }
}
/** Public page names independent of internal onboarding state names. */
diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
index 1fb86ad3ab..a0f1a8e1c9 100644
--- a/packages/client/ui-conversation/src/client/input/submission-analytics.ts
+++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
@@ -9,14 +9,13 @@ import type { MessageSubmission } from '../contract/composer-submission.ts'
* @param submission - original occurrence and Session snapshot.
*/
export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void {
- const { state, timestamp, source, mode } = submission
+ const { state, timestamp, mode } = submission
if (state === undefined) return
const model = state.model
ctx.get('productAnalytics')?.track('send_button_click', {
...state.sessionId === undefined ? {} : { session_id: state.sessionId },
...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } },
run_mode: state.runMode,
- ...source === undefined ? {} : { submit_source: source },
- submit_type: state.running ? mode : 'normal',
+ msg_type: state.running ? mode : 'default',
}, timestamp)
}
diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
index 05f6e815c7..6665284ae4 100644
--- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
+++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
@@ -890,10 +890,25 @@ it('captures run mode and queue intent at submission before later projection cha
b.shell.setDraft('private prompt')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
- session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', submit_source: 'click', submit_type: 'queue',
+ session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue',
}, expect.any(Number))
await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false })
expect(track).toHaveBeenCalledTimes(1)
expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt')
} finally { await b.runtime.dispose() }
})
+
+it.each([
+ { running: false, mode: 'queue', expected: 'default' },
+ { running: true, mode: 'steer', expected: 'steer' },
+] as const)('reports msg_type=$expected without gesture fields', async ({ running, mode, expected }) => {
+ const b = await bench()
+ const track = vi.fn()
+ b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
+ try {
+ await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true })
+ b.shell.setDraft('message')
+ b.shell.submit(mode, 'enter')
+ expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number))
+ } finally { await b.runtime.dispose() }
+})
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts
index ad885ffac6..41f67832a0 100644
--- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/packages/client/ui-model-selection/src/client/directory.ts
@@ -53,6 +53,7 @@ export class ModelDirectory {
* @param available - whether this session may use Agent-bound model RPCs.
* @param catalog - Host-generation catalog shared by every Session.
* @param projected - durable model selection projected from Session history.
+ * @param isBlank - whether this Session has no first message yet.
* @param track - desktop-only callback after a successful user selection.
*/
constructor(
@@ -61,6 +62,7 @@ export class ModelDirectory {
private readonly available: () => boolean,
private readonly catalog: ModelCatalogDirectory,
private readonly projected: ObservableSnapshot,
+ private readonly isBlank: () => boolean,
private readonly track?: TrackProductEvent,
) {
this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() })
@@ -115,9 +117,9 @@ export class ModelDirectory {
if (previous !== null) {
const from = `${previous.provider}/${previous.model}`
const to = `${selection.provider}/${selection.model}`
- if (from !== to) this.track?.('model_switch', { session_id: this.sessionId, switch_from: from, switch_to: to })
+ if (from !== to) this.track?.('model_switch', { ...this.isBlank() ? {} : { session_id: this.sessionId }, switch_from: from, switch_to: to })
if (from === to && previousEffort !== nextEffort) this.track?.('thinking_level_switch', {
- session_id: this.sessionId, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default',
+ ...this.isBlank() ? {} : { session_id: this.sessionId }, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default',
})
}
this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null })
diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts
index a5c4d1ad62..18fa88cc86 100644
--- a/packages/client/ui-model-selection/src/client/service.ts
+++ b/packages/client/ui-model-selection/src/client/service.ts
@@ -78,6 +78,7 @@ export class ModelDirectoryResolver extends Service {
() => sessions.subagentAddress(sessionId) === undefined,
this.catalog,
binding.session.projections.faceOf('modelSelection'),
+ () => binding.session.getSnapshot().blank,
(name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes),
)
live.directories.set(binding, directory)
diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
index 0719e8c476..1896332c02 100644
--- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
@@ -138,7 +138,11 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const scopes = new Map()
const bindings = new Map SnapshotStore } }
+ session: {
+ sessionId: SessionId
+ getSnapshot: () => { blank: boolean }
+ projections: { faceOf: () => SnapshotStore }
+ }
ctx: Context
}>()
const addressed = new Set()
@@ -154,7 +158,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
- const mint = (key: string) => {
+ const mint = (key: string, blank = false) => {
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
@@ -165,7 +169,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
projections.set(id, projection)
const binding = {
sessionId: id,
- session: { sessionId: id, projections: { faceOf: () => projection } },
+ session: { sessionId: id, getSnapshot: () => ({ blank }), projections: { faceOf: () => projection } },
ctx: handle.ctx,
}
bindings.set(id, binding)
@@ -528,17 +532,17 @@ describe('ui-model-selection dual entry', () => {
})
-it('reports successful model and effort changes and no refused switch', async () => {
+it.each([false, true])('reports accepted switches with blank=%s and no refused switch', async (blank) => {
const b = await bench('en')
- const scope = b.mint('analytics')
+ const scope = b.mint('analytics', blank)
try {
const face = b.seat().inject!(sid('analytics'))
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' })
- expect(b.track).toHaveBeenCalledWith('model_switch', expect.objectContaining({ switch_to: 'deepseek-official/deepseek-v4-pro' }))
+ expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' })
b.track.mockClear()
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' })
expect(b.track).toHaveBeenCalledExactlyOnceWith('thinking_level_switch', {
- session_id: 'analytics', model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high',
+ ...blank ? {} : { session_id: 'analytics' }, model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high',
})
b.track.mockClear()
b.rejectSelection()
diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts
index b9e823b53b..1a3dde4a82 100644
--- a/packages/client/ui-plugin-manager/src/client/manager-store.ts
+++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts
@@ -6,7 +6,7 @@ import { sanitizeInstallInput } from './sanitize-install-input.ts'
* after each action and after every `plugin-manager/changed` event, so a
* change made on another surface shows here without a manual refresh.
*/
-import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client'
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {
@@ -615,7 +615,7 @@ export class PluginManagerController {
},
uninstall: (packageName) => {
this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => {
- this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName, plugin_type: 'bundle' })
+ this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName })
this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName)
})
this.patch({ confirm: { action: 'uninstall', packageName } })
@@ -790,7 +790,7 @@ export class PluginManagerController {
if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return
if (this.ctx.get('productAnalytics')?.enabled) {
this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() }
- this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec), plugin_type: 'bundle' })
+ this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) })
}
// A name the list already shows is refused at once, before the Host is asked.
if (state.packages.some(pkg => pkg.name === spec)) {
@@ -1094,14 +1094,15 @@ export class PluginManagerController {
})
}
- private finishAnalytics(status: ProductEventMap['install_plugin_result']['result_status'], reason?: string, name?: string): void {
+ private finishAnalytics(status: 'success' | 'failed' | 'cancelled' | 'unknown', reason?: string, name?: string): void {
const attempt = this.analyticsAttempt
this.analyticsAttempt = undefined
if (attempt === undefined || this.disposed) return
+ const errorReason = status === 'cancelled' ? 'user_cancelled' : status === 'unknown' ? 'unknown_result' : reason
this.ctx.get('productAnalytics')?.track('install_plugin_result', {
- input_value: attempt.input, plugin_type: 'bundle', is_success: status === 'success', result_status: status,
+ input_value: attempt.input, is_success: status === 'success',
duration: Math.max(0, Date.now() - attempt.started),
- ...reason === undefined ? {} : { error_reason: reason }, ...name === undefined ? {} : { plugin_name: name },
+ ...errorReason === undefined ? {} : { error_reason: errorReason }, ...name === undefined ? {} : { plugin_name: name },
})
}
diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
index 2eec275f4c..1169a9e444 100644
--- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
+++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
@@ -313,7 +313,7 @@ describe('PluginManagerController', () => {
})
it('always asks before uninstalling, and cancelling runs nothing', async () => {
- const { plugins, face, state, controller } = bench()
+ const { plugins, face, state, controller, track } = bench()
await controller.load()
face.uninstall(BUNDLE.name)
expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
@@ -321,10 +321,12 @@ describe('PluginManagerController', () => {
expect(state().confirm).toBeNull()
face.confirm()
expect(plugins.removeBundle).not.toHaveBeenCalled()
+ expect(track).not.toHaveBeenCalled()
face.uninstall(BUNDLE.name)
face.confirm()
expect(state().confirm).toBeNull()
await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
+ expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name })
await vi.waitFor(() => { expect(state().busy).toEqual([]) })
// A refused removal names the action it was.
plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never)
@@ -1423,7 +1425,7 @@ describe('desktop analytics outcomes', () => {
b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token')
b.face.runInstall()
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) })
- expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]', plugin_type: 'bundle' })
+ expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' })
expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token')
})
@@ -1432,8 +1434,8 @@ describe('desktop analytics outcomes', () => {
b.face.openInstall()
b.face.editInstallSpec('dsh-new')
b.face.runInstall()
- await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', result_status: 'failed', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) })
- expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new', plugin_type: 'bundle' })
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) })
+ expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' })
})
it('reports unknown only after recovery confirms the result is absent', async () => {
@@ -1445,12 +1447,12 @@ describe('desktop analytics outcomes', () => {
await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) })
expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([])
lost.resolve(ok(null))
- await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ result_status: 'unknown', is_success: false })) })
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) })
b.face.reconcileInstall()
expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1)
})
- it('closing during inspection reports cancellation once without an error reason', async () => {
+ it('closing during inspection reports user_cancelled once', async () => {
const inspecting = deferred>>()
const b = bench({ inspect: vi.fn(() => inspecting.promise) })
b.face.openInstall()
@@ -1462,8 +1464,9 @@ describe('desktop analytics outcomes', () => {
await Promise.resolve()
const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')
expect(results).toHaveLength(1)
- expect(results[0]?.[1]).toMatchObject({ result_status: 'cancelled', is_success: false })
- expect(results[0]?.[1]).not.toHaveProperty('error_reason')
+ expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false })
+ expect(results[0]?.[1]).not.toHaveProperty('result_status')
+ expect(results[0]?.[1]).not.toHaveProperty('plugin_type')
expect(b.plugins.installBundle).not.toHaveBeenCalled()
})
@@ -1500,3 +1503,15 @@ it('reports restart-required row toggles and tolerates a vanished inventory entr
await vi.waitFor(() => { expect(b.state().busy).toEqual([]) })
expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1)
})
+
+it('reports a successful install with only documented result fields', async () => {
+ const b = bench()
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') })
+ const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result')
+ expect(results).toEqual([['install_plugin_result', {
+ input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new',
+ }]])
+})
diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx
index 05b4110b10..99c1bc4d2a 100644
--- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx
+++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx
@@ -106,7 +106,7 @@ export function DesktopOnboarding({
{step === 'welcome' && { click('next'); go('credit') }} />}
{step === 'credit' && { click('next'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />}
+ funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { click('continue'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />}
{step === 'purpose' && { void update({ purpose }) }} onContinue={() => { click('next'); purposeNext() }} />}
{step === 'process' &&
{dialog !== null && { setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }}
+ onClose={() => { popupClick('close'); setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }}
onSkip={() => { popupClick(dialog === 'skip' ? 'enter' : 'know'); void complete('skipped').then(() => { setDialog(null) }) }} />}
>
}
diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
index 1ee7324909..8152510505 100644
--- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
@@ -335,16 +335,16 @@ it('returns from purpose to credit without dropping the selected purpose', async
})
-it('reports visible pages once, funded Continue, popup actions, and no close event', async () => {
+it('reports visible pages once, funded Continue, and popup close', async () => {
const b = mount('credit', 'positive', 'ready', zh, true)
expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) })
expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' })
b.track.mockClear()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) })
- expect(b.track).not.toHaveBeenCalled()
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) })
- expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'next' })
+ expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' })
expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' })
})
@@ -361,3 +361,11 @@ it('does not repeat page or popup exposure during save and refresh', () => {
act(() => { b.changeStatus('ready') })
expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1)
})
+
+it('reports closing the skip-settings popup', () => {
+ const b = mount('purpose')
+ fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip }))
+ b.track.mockClear()
+ fireEvent.click(screen.getByRole('button', { name: zh.close }))
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' })
+})
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index a17df31798..cdfd82df01 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -6005,7 +6005,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProductEventMap',
- declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source?: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */',
+ declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\' | \'continue\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\' | \'close\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n msg_type: \'default\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: str /* …truncated — full shape in source */',
},
{
name: 'ProductTelemetryRecord',