Merge pull request #6799 from deepseek-harness/worktree/sync-dsh-0.2.1-analytics-to-master

release(dsh): sync 0.2.1 entry analytics back to master
This commit is contained in:
imccyu authored and GitHub committed 2026-10-21 12:38:54 +08:00
commit 224516ec2f
51 files changed
+927 -99

No files matched your search

@@ -15,8 +15,11 @@
en: 2fee9973f19f9401
zh: 8ab4b55413cb8408
/deepseek-ai-dsh-client-product-analytics/use-this-package:
en: 042e8eef8647b025
zh: 256ff24fee965eda
en: 1968870e603f46ab
zh: 7b0ad31d3ee3cc25
/deepseek-ai-dsh-client-product-analytics/use-this-package/navigation-preset-and-new-session-events:
en: f3671bd903637bf4
zh: 1f0ad4d68ed8d744
/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
en: 96b1169d78f4c761
zh: 475df4ea6ed1cb06
@@ -49,6 +49,14 @@ The [event types](src/events.ts) own names and allowed fields. Authentication ev
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`.
### Navigation, preset, and new-session events
Official navigation reports New Session, Plugins, Automation tasks, and More clicks with their physical placement: `sidebar`, `sidebar_more`, or `titlebar`. Opening Automation tasks from the left sidebar More menu counts as `sidebar_more`, not as a direct sidebar click. Historical `sidebar_menu_click` records without `position` remain unattributed. More-menu actions distinguish the sidebar, conversation, and rightbar regions. Third-party entry identifiers and custom preset names are excluded.
Direct preset switches report accepted different choices in the mode picker: an unbound staged choice, or Host application for a blank Session. Custom source modes are `other`, and only built-in targets are reported. Default changes, automatic fallback, restoration, and Creator launchers are excluded. Each switch records the Coding Tools setting known at the action; an unknown setting reports nothing. `code_work_view_toggle` reports a successful change of that setting.
`send_button_click` carries `is_new_session`, which is true while the submitting Session is still blank, and `agent_preset`, the Session's preset as `creator`, `ptc`, `minimal`, `standard`, or `other` for a custom preset; `agent_preset` is omitted while the preset is unknown. The collector receives `eventName` on the OTLP log record and business fields in its `attributes`; repository code supplies no warehouse table or dashboard.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -49,6 +49,14 @@ Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出
只有 `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`。
### 导航、模式与新会话事件
官方导航上报新会话、插件、自动化任务和“更多”的点击及其实际位置:`sidebar`、`sidebar_more` 或 `titlebar`。从左侧栏“更多”菜单打开自动化任务计为 `sidebar_more`,不计为侧栏直接点击。历史上不带 `position` 的 `sidebar_menu_click` 记录保持未归属。“更多”菜单动作区分左侧栏、会话和右侧栏区域。不采集第三方入口标识和自定义预设名称。
模式选择器中被接受的不同选择上报为直接切换:未绑定会话时的暂存选择,或空白会话中 Host 已应用的选择。自定义来源模式记为 `other`,只上报内置目标。默认值修改、自动回退、恢复和创造模式入口均不计入。每次切换记录操作时已知的代码工作工具设置;设置未知时不上报。`code_work_view_toggle` 在该设置修改成功时上报。
`send_button_click` 携带 `is_new_session`(提交时会话仍为空白会话时为 true)和 `agent_preset`(会话预设,取 `creator`、`ptc`、`minimal`、`standard`,自定义预设为 `other`);预设未知时省略 `agent_preset`。采集端在 OTLP 日志记录上接收 `eventName`,业务字段位于其 `attributes`;仓库代码不提供数仓表或看板。
<a id="understand-the-implementation"></a>
## 了解实现
@@ -3,7 +3,10 @@ import { Service, type Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
import type { ProductEvent, ProductEventMap } from '../events.ts'
export type { ProductEvent, ProductEventMap, TrackProductEvent } from '../events.ts'
export type {
AnalyticsPreset, EntryAnalyticsRegion, ProductEvent, ProductEventMap,
SidebarAnalyticsEntry, SidebarAnalyticsPosition, TrackProductEvent,
} from '../events.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -25,6 +25,10 @@ export interface ProductEventMap {
thinking_effort?: string
run_mode: 'plan' | 'goal' | 'default'
msg_type: 'default' | 'steer' | 'queue'
/** Whether the message is the first one of a blank Session. */
is_new_session: boolean
/** Fixed category of the Session's Agent preset, when known. */
agent_preset?: AnalyticsPreset | 'other'
}
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 }
@@ -35,7 +39,10 @@ export interface ProductEventMap {
parent_message_id?: MessageId
click_position: 'footer' | 'sidebar'
}
sidebar_menu_click: { menu_name: 'plugin' | 'cron' }
sidebar_menu_click: { menu_name: SidebarAnalyticsEntry; position?: SidebarAnalyticsPosition }
more_menu_item_click: { region: EntryAnalyticsRegion; action: 'browse_plugins' | 'create_plugin' }
agent_preset_switch: { switch_from: AnalyticsPreset | 'other'; switch_to: AnalyticsPreset; code_work_view_enabled: boolean }
code_work_view_toggle: { is_enabled: boolean }
plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean }
plugin_add_button_click: Record<string, never>
plugin_install_click: { input_value: string }
@@ -49,6 +56,18 @@ export interface ProductEventMap {
confirm_uninstall_plugin: { plugin_name: string }
}
/** Official navigation entries; third-party identifiers are never analytics dimensions. */
export type SidebarAnalyticsEntry = 'new_session' | 'plugin' | 'cron' | 'more'
/** Physical navigation placement, independent of the selected destination. */
export type SidebarAnalyticsPosition = 'sidebar' | 'sidebar_more' | 'titlebar'
/** Entry-customization menu regions. */
export type EntryAnalyticsRegion = 'sidebar' | 'conversation' | 'rightbar'
/** Built-in presets, independent of localized names and custom preset identifiers. */
export type AnalyticsPreset = 'creator' | 'ptc' | 'minimal' | 'standard'
/** Public page names independent of internal onboarding state names. */
export type OnboardingPage = 'onboarding_welcome' | 'onboarding_recharge' | 'onboarding_use_case' | 'onboarding_process'
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: b3ad7867f23dad1c
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
en: 475fae2164ced556
zh: 4d0c729ffc210a83
en: b53d5c356c6a7a83
zh: ade3f5f551e73c7e
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
en: d7a38ad9e5497789
zh: 5f5595ddd90fa6bd
en: 0373c5425a952881
zh: 609c5829283f515d
/deepseek-ai-dsh-client-ui-agent-preset/further-exploration:
en: bbae8d79844bc481
zh: 4302107fd1f460e6
@@ -37,6 +37,8 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
Entering Creator selects `cordis` for the receiving blank Session without changing the new-task default or Coding Tools setting. If no workspace or blank Session is bound yet, the choice waits for that binding. After the choice is applied, the Creator Session keeps its preset; a later newly created Session uses the configured default, such as Standard.
On Desktop, the optional [product analytics](../product-analytics/README.md) service receives `agent_preset_switch` when a direct mode-picker choice moves to a different built-in preset: an unbound choice counts once it is staged for the next Session, and a choice for the bound blank Session counts once the Host applies it. The event carries the source and target categories mapped from the preset id (`cordis` is `creator`; `ptc`, `minimal`, and `standard` keep their ids; any other id is `other`) and the Coding Tools setting known at the choice. Custom targets, refused choices, choices the Host settles on another preset, default changes and their blank-Session synchronization report nothing, and neither does a choice made while the source mode or Coding Tools setting is unknown. Custom preset ids and names are not collected.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -47,6 +49,8 @@ Entering Creator selects `cordis` for the receiving blank Session without changi
`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController`, except when the main blank Session lists a different preset (navigation then opens another blank and leaves that one unchanged), and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
`AgentPresetSeatController.select()` and `apply()` return the attempt's outcome: `applied` when the Host commits the staged preset, `staged` while no Session is current, `unchanged` when busy, without a stage, for a started or already matching Session, or when the Host settles on another preset, and `refused` with the bare Host reason. The chip's injected `select` reports `agent_preset_switch` from that outcome and returns only the refusal reason.
</details>
<a id="further-exploration"></a>
@@ -37,6 +37,8 @@ kind: "package-reference"
进入创造模式会为接收该选择的空白 Session 选择 `cordis`,不改变新任务默认值或代码工作工具设置。尚未绑定工作区或空白 Session 时,选择等待绑定完成后应用。应用后,创造 Session 保留该预设;之后真正创建的新 Session 使用配置的默认值,例如标准模式。
在桌面端,模式选择器中的直接选择切换到另一个内置 preset 时,可选的[产品埋点](../product-analytics/README.zh.md)服务会收到 `agent_preset_switch`:未绑定 Session 的选择在暂存给下一个 Session 时计入,已绑定空白 Session 的选择在 Host 应用后计入。事件携带按 preset id 映射的来源与目标类别(`cordis` 记为 `creator`;`ptc`、`minimal`、`standard` 保持原 id;其他 id 记为 `other`),以及选择时已知的代码工作工具设置。选择自定义目标、被拒绝的选择、Host 最终落在其他 preset 的选择、默认值修改及其空白 Session 同步均不报告;来源模式或代码工作工具设置未知时做出的选择同样不报告。不采集自定义 preset 的 id 和名称。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -47,6 +49,8 @@ kind: "package-reference"
`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`(主区空白 Session 列出其他 preset 时不暂存,因为导航会打开另一个空白 Session,原空白 Session 保持不变),再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
`AgentPresetSeatController.select()` 与 `apply()` 返回本次尝试的结果:Host 提交暂存 preset 时为 `applied`;没有当前 Session 时为 `staged`;忙碌、没有暂存选择、Session 已开始或已使用该 preset,或 Host 最终落在其他 preset 时为 `unchanged`;Host 拒绝时为携带原始原因的 `refused`。选择器注入的 `select` 根据该结果报告 `agent_preset_switch`,并且只返回拒绝原因。
</details>
<a id="further-exploration"></a>
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
@@ -0,0 +1,17 @@
/** Fixed analytics categories for the picker; custom preset ids never leave this package. */
import type { AnalyticsPreset } from '@deepseek-ai/dsh-client-product-analytics/client'
/** Resolve a known selection by preset id without treating an unloaded state as a default.
* @param id - selected preset id; an empty id means unknown.
* @returns the built-in category for a shipped id, other for any other id, or undefined until known.
*/
export function analyticsMode(id: string): AnalyticsPreset | 'other' | undefined {
if (id === '') return undefined
switch (id) {
case 'cordis': return 'creator'
case 'ptc': return 'ptc'
case 'minimal': return 'minimal'
case 'standard': return 'standard'
default: return 'other'
}
}
@@ -44,6 +44,7 @@ import { AgentPresetSeatController, type AgentPresetStage } from './seat-store.t
import { AgentPresetSectionController } from './section-store.ts'
import { en, zh, type AgentPresetSettingsKey } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
import { analyticsMode } from './analytics.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -187,7 +188,21 @@ export function apply(ctx: ClientContext): void {
return {
hooks: { agentPresetSeat: seat.store, developerTools: ctx.configForms.developerTools.enabled },
load: () => seat.load(),
select: (id: string) => seat.select(id),
select: async (id: string) => {
const state = seat.store.getSnapshot()
const from = analyticsMode(state.current)
const to = analyticsMode(id)
const enabled = toolsSettings.getSnapshot().value?.enabled
const outcome = await seat.select(id)
// A bound pick counts once the Host applies it; an unbound pick counts when staged for the next Session.
const accepted = outcome.kind === 'applied' || (outcome.kind === 'staged' && sessionId === undefined)
if (accepted && from !== undefined && to !== undefined && to !== 'other' && from !== to && enabled !== undefined) {
ctx.get('productAnalytics')?.track('agent_preset_switch', {
switch_from: from, switch_to: to, code_work_view_enabled: enabled,
})
}
return outcome.kind === 'refused' ? outcome.reason : undefined
},
dismissRefusal: (error) => { seat.dismissRefusal(error) },
introduced: () => { seat.introduced() },
}
@@ -50,6 +50,17 @@ export interface AgentPresetStage {
introduce: boolean
}
/**
* Result of one attempt to hand a staged preset to the current Session: the Host applied it,
* it waits for a Session to appear, nothing changed (busy, already composed, started, or no
* stage), or the Host refused it.
*/
export type AgentPresetApplyOutcome =
| { readonly kind: 'applied' | 'staged' | 'unchanged' }
| { readonly kind: 'refused'; readonly reason: string }
const UNCHANGED: AgentPresetApplyOutcome = { kind: 'unchanged' }
/** Stages the next session's preset and applies it when one appears. */
export class AgentPresetSeatController {
/** Chip snapshot the renderer subscribes to. */
@@ -124,10 +135,10 @@ export class AgentPresetSeatController {
* The refusal is stored for the chip's announcement and returned to callers
* such as Settings that also report the result of their own write.
* @param id - the preset to stage.
* @returns the refusal text, or undefined once the pick settled.
* @returns how the pick settled; a refusal carries its text.
*/
async select(id: string): Promise<string | undefined> {
if (this.store.getSnapshot().busy) return undefined
async select(id: string): Promise<AgentPresetApplyOutcome> {
if (this.store.getSnapshot().busy) return UNCHANGED
this.stage(id)
return await this.apply()
}
@@ -182,7 +193,8 @@ export class AgentPresetSeatController {
const session = this.currentSession()
if (session === undefined || !session.blank || session.id !== expectedSessionId) return undefined
this.stage(id)
return await this.apply()
const outcome = await this.apply()
return outcome.kind === 'refused' ? outcome.reason : undefined
}
/** Acknowledge the introduction cue once the chip has played it. */
@@ -198,34 +210,34 @@ export class AgentPresetSeatController {
* Called both by `select()` and by whoever observes the current session
* changing, because the session may appear either before or after the pick.
* List updates do not repeat a selection while its response is pending.
* @returns this attempt's Host refusal, or undefined when successful or no switch starts.
* @returns how this attempt settled; only a Host commit of the staged preset is applied.
*/
async apply(): Promise<string | undefined> {
if (this.store.getSnapshot().busy) return
async apply(): Promise<AgentPresetApplyOutcome> {
if (this.store.getSnapshot().busy) return UNCHANGED
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
const current = session === undefined ? this.fallback : presetOf(session) ?? ''
const shown = this.store.getSnapshot()
if (current !== shown.current) this.set({ current })
return
return UNCHANGED
}
if (session === undefined) return
if (session === undefined) return { kind: 'staged' }
// A started session's history was produced under its own composition; the
// host refuses the swap, so the stage is no longer meaningful.
if (!session.blank || presetOf(session) === staged) {
this.clearStage()
return
return UNCHANGED
}
const completion = Promise.withResolvers<undefined>()
this.pendingSelection = completion.promise
this.clearStage()
const refuse = (reason: string) => {
const refuse = (reason: string): AgentPresetApplyOutcome => {
this.set({
error: { reason, preset: this.store.getSnapshot().options.find(option => option.id === staged) ?? { id: staged } },
current: this.staged.id ?? presetOf(session) ?? '',
})
return reason
return { kind: 'refused', reason }
}
try {
this.set({ busy: true, error: null })
@@ -239,6 +251,7 @@ export class AgentPresetSeatController {
return refuse(refusal)
}
this.set({ current: this.staged.id ?? result.value })
return result.value === staged ? { kind: 'applied' } : UNCHANGED
} catch (error) {
return refuse(error instanceof Error ? error.message : String(error))
} finally {
@@ -0,0 +1,28 @@
/**
* Analytics categories for picker selections come from the preset id alone:
* shipped ids map to fixed categories, every other id is `other`, and an
* unloaded selection has no category.
*/
import { describe, expect, it } from 'vitest'
import { analyticsMode } from '../src/client/analytics.ts'
describe('analyticsMode', () => {
it.each([
['cordis', 'creator'],
['ptc', 'ptc'],
['minimal', 'minimal'],
['standard', 'standard'],
] as const)('maps the built-in %s preset id to %s', (id, category) => {
expect(analyticsMode(id)).toBe(category)
})
it('treats any other preset id as other', () => {
expect(analyticsMode('mine')).toBe('other')
expect(analyticsMode('private-id')).toBe('other')
})
it('has no category before the selection is known', () => {
expect(analyticsMode('')).toBeUndefined()
})
})
@@ -68,6 +68,10 @@ const ROSTER_MOVED = {
async function bench(options: {
failSettingsUpdate?: boolean
selectGate?: Promise<void>
/** The Host refuses every selection. */
selectRefused?: boolean
/** The preset the Host reports after a selection, when it differs from the request. */
selectResult?: string
settingsRosterGate?: Promise<undefined>
describeGate?: Promise<undefined>
presets?: AgentPresetRow[]
@@ -164,7 +168,9 @@ async function bench(options: {
},
select: (_agentId: SessionId, agentPreset: string) => {
calls.push(`select:${agentPreset}`)
return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset }))
return Promise.resolve(options.selectGate).then(() => options.selectRefused === true
? { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }
: { ok: true as const, value: options.selectResult ?? agentPreset })
},
}
ctx.provide('remote.agentPresets', agentPresets as never)
@@ -1188,6 +1194,154 @@ describe('ui-agent-preset apply', () => {
})
})
/** Official presets plus one custom preset whose id and name must not leave the Client. */
const ANALYTICS_ROSTER: AgentPresetRow[] = [
{ id: 'standard', isDefault: true }, { id: 'cordis', isDefault: false },
{ id: 'ptc', isDefault: false }, { id: 'minimal', isDefault: false },
{ id: 'private-id', name: 'Private name', isDefault: false },
]
/**
* Mount the picker with a recording analytics service. Without `bound` the chip
* is the unbound new-session seat; otherwise it binds Session `blank`, which is
* the main view's current Session unless `current` is false.
*/
async function analyticsBench(
options: Parameters<typeof bench>[0] = {},
bound?: { preset?: string; blank: boolean; current?: boolean },
) {
const b = await bench({ presets: ANALYTICS_ROSTER, ...options })
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
const sessions = sessionsDouble(b.ctx, bound === undefined ? { byId: {} } : {
...bound.current === false ? {} : { current: 'blank' },
byId: { blank: {
id: 'blank', blank: bound.blank,
projectionValues: bound.preset === undefined ? {} : { agentPreset: bound.preset },
} },
})
b.ctx.provide('sessions', sessions as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
const track = vi.fn()
b.ctx.provide('productAnalytics', { enabled: true, track } as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const seat = (b.slots.entries('conversation.hero.agentPreset')[0]!.inject as
(sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>)(
bound === undefined ? undefined : SessionId('blank'))
await seat.load()
return { ...b, seat, sessions, track }
}
describe('agent preset switch analytics', () => {
it('reports unbound picks staged for the next Session, except equal modes and custom targets', async () => {
const b = await analyticsBench()
try {
await expect(b.seat.select('cordis')).resolves.toBeUndefined()
expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', {
switch_from: 'standard', switch_to: 'creator', code_work_view_enabled: true,
})
await b.seat.select('cordis')
expect(b.track).toHaveBeenCalledOnce()
await b.seat.select('private-id')
expect(b.track).toHaveBeenCalledOnce()
await b.seat.select('ptc')
expect(b.track).toHaveBeenLastCalledWith('agent_preset_switch', {
switch_from: 'other', switch_to: 'ptc', code_work_view_enabled: true,
})
expect(b.track).toHaveBeenCalledTimes(2)
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-id')
expect(JSON.stringify(b.track.mock.calls)).not.toContain('Private name')
} finally { await b.ctx.fiber.dispose() }
})
it('reports a bound pick once the Host applies it, and ignores picks made while it is pending', async () => {
const gate = Promise.withResolvers<undefined>()
const b = await analyticsBench({ selectGate: gate.promise }, { preset: 'standard', blank: true })
try {
const selecting = b.seat.select('minimal')
await expect(b.seat.select('cordis')).resolves.toBeUndefined()
expect(b.track).not.toHaveBeenCalled()
gate.resolve(undefined)
await expect(selecting).resolves.toBeUndefined()
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', {
switch_from: 'standard', switch_to: 'minimal', code_work_view_enabled: true,
})
} finally { gate.resolve(undefined); await b.ctx.fiber.dispose() }
})
it('does not report a bound pick that only waits for its Session to become current', async () => {
const b = await analyticsBench({}, { preset: 'standard', blank: true, current: false })
try {
await b.seat.select('cordis')
expect(b.seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(b.track).not.toHaveBeenCalled()
} finally { await b.ctx.fiber.dispose() }
})
it.each([
{ name: 'a started Session', options: {}, bound: { preset: 'standard', blank: false } },
{ name: 'a Host result other than the pick', options: { selectResult: 'standard' }, bound: { preset: 'standard', blank: true } },
{ name: 'an unknown source mode', options: {}, bound: { blank: true } },
])('does not report $name', async ({ options, bound }) => {
const b = await analyticsBench(options, bound)
try {
await expect(b.seat.select('cordis')).resolves.toBeUndefined()
expect(b.track).not.toHaveBeenCalled()
} finally { await b.ctx.fiber.dispose() }
})
it.each(['refused', 'thrown'] as const)('returns the reason of a %s pick without reporting it', async (failure) => {
const gate = Promise.withResolvers<undefined>()
const b = await analyticsBench(
{ selectGate: gate.promise, selectRefused: failure === 'refused' },
{ preset: 'standard', blank: true },
)
try {
const selecting = b.seat.select('cordis')
if (failure === 'thrown') gate.reject(new Error('connection lost'))
else gate.resolve(undefined)
await expect(selecting).resolves.toBe(failure === 'thrown' ? 'connection lost' : 'selection refused')
expect(b.track).not.toHaveBeenCalled()
} finally { gate.resolve(undefined); await b.ctx.fiber.dispose() }
})
it('records the Coding Tools setting known at the pick and reports nothing while it is unknown', async () => {
const described = Promise.withResolvers<undefined>()
const b = await analyticsBench({ describeGate: described.promise })
try {
expect(b.ctx.configForms.get('ui-settings').getSnapshot().value).toBeUndefined()
await b.seat.select('cordis')
expect(b.track).not.toHaveBeenCalled()
described.resolve(undefined)
await vi.waitFor(() => { expect(b.ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') })
expect(b.track).not.toHaveBeenCalled()
// Turning Coding Tools off clears the stage, so the chip falls back to the default.
await b.setDeveloperTools(false)
expect(b.seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
await b.seat.select('cordis')
expect(b.track).toHaveBeenCalledExactlyOnceWith('agent_preset_switch', {
switch_from: 'standard', switch_to: 'creator', code_work_view_enabled: false,
})
} finally { described.resolve(undefined); await b.ctx.fiber.dispose() }
})
it('does not report a default change or its blank-Session synchronization', async () => {
const b = await analyticsBench({}, { preset: 'standard', blank: true })
try {
const section = (b.slots.entries('settings.section')[0]!.inject as
() => AgentPresetSectionInjected & Record<string, unknown>)()
await section.load()
await section.makeDefault('minimal')
expect(b.calls).toContain('select:minimal')
expect(b.track).not.toHaveBeenCalled()
} finally { await b.ctx.fiber.dispose() }
})
})
describe('AgentPresetSeatController reconciliation', () => {
it.each([
{ refuseFirst: false, refuseLatest: false },
@@ -1379,7 +1533,7 @@ describe('AgentPresetSeatController reconciliation', () => {
// The surface reporting this names the preset itself, so carrying the
// roster's own "preset X failed to mount" frame would say it twice.
expect(await controller.select('broken')).toBe(reason)
expect(await controller.select('broken')).toEqual({ kind: 'refused', reason })
const first = controller.store.getSnapshot().error
expect(first).toEqual({ preset: { id: 'broken' }, reason })
await controller.select('broken')
@@ -1394,3 +1548,88 @@ describe('AgentPresetSeatController reconciliation', () => {
expect(controller.store.getSnapshot().error).toBe(reason)
})
})
describe('AgentPresetSeatController apply outcomes', () => {
type SessionView = { id: SessionId; blank: boolean; projectionValues?: { agentPreset?: string } }
type SelectReply = { ok: true; value: string } | { ok: false; error: RemoteError }
const blankStandard: SessionView = { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }
function seatOver(current: () => SessionView | undefined, reply?: (preset: string) => Promise<SelectReply>) {
const select = vi.fn((_id: SessionId, preset: string) => reply?.(preset) ?? Promise.resolve({ ok: true as const, value: preset }))
const controller = new AgentPresetSeatController({ ...developerTools(), remote: { agentPresets: { select } } } as never, current)
return { controller, select }
}
it('stages a pick while no Session is current', async () => {
const { controller, select } = seatOver(() => undefined)
await expect(controller.select('minimal')).resolves.toEqual({ kind: 'staged' })
expect(controller.store.getSnapshot().current).toBe('minimal')
expect(select).not.toHaveBeenCalled()
})
it('reports a display synchronization without a stage as unchanged', async () => {
const { controller, select } = seatOver(() => blankStandard)
await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' })
expect(controller.store.getSnapshot().current).toBe('standard')
expect(select).not.toHaveBeenCalled()
})
it.each([
{ name: 'a started Session', session: { ...blankStandard, blank: false }, pick: 'minimal' },
{ name: 'a Session already using the pick', session: blankStandard, pick: 'standard' },
])('leaves $name unchanged and drops the stage', async ({ session, pick }) => {
let current: SessionView = session
const { controller, select } = seatOver(() => current)
await expect(controller.select(pick)).resolves.toEqual({ kind: 'unchanged' })
current = { ...blankStandard, id: SessionId('next') }
await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' })
expect(select).not.toHaveBeenCalled()
})
it('is applied only when the Host commits the staged preset', async () => {
const { controller, select } = seatOver(() => blankStandard)
await expect(controller.select('minimal')).resolves.toEqual({ kind: 'applied' })
expect(select).toHaveBeenCalledExactlyOnceWith(SessionId('blank'), 'minimal')
expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', busy: false, error: null })
})
it('is unchanged when the Host settles on another preset', async () => {
const { controller } = seatOver(() => blankStandard, () => Promise.resolve({ ok: true, value: 'standard' }))
await expect(controller.select('minimal')).resolves.toEqual({ kind: 'unchanged' })
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: null })
})
it('leaves picks and applies made while a selection is pending unchanged', async () => {
const reply = Promise.withResolvers<SelectReply>()
const { controller, select } = seatOver(() => blankStandard, () => reply.promise)
const first = controller.select('minimal')
await expect(controller.select('cordis')).resolves.toEqual({ kind: 'unchanged' })
await expect(controller.apply()).resolves.toEqual({ kind: 'unchanged' })
expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', busy: true })
reply.resolve({ ok: true, value: 'minimal' })
await expect(first).resolves.toEqual({ kind: 'applied' })
expect(select).toHaveBeenCalledOnce()
})
it.each([
{
name: 'a Host refusal with a cause',
reply: () => Promise.resolve({ ok: false as const, error: new RemoteError(
'agent-preset/invalid', 'agent-presets: preset "minimal" failed to mount: gone', { agentPreset: 'minimal', reason: 'gone' },
) }),
reason: 'gone',
},
{
name: 'a Host refusal without a cause',
reply: () => Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }),
reason: 'selection refused',
},
{ name: 'a transport exception', reply: () => Promise.reject(new Error('connection lost')), reason: 'connection lost' },
])('is refused with the bare reason of $name', async ({ reply, reason }) => {
const { controller } = seatOver(() => blankStandard, reply)
await expect(controller.select('minimal')).resolves.toEqual({ kind: 'refused', reason })
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard', busy: false, error: { preset: { id: 'minimal' }, reason },
})
})
})
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../product-analytics/tsconfig.client.json"
},
{
"path": "../locale/tsconfig.client.json"
},
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 566ac2a2289609a8
zh: 068e895845d911a4
en: a7a0b235e1c5f2b0
zh: ca24e6bf77ad7e1b
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+1 -1
View File
@@ -102,7 +102,7 @@ Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter u
Clicking unused space inside an editable composer preserves its current caret or selection when focused, or restores its previous caret or selection when unfocused. Text selection, scrollbars, controls, and embedded overlays keep their own pointer behavior.
Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The snapshot also records whether the Session is still blank and the Agent preset id the Session records. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. On Desktop, the notification reports `send_button_click`, which carries `is_new_session` (true while the Session is blank) and `agent_preset`, the preset category: `creator` for `cordis`; `ptc`, `minimal`, and `standard` under their own ids; `other` for any other preset. `agent_preset` is omitted while the Session records no preset. Custom preset ids are not sent.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`.
+1 -1
View File
@@ -102,7 +102,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
点击可编辑输入框内的留白时,已聚焦的输入框保留当前光标或选区,未聚焦的输入框恢复上次的光标或选区。文本选择、滚动条、控件和内嵌浮层保持各自的指针交互行为。
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。快照还记录会话是否仍为空白,以及会话记录的 Agent preset id。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。在桌面端,该通知报告 `send_button_click`,携带 `is_new_session`(会话为空白时为 true)和表示 preset 类别的 `agent_preset`:`cordis` 记为 `creator`;`ptc`、`minimal` 与 `standard` 沿用各自的 id;其他 preset 记为 `other`。会话未记录 preset 时省略 `agent_preset`。不发送自定义 preset 的 id。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。
@@ -16,6 +16,10 @@ export interface MessageSubmissionState {
readonly model?: { readonly provider: string; readonly name: string; readonly effort?: string }
readonly runMode: 'default' | 'plan' | 'goal'
readonly running: boolean
/** Whether the Session is still blank, so this message is its first. */
readonly newSession: boolean
/** Agent preset id the Session records, when known. */
readonly agentPreset?: string
}
/** Immutable occurrence and delivery intent carried by one composer attempt. */
@@ -104,6 +104,7 @@ export class InputHub implements SessionInputResolver {
const model = session.projections.faceOf('modelSelection').getSnapshot() as ModelSelectionProjection | undefined
const plan = session.projections.faceOf('plan').getSnapshot() as PlanProjection | undefined
const goal = session.projections.faceOf('goal').getSnapshot() as GoalProjection | null | undefined
const agentPreset = session.projections.faceOf('agentPreset').getSnapshot()
const selection = model?.next ?? model?.lastUsed
return Object.freeze({
...state.blank ? {} : { sessionId: state.sessionId },
@@ -113,6 +114,8 @@ export class InputHub implements SessionInputResolver {
}) },
runMode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default',
running: state.running,
newSession: state.blank,
...typeof agentPreset === 'string' ? { agentPreset } : {},
})
},
messageSubmitted: (submission) => { reportMessageSubmission(this.rootCtx, submission) },
@@ -1,8 +1,23 @@
/** Maps ordinary message occurrences to Desktop product events. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { AnalyticsPreset } from '@deepseek-ai/dsh-client-product-analytics/client'
import type { MessageSubmission } from '../contract/composer-submission.ts'
/**
* Map a preset id to its fixed analytics category; custom ids never leave the Client.
* @param id - Agent preset id the Session records.
* @returns the built-in category, or other.
*/
function analyticsPreset(id: string): AnalyticsPreset | 'other' {
switch (id) {
case 'cordis': return 'creator'
case 'ptc': return 'ptc'
case 'minimal': return 'minimal'
case 'standard': return 'standard'
default: return 'other'
}
}
/**
* Report the original message occurrence without reading newer Session facts.
* @param ctx - client context.
@@ -17,5 +32,7 @@ export function reportMessageSubmission(ctx: Context, submission: MessageSubmiss
...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } },
run_mode: state.runMode,
msg_type: state.running ? mode : 'default',
is_new_session: state.newSession,
...state.agentPreset === undefined ? {} : { agent_preset: analyticsPreset(state.agentPreset) },
}, timestamp)
}
@@ -293,7 +293,7 @@ describe('submit transaction hardening', () => {
it('captures click and Enter submission intent before async admission, excluding empty submits', async () => {
const report = vi.fn()
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false, newSession: false }))
const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, submissionState, messageSubmitted: report })
try {
shell.submit('queue', 'click')
@@ -327,7 +327,7 @@ it.each(['capture', 'report'])('analytics %s failure does not interrupt a messag
it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => {
const pending = Promise.withResolvers<PickOutcome>()
const report = vi.fn()
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false, newSession: false }))
const command = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const sink = vi.fn(async (): Promise<SubmitOutcome> => ({ kind: 'success' }))
const inputTriggers: InputTriggerController = {
@@ -366,7 +366,7 @@ it('retains occurrence time and Session facts across arbitration and independent
let now = 100
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
const shell = new SessionInputShell({ actx: {} as Context, commandAttachments,
submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running }),
submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running, newSession: false }),
messageSubmitted: submitted,
inputTriggers: () => ({
launcher: { getSnapshot: () => null, subscribe: () => () => {} },
@@ -386,10 +386,10 @@ it('retains occurrence time and Session facts across arbitration and independent
expect(submitted).not.toHaveBeenCalled()
pending.resolve(undefined)
await vi.waitFor(() => { expect(submitted).toHaveBeenCalledOnce() })
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan' } })
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan', newSession: false } })
shell.setDraft('second')
shell.actions.submit()
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default' } })
expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default', newSession: false } })
await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
secondSend.resolve({ kind: 'error', text: 'second rejected' })
firstSend.resolve({ kind: 'error', text: 'first rejected' })
@@ -14,6 +14,7 @@ import type {
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { reportMessageSubmission } from '../src/client/input/submission-analytics.ts'
import { ConversationController } from '../src/client/service.ts'
import { zh } from '../src/client/locales.ts'
@@ -894,6 +895,7 @@ it('captures run mode and queue intent at submission before later projection cha
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue',
is_new_session: false,
}, expect.any(Number))
await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false })
expect(track).toHaveBeenCalledTimes(1)
@@ -912,6 +914,104 @@ it.each([
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))
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
run_mode: 'default', msg_type: expected, is_new_session: true,
}, expect.any(Number))
} finally { await b.runtime.dispose() }
})
it('marks only a message sent from a blank Session as a new Session', async () => {
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 = false; state.blank = true })
b.shell.setDraft('first message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenLastCalledWith('send_button_click', {
run_mode: 'default', msg_type: 'default', is_new_session: true,
}, expect.any(Number))
await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.blank = false })
b.shell.setDraft('second message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenLastCalledWith('send_button_click', {
session_id: 's1', run_mode: 'default', msg_type: 'default', is_new_session: false,
}, expect.any(Number))
expect(track).toHaveBeenCalledTimes(2)
} finally { await b.runtime.dispose() }
})
it.each([
{ preset: 'cordis', category: 'creator' },
{ preset: 'ptc', category: 'ptc' },
{ preset: 'minimal', category: 'minimal' },
{ preset: 'standard', category: 'standard' },
] as const)('reports the built-in $preset preset as $category', async ({ preset, category }) => {
const b = await bench()
const track = vi.fn()
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
try {
await b.runtime.sessions.setProjection('s1', 'agentPreset', preset)
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = true })
b.shell.setDraft('message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
run_mode: 'default', msg_type: 'default', is_new_session: true, agent_preset: category,
}, expect.any(Number))
} finally { await b.runtime.dispose() }
})
it('reports a custom preset as other without its id', async () => {
const b = await bench()
const track = vi.fn()
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
try {
await b.runtime.sessions.setProjection('s1', 'agentPreset', 'private-preset')
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = false })
b.shell.setDraft('message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
session_id: 's1', run_mode: 'default', msg_type: 'default', is_new_session: false, agent_preset: 'other',
}, expect.any(Number))
expect(JSON.stringify(track.mock.calls)).not.toContain('private-preset')
} finally { await b.runtime.dispose() }
})
it('omits the preset category while the Session records no preset', async () => {
const b = await bench()
const track = vi.fn()
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
try {
await b.runtime.sessions.setProjection('s1', 'agentPreset', null)
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = true })
b.shell.setDraft('message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledOnce()
expect(track.mock.calls[0]?.[1]).not.toHaveProperty('agent_preset')
} finally { await b.runtime.dispose() }
})
it('reports an active goal and a model without an explicit effort', async () => {
const b = await bench()
const track = vi.fn()
b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
try {
await b.runtime.sessions.setProjection('s1', 'goal', { goal: { phase: 'active' } })
await b.runtime.sessions.setProjection('s1', 'modelSelection', { next: null, lastUsed: { provider: 'fixture', model: 'model' } })
await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = false; state.blank = false })
b.shell.setDraft('message')
b.shell.submit('queue', 'click')
expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
session_id: 's1', model_name: 'fixture/model', run_mode: 'goal', msg_type: 'default', is_new_session: false,
}, expect.any(Number))
} finally { await b.runtime.dispose() }
})
it('reports nothing for a submission whose Session facts were not captured', () => {
const ctx = new Context()
const track = vi.fn()
ctx.provide('productAnalytics', { enabled: true, track } as never)
reportMessageSubmission(ctx, { timestamp: 1, mode: 'queue', source: 'click' })
expect(track).not.toHaveBeenCalled()
})
@@ -6,8 +6,8 @@
en: 200adf0dd8c9757c
zh: 3d10d49a71289702
/deepseek-ai-dsh-client-ui-entry-customization:
en: 942676b8e2aef2b6
zh: 9e107b4c007856ad
en: ab35194f4a9dafce
zh: 7e9e57fde518c3d7
/deepseek-ai-dsh-client-ui-entry-customization/summary:
en: d53e7d71da9fd4d6
zh: a32b635ec1e572f3
@@ -15,11 +15,11 @@
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
en: 9edf91832478f10d
zh: 2baeefa27d2c01fe
en: 7cc90968634e3f7b
zh: 54f58e17b14f7404
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
en: 12a9c39c4e309946
zh: 59027889d3d7a49a
en: 3dcb1f29d03d5afe
zh: 5ee39637636cd062
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
en: b891f311c09d536f
zh: 68c0f41fbfed0d73
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
## Summary
Fill the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide with each region's customizable entries, pin buttons, plugin browsing, and a reviewable Creator draft. The Web app loads it by default. Each region draws its own More button and stores its own pin choices; this package stores nothing.
@@ -30,6 +32,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a
**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available when a region has no customizable entries; an empty list adds no separator.
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
-----
<a id="understand-the-implementation"></a>
@@ -40,7 +44,7 @@ Open **More** beneath the left navigation entries, **More conversation views** a
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open.
Each registration's `inject` supplies its region and two callbacks. Browsing calls `pluginNavigation.openList()`. Creation calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
</details>
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
## 概述
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。
@@ -30,6 +32,8 @@ kind: "package-reference"
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
-----
<a id="understand-the-implementation"></a>
@@ -40,7 +44,7 @@ kind: "package-reference"
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。
每个注册项的 `inject` 提供所属区域与两个回调。浏览调用 `pluginNavigation.openList()`。创建按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
</details>
@@ -24,6 +24,7 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
@@ -1,6 +1,7 @@
/** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -29,8 +30,12 @@ export function apply(ctx: Context): void {
const t = ctx.locale.bind('entryCustomization')
const injected = (region: EntryRegion): EntryMenuInjected => ({
region,
browsePlugins: () => { ctx.pluginNavigation.openList() },
browsePlugins: () => {
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' })
ctx.pluginNavigation.openList()
},
createPlugin: () => {
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' })
ctx.uiWorkspace.startSession(undefined, {
agentPreset: 'cordis', prompt: t(`createPrompt.${region}`), whenDraftExists: 'append',
})
@@ -8,6 +8,7 @@ import { useState } from 'react'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx'
import { en } from '../src/client/locales.ts'
import { apply, inject } from '../src/client/index.ts'
@@ -111,10 +112,10 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
</>
}
async function bench(language: 'en' | 'zh' = 'en') {
async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) {
const runtime = await SlotTestRuntime.create()
runtimes.add(runtime)
const openList = vi.fn(), startSession = vi.fn()
const openList = vi.fn(), startSession = vi.fn(), track = vi.fn<TrackProductEvent>()
await runtime.mount({
inject: ['slots'],
apply(ctx: Context) {
@@ -124,6 +125,7 @@ async function bench(language: 'en' | 'zh' = 'en') {
ctx.slots.installLocale(locale)
ctx.provide('pluginNavigation', { openList } as never)
ctx.provide('uiWorkspace', { startSession } as never)
if (analytics) ctx.provide('productAnalytics', { track } as never)
},
})
await runtime.root.declare({
@@ -133,7 +135,7 @@ async function bench(language: 'en' | 'zh' = 'en') {
}, Frame)
const plugin = await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
return { runtime, openList, startSession, plugin, view }
return { runtime, openList, startSession, track, plugin, view }
}
describe('More menu registrations', () => {
@@ -174,4 +176,39 @@ describe('More menu registrations', () => {
agentPreset: 'cordis', prompt, whenDraftExists: 'append',
})
})
it.each([
['sidebar.panellist.actions', 'sidebar'],
['conversation.session.header.views.actions', 'conversation'],
['sidebar.right.tab.guide.actions', 'rightbar'],
] as const)('reports both footer actions of %s as the %s region before acting', async (seat, region) => {
const b = await bench()
const menu = within(b.view.getByRole('menu', { name: seat }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.track).toHaveBeenCalledExactlyOnceWith('more_menu_item_click', { region, action: 'browse_plugins' })
expect(b.track.mock.invocationCallOrder[0]).toBeLessThan(b.openList.mock.invocationCallOrder[0]!)
fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.track).toHaveBeenLastCalledWith('more_menu_item_click', { region, action: 'create_plugin' })
expect(b.track.mock.invocationCallOrder[1]).toBeLessThan(b.startSession.mock.invocationCallOrder[0]!)
expect(b.track).toHaveBeenCalledTimes(2)
})
it('reports neither entry opening nor pin changes', async () => {
const b = await bench()
const menu = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Notes' }))
expect(b.view.getByTestId('opened').textContent).toBe('sidebar.panellist.actions:notes')
expect(b.track).not.toHaveBeenCalled()
})
it('keeps both footer actions working without the analytics service', async () => {
const b = await bench('en', { analytics: false })
const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
expect(b.startSession).toHaveBeenCalledOnce()
expect(b.track).not.toHaveBeenCalled()
})
})
@@ -5,6 +5,7 @@
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../client/locale/tsconfig.client.json" },
{ "path": "../../client/product-analytics/tsconfig.client.json" },
{ "path": "../../client/ui-sidebar" },
{ "path": "../../client/ui-conversation/tsconfig.client.json" },
{ "path": "../../client/ui-sidebar-right" },
@@ -18,8 +18,8 @@
en: 4285513efdb1be68
zh: b28089509254e8d5
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
en: 8b8a7c6287bb303b
zh: 19535112531aedd5
en: de35e867b2a56d73
zh: 6d4e5c912636f889
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/opening-the-configuration-file:
en: 597df68b5913e8d7
zh: bc9114f3e877679a
@@ -43,7 +43,7 @@ Settings visibility and section selection live in the shell owner store. The she
The current release version appears at the bottom of General Settings in Web and Desktop, using the build’s `DSH_CLIENT_VERSION` metadata and the active language. Partial builds without version metadata omit the row.
The Coding Tools switch controls the shared `ui-settings.enabled` preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance.
The Coding Tools switch controls the shared `ui-settings.enabled` preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance. On Desktop, a change made with this switch reports `code_work_view_toggle` with the new value through the optional [product analytics](../product-analytics/README.md) service, timestamped at the click. Only a write that changes a known value and leaves the preference at the requested value reports; requesting the current value, an unknown previous value, a failed write, and preference updates made elsewhere report nothing.
The General section holds the built-in Coding Tools and Current version rows alongside rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme.
@@ -43,7 +43,7 @@ kind: "package-reference"
Web 与桌面端的通用设置底部显示当前发布版本,使用构建注入的 `DSH_CLIENT_VERSION` 元数据和当前语言。缺少版本元数据的局部构建不显示该行。
代码工作工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好 `ui-settings.enabled`。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。
代码工作工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好 `ui-settings.enabled`。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。在桌面端,通过此开关做出的变更会经可选的[产品埋点](../product-analytics/README.zh.md)服务报告携带新值的 `code_work_view_toggle`,时间戳取点击时刻。只有改变已知值、且写入后偏好等于所请求值的写入才会报告;请求当前值、原值未知、写入失败以及在其他位置修改偏好均不报告。
「通用」分区承载内置的代码工作工具行与当前版本行,以及功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。
@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-modules": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
@@ -24,6 +24,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './shell-contract.ts'
@@ -81,7 +82,15 @@ export function apply(ctx: ClientContext): void {
name: 'settings.general.item', id: 'developer-tools', order: 15, locale: NS,
inject: (): DeveloperToolsRowInjected => ({
hooks: { developerTools: ctx.configForms.developerTools.enabled },
setEnabled: enabled => ctx.configForms.developerTools.setEnabled(enabled),
setEnabled: async (enabled) => {
const settings = ctx.configForms.get<{ enabled: boolean }>('ui-settings')
const previous = settings.getSnapshot().value?.enabled
const timestamp = Date.now()
await ctx.configForms.developerTools.setEnabled(enabled)
if (previous !== undefined && previous !== enabled && settings.getSnapshot().value?.enabled === enabled) {
ctx.get('productAnalytics')?.track('code_work_view_toggle', { is_enabled: enabled }, timestamp)
}
},
}),
}, DeveloperToolsRow))
// Version information follows the core preferences.
@@ -73,6 +73,11 @@ function generalLabel(c: TestClient): string | undefined {
return resolveSlotLabel(generalEntry(c).options.label)
}
function developerToolsRowOf(c: TestClient): DeveloperToolsRowInjected {
const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')!
return (developerRow.inject as unknown as () => DeveloperToolsRowInjected)()
}
function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected {
const entry = ownEntries(c, 'settings.action')[0]!
return (entry.inject as unknown as () => SettingsDocumentActionInjected)()
@@ -106,8 +111,7 @@ describe('ui-settings-general apply', () => {
// The shared developer-tool control belongs to General; onboarding remains feature-owned.
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools', 'current-version'])
expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')!
const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)()
const developer = developerToolsRowOf(c)
expect(developer.hooks.developerTools).toBe(c.ctx.configForms.developerTools.enabled)
expect(developer.hooks.developerTools.getSnapshot()).toBe(false)
const setEnabled = vi.spyOn(c.ctx.configForms.developerTools, 'setEnabled').mockResolvedValue(undefined)
@@ -122,6 +126,62 @@ describe('ui-settings-general apply', () => {
}
}, COLD_BOOT_TIMEOUT_MS)
it('reports a Coding Tools toggle only when a known value changes to the requested one', async ({ mock, start }) => {
const { c } = await client(mock, start)
const track = vi.fn()
c.ctx.provide('productAnalytics', { enabled: true, track } as never)
const form = c.ctx.configForms.get<{ enabled: boolean }>('ui-settings')
const snapshot = form.getSnapshot()
let value: { enabled: boolean } | undefined = { enabled: false }
let accepted = true
let now = 100
const reads = vi.spyOn(form, 'getSnapshot').mockImplementation(() => ({ ...snapshot, value }))
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
const write = vi.spyOn(c.ctx.configForms.developerTools, 'setEnabled').mockImplementation(async (enabled) => {
now += 50
if (accepted) value = { enabled }
})
onTestFinished(() => {
reads.mockRestore()
clock.mockRestore()
write.mockRestore()
})
const row = developerToolsRowOf(c)
// The event carries the click time, before the write settles.
await row.setEnabled(true)
expect(track).toHaveBeenCalledExactlyOnceWith('code_work_view_toggle', { is_enabled: true }, 100)
// Requesting the current value writes but changes nothing.
await row.setEnabled(true)
expect(write).toHaveBeenCalledTimes(2)
expect(track).toHaveBeenCalledOnce()
// A write that settles without the requested value is not a toggle.
accepted = false
await row.setEnabled(false)
expect(value).toEqual({ enabled: true })
expect(track).toHaveBeenCalledOnce()
// A rejected write reports its failure and no toggle.
accepted = true
write.mockRejectedValueOnce(new Error('not accepted'))
await expect(row.setEnabled(false)).rejects.toThrow('not accepted')
expect(track).toHaveBeenCalledOnce()
// Without a known previous value the change cannot be attributed.
value = undefined
await row.setEnabled(false)
expect(value).toEqual({ enabled: false })
expect(track).toHaveBeenCalledOnce()
value = { enabled: true }
now = 300
await row.setEnabled(false)
expect(track).toHaveBeenCalledTimes(2)
expect(track).toHaveBeenLastCalledWith('code_work_view_toggle', { is_enabled: false }, 300)
})
it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => {
const { c, settings } = await client(mock, start)
const english = localeView('en', 1)
@@ -25,6 +25,7 @@
{ "path": "../ui-sidebar" },
{ "path": "../shortcuts/tsconfig.client.json" },
{ "path": "../modules" },
{ "path": "../product-analytics/tsconfig.client.json" },
{ "path": "../ui-layout" }
]
}
+7 -4
View File
@@ -30,11 +30,14 @@
en: 28e3639ed58492e0
zh: 4896acd767287c8a
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/scrollbars:
en: d053b1ed0f4fd293
zh: ff25a56541f57530
en: 9e7b31acee759f2b
zh: 3a6beb63b23d26ae
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics:
en: bb3f4a199ed49039
zh: cc29413fd31fe2e7
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation:
en: 676cd4c309b4110d
zh: 25d1bd0b2c7db645
en: bc51c446d8b8e098
zh: c0c7bbfb50541d33
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline:
en: 6eefc5c45abe8a8f
zh: 20b651e48a0ea903
+10 -1
View File
@@ -57,6 +57,15 @@ Under `html[data-platform='darwin']` (set only by the desktop preload) the expan
Scrollbars in the column are a pointer affordance: the shell rebinds the scrollbar indirection to `transparent` whenever the pointer is outside the column and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region (ui-workspace), so revealing a thumb never reflows.
### Click analytics
On Desktop, the official navigation controls report `sidebar_menu_click` with `menu_name` and `position` before they act. Without the analytics service, the controls only navigate. Selecting a panel by other means reports nothing, and the sidebar reports no exposure events.
- The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`.
- The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing.
- Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu.
- A More menu row that opens Plugins or Automation tasks through `onOpen(id)` reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected.
-----
<a id="understand-the-implementation"></a>
@@ -65,7 +74,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds the scrollb
<details>
<summary>Implementation internals — click to expand</summary>
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store.
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
### Slot discipline
+10 -1
View File
@@ -57,6 +57,15 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把滚动条间接层重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身(ui-workspace),所以显示滑块不会引起重排。
### 点击埋点
在桌面端,官方导航控件在执行操作前上报 `sidebar_menu_click`,携带 `menu_name` 与 `position`。没有埋点服务时,这些控件只执行导航。通过其他途径选中面板不上报,侧边栏也不上报曝光事件。
- 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。
- **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。
- 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。
- 更多菜单中的行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。
-----
<a id="understand-the-implementation"></a>
@@ -65,7 +74,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
<details>
<summary>实现细节——点击展开</summary>
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
### slot 纪律
@@ -23,7 +23,7 @@ export type HeaderLeadingControlsProps =
* @param props - Injected sidebar actions plus the sidebar locale seat.
* @returns the two window-chrome controls.
*/
export function HeaderLeadingControls({ toggleSidebar, startSession, useShortcuts, t }: HeaderLeadingControlsProps) {
export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryClick, useShortcuts, t }: HeaderLeadingControlsProps) {
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle'))
const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new'))
return (
@@ -45,7 +45,10 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, useShortcut
className={css.iconButton}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={() => { startSession() }}
onClick={() => {
trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
startSession()
}}
>
<IconNewChatOutlineRegular size={16} />
</button>
@@ -54,11 +54,17 @@ type PanelRowProps =
Pick<SidebarPanelMetadata, 'id' | 'label'>
& Pick<SidebarSectionOwnerProps, 'wide'>
& Pick<PropsRuntime<'sidebar'>, 'usePanelInfo'>
& Pick<InjectFace<SidebarRootInjected>, 'selectPanel'>
& Pick<InjectFace<SidebarRootInjected>, 'selectPanel' | 'trackEntryClick'>
& PropsRenderSlots<'sidebar.panellist'>
/** Analytics category of an official panel; other panels are never reported. */
function officialEntry(id: string): 'plugin' | 'cron' | undefined {
if (id === 'plugins') return 'plugin'
return id === 'schedules' ? 'cron' : undefined
}
/** Each panel row subscribes only to its own selection state. */
function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: PanelRowProps) {
function PanelRow({ id, label, wide, usePanelInfo, selectPanel, trackEntryClick, renderSlot }: PanelRowProps) {
const active = usePanelInfo(info => info.activePanelId === id)
return (
<Tooltip label={label} delayMs={500} disabled={wide}>
@@ -67,7 +73,11 @@ function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: Pa
className={clsx(css.panelRow, active && css.panelActive)}
aria-label={label}
aria-current={active ? 'page' : undefined}
onClick={() => { selectPanel(id) }}
onClick={() => {
const entry = officialEntry(id)
if (entry !== undefined) trackEntryClick({ menu_name: entry, position: 'sidebar' })
selectPanel(id)
}}
>
<span className={css.panelGlyph} aria-hidden="true">
{renderSlot('sidebar.panellist', { size: wide ? 16 : 18, active }, { only: id })}
@@ -86,10 +96,11 @@ type PanelMoreMenuProps =
Pick<SidebarSectionOwnerProps, 'wide'>
& Omit<SidebarPanelActionsOwnerProps, 'onDismiss'>
& PropsRenderSlots<'sidebar.panellist.actions'>
& Pick<InjectFace<SidebarRootInjected>, 'trackEntryClick'>
& { readonly label: string }
/** The panel list's More launcher: a panel-row button whose menu rows come from `sidebar.panellist.actions`. */
function PanelMoreMenu({ wide, label, entries, onOpen, onPinnedChange, renderSlot }: PanelMoreMenuProps) {
function PanelMoreMenu({ wide, label, entries, onOpen, onPinnedChange, trackEntryClick, renderSlot }: PanelMoreMenuProps) {
const [open, setOpen] = useState(false)
const onDismiss = (): void => { setOpen(false) }
return (
@@ -97,7 +108,10 @@ function PanelMoreMenu({ wide, label, entries, onOpen, onPinnedChange, renderSlo
anchor={(
<Tooltip label={label} delayMs={500} disabled={wide}>
<button type="button" className={css.panelRow} aria-label={label} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setOpen(value => !value) }}>
onClick={() => {
trackEntryClick({ menu_name: 'more', position: 'sidebar' })
setOpen(value => !value)
}}>
<span className={css.panelGlyph} aria-hidden="true"><IconAppsAddOutlineRegular size={wide ? 16 : 18} /></span>
{wide && <span className={clsx(css.panelTitle, css.wide)}>{label}</span>}
</button>
@@ -120,6 +134,7 @@ export function SidebarRoot({
toggleSidebar,
selectPanel,
setPinned,
trackEntryClick,
usePanels,
useEntryPins,
useEntryActions,
@@ -145,6 +160,11 @@ export function SidebarRoot({
}, [collapsed])
const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar')
const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
// The Windows titlebar hosts the New Session controls while the sidebar is collapsed.
const openNewSession = (): void => {
trackEntryClick({ menu_name: 'new_session', position: windowsTitlebar && collapsed ? 'titlebar' : 'sidebar' })
startSession()
}
// The Windows caption menus occupy the strip to the right of these controls
// (that is what --dsh-windows-menu-start reserves), so a right-side bubble
// lands under their text. Below the caption is the only clear side.
@@ -297,7 +317,7 @@ export function SidebarRoot({
aria-keyshortcuts={newShortcut?.aria}
onClick={(event) => {
if ((event.target as Element).closest('[data-copy-version]') !== null) return
startSession()
openNewSession()
}}
>
{identity}
@@ -314,7 +334,7 @@ export function SidebarRoot({
className={css.newSession}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={() => { startSession() }}
onClick={openNewSession}
>
<span className={css.newSessionLabelMask}><span className={css.newSessionContent}>
{wide
@@ -338,6 +358,7 @@ export function SidebarRoot({
wide={wide}
usePanelInfo={usePanelInfo}
selectPanel={selectPanel}
trackEntryClick={trackEntryClick}
renderSlot={renderSlot}
/>
))}
@@ -348,10 +369,13 @@ export function SidebarRoot({
entries={panels.filter(({ id }) => id !== FIXED_PANEL_ID)
.map(({ id, label }) => ({ id, label, pinned: !unpinned.includes(id) }))}
onOpen={(id) => {
const entry = officialEntry(id)
if (entry !== undefined) trackEntryClick({ menu_name: entry, position: 'sidebar_more' })
const panel = panels.find(candidate => candidate.id === id)
if (panel !== undefined) selectPanel(panel.id)
}}
onPinnedChange={setPinned}
trackEntryClick={trackEntryClick}
renderSlot={renderSlot}
/>
)}
@@ -12,6 +12,7 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -162,6 +163,8 @@ export type SidebarRootInjected = {
* @param pinned - Whether the launcher shows in the sidebar.
*/
setPinned: (id: string, pinned: boolean) => void
/** Record activation of an official navigation control without changing navigation. */
trackEntryClick: (attributes: ProductEventMap['sidebar_menu_click']) => void
/** Private reactive sources bound to framework selector hooks. */
hooks: {
panels: ObservableSnapshot<readonly SidebarPanelMetadata[]>
@@ -73,11 +73,9 @@ export function apply(ctx: ClientContext): void {
// (current Session Workspace, then recent Workspace).
startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) },
toggleSidebar: () => { ctx.layout.toggleSidebar() },
selectPanel: (id) => {
if (id === 'plugins' || id === 'schedules') ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: id === 'plugins' ? 'plugin' : 'cron' })
ctx.layout.selectPanel(id)
},
selectPanel: (id) => { ctx.layout.selectPanel(id) },
setPinned: panelPins.setPinned,
trackEntryClick: (attributes) => { ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes) },
hooks: { panels, shortcuts: ctx.shortcuts.catalog, entryPins: panelPins.unpinned, entryActions },
})
ctx.slots.inject('sidebar', () => ctx.slots.register({
@@ -8,6 +8,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import { HeaderLeadingControls } from '../src/client/HeaderLeadingControls.tsx'
import { apply as hostApply } from '../src/index.ts'
@@ -80,7 +81,7 @@ describe('ui-sidebar apply', () => {
expect(leading[0]!.locale).toBe('sidebar')
expect(leading[0]!.inject).toBe(b.slots.entries('sidebar')[0]!.inject)
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'setPinned', 'hooks'])
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'setPinned', 'trackEntryClick', 'hooks'])
expect(injected.hooks.panels.getSnapshot()).toEqual([])
expect(injected.hooks.entryPins.getSnapshot()).toEqual([])
expect(injected.hooks.entryActions.getSnapshot()).toBe(false)
@@ -98,6 +99,27 @@ describe('ui-sidebar apply', () => {
expectTypeOf<Parameters<SidebarRootInjected['selectPanel']>[0]>().toEqualTypeOf<MainPanelId>()
})
it('forwards entry clicks to the optional product analytics service without tracking panel selection', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
// Ordinary Web mounts no analytics service; reporting is then a no-op.
expect(() => { injected.trackEntryClick({ menu_name: 'more', position: 'sidebar' }) }).not.toThrow()
const track = vi.fn<TrackProductEvent>()
b.ctx.provide('productAnalytics', { track } as never)
injected.trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
expect(track).toHaveBeenCalledExactlyOnceWith('sidebar_menu_click', { menu_name: 'new_session', position: 'titlebar' })
// Selection only navigates; the clicked control reports its own entry and position.
injected.selectPanel('plugins' as MainPanelId)
injected.selectPanel('schedules' as MainPanelId)
expect(b.layout.selectPanel.mock.calls).toEqual([['plugins'], ['schedules']])
expect(track).toHaveBeenCalledOnce()
// The window-chrome occupant shares the same reporting callback.
const leading = (b.slots.entries('shell.leading')[0]!.inject as () => SidebarRootInjected)()
leading.trackEntryClick({ menu_name: 'cron', position: 'sidebar_more' })
expect(track).toHaveBeenLastCalledWith('sidebar_menu_click', { menu_name: 'cron', position: 'sidebar_more' })
})
it('waits for the sidebar declaration before registering', async () => {
const b = await bench(false)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
@@ -11,6 +11,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ILayout, MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { PropsRenderSlots, PropsRuntime, SlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import { apply, inject } from '../src/client/index.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -21,6 +22,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const ALPHA = 'sidebar-test-alpha' as MainPanelId
const BETA = 'sidebar-test-beta' as MainPanelId
const PLUGINS = 'plugins' as MainPanelId
const SCHEDULES = 'schedules' as MainPanelId
const runtimes = new Set<SlotTestRuntime>()
afterEach(async () => {
@@ -33,7 +36,7 @@ afterEach(async () => {
}
})
async function bench(collapsed = false) {
async function bench(collapsed = false, track?: TrackProductEvent) {
const runtime = await SlotTestRuntime.create()
runtimes.add(runtime)
const locale = new LocaleRuntime(runtime.ctx)
@@ -53,6 +56,7 @@ async function bench(collapsed = false) {
ctx.provide('layout', layout)
ctx.provide('uiWorkspace', { startSession: vi.fn() } as never)
ctx.provide('locale', locale)
if (track !== undefined) ctx.provide('productAnalytics', { track } as never)
ctx.effect(() => locale.register('common', { zh: commonZh, en: commonEn }), 'panel test: common locale')
ctx.effect(() => locale.register('sidebar-panel-test', {
zh: { alpha: '甲面板' }, en: { alpha: 'Alpha panel' },
@@ -103,12 +107,22 @@ function StaleOpen({ onOpen }: PropsRuntime<'sidebar.panellist.actions'>) {
return <button type="button" role="menuitem" onClick={() => { onOpen(ALPHA) }}>Open removed panel</button>
}
async function mountProbeActions(runtime: SlotTestRuntime) {
/** Requests the official panels by id, including Plugins, which the menu entries omit. */
function OfficialOpen({ onOpen }: PropsRuntime<'sidebar.panellist.actions'>) {
return (
<>
<button type="button" role="menuitem" onClick={() => { onOpen(PLUGINS) }}>Open Plugins by id</button>
<button type="button" role="menuitem" onClick={() => { onOpen(SCHEDULES) }}>Open Automation by id</button>
</>
)
}
async function mountProbeActions(runtime: SlotTestRuntime, id = 'probe', component = ProbeActions) {
return runtime.mount({
inject: ['slots'],
apply(ctx: Context) {
ctx.slots.inject('sidebar.panellist.actions', () => ctx.slots.register(
{ name: 'sidebar.panellist.actions', id: 'probe' }, ProbeActions,
{ name: 'sidebar.panellist.actions', id }, component,
))
},
})
@@ -314,7 +328,6 @@ describe('sidebar global panels', () => {
describe('sidebar launcher pins', () => {
const PINS_KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
const PLUGINS = 'plugins' as MainPanelId
it('hides stored unpinned panels, keeps Plugins, and keeps the list while panels are registered', async () => {
localStorage.setItem(PINS_KEY, JSON.stringify({ unpinned: [ALPHA, BETA, PLUGINS, 'removed-plugin'] }))
@@ -400,3 +413,71 @@ describe('sidebar launcher pins', () => {
expect(layout.selectPanel).not.toHaveBeenCalled()
})
})
describe('sidebar entry click analytics', () => {
const MORE_CLICK = ['sidebar_menu_click', { menu_name: 'more', position: 'sidebar' }]
async function analyticsBench() {
const track = vi.fn<TrackProductEvent>()
const b = await bench(false, track)
await mountPanels(b.runtime, b.locale)
await mountPanel(b.runtime, { id: PLUGINS, heading: 'Plugins content', label: 'Plugins', order: 0 })
const schedules = await mountPanel(b.runtime, { id: SCHEDULES, heading: 'Automation content', label: 'Automation', order: 5 })
const navigation = await b.view.findByRole('navigation', { name: 'Global panels' })
await within(navigation).findByRole('button', { name: 'Automation' })
return { ...b, track, schedules, navigation }
}
it('reports the Plugins and Automation rows and leaves other panel rows unreported', async () => {
const { layout, track, navigation } = await analyticsBench()
for (const name of ['Plugins', 'Automation', 'Alpha panel', 'Beta panel']) {
fireEvent.click(within(navigation).getByRole('button', { name }))
}
expect(track.mock.calls).toEqual([
['sidebar_menu_click', { menu_name: 'plugin', position: 'sidebar' }],
['sidebar_menu_click', { menu_name: 'cron', position: 'sidebar' }],
])
expect(layout.selectPanel.mock.calls).toEqual([[PLUGINS], [SCHEDULES], [ALPHA], [BETA]])
expect(track.mock.invocationCallOrder[0]).toBeLessThan(layout.selectPanel.mock.invocationCallOrder[0]!)
})
it('reports every More button click, whether it opens or closes the menu', async () => {
const { runtime, track, navigation } = await analyticsBench()
await mountProbeActions(runtime)
const more = await within(navigation).findByRole('button', { name: 'More' })
fireEvent.click(more)
expect(more.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(more)
expect(more.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(more)
expect(track.mock.calls).toEqual([MORE_CLICK, MORE_CLICK, MORE_CLICK])
})
it('reports official panels opened from the More menu at the sidebar_more position', async () => {
const { runtime, layout, track, navigation, view } = await analyticsBench()
await mountProbeActions(runtime)
await mountProbeActions(runtime, 'official', OfficialOpen)
fireEvent.click(await within(navigation).findByRole('button', { name: 'More' }))
const menu = within(view.getByRole('menu'))
fireEvent.click(menu.getByRole('menuitem', { name: 'Open Automation' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Open Alpha panel' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Open Plugins by id' }))
expect(track.mock.calls).toEqual([
MORE_CLICK,
['sidebar_menu_click', { menu_name: 'cron', position: 'sidebar_more' }],
['sidebar_menu_click', { menu_name: 'plugin', position: 'sidebar_more' }],
])
expect(layout.selectPanel.mock.calls).toEqual([[SCHEDULES], [ALPHA], [PLUGINS]])
})
it('reports an official menu request whose panel unregistered without selecting it', async () => {
const { runtime, layout, track, schedules, navigation, view } = await analyticsBench()
await mountProbeActions(runtime, 'official', OfficialOpen)
fireEvent.click(await within(navigation).findByRole('button', { name: 'More' }))
await schedules.dispose()
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Automation' })).toBeNull() })
fireEvent.click(view.getByRole('menuitem', { name: 'Open Automation by id' }))
expect(track).toHaveBeenLastCalledWith('sidebar_menu_click', { menu_name: 'cron', position: 'sidebar_more' })
expect(layout.selectPanel).not.toHaveBeenCalled()
})
})
@@ -47,7 +47,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t}
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
<div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}
/>,
@@ -43,6 +43,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: {
} = {}) {
const startSession = vi.fn()
const toggleSidebar = vi.fn()
const trackEntryClick = vi.fn<SidebarRootComponentProps['trackEntryClick']>()
let regionOwner: SidebarSectionOwnerProps | undefined
let settingsOwner: SidebarSettingsOwnerProps | undefined
let footerActionOwner: SidebarFooterActionOwnerProps | undefined
@@ -58,7 +59,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: {
useShortcuts={selector => selector(shortcuts)}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={trackEntryClick} t={t}
renderSlot={((
key: string,
owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
@@ -83,6 +84,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: {
return {
startSession,
toggleSidebar,
trackEntryClick,
regionOwner: () => {
if (regionOwner === undefined) throw new Error('region owner not rendered')
return regionOwner
@@ -116,7 +118,7 @@ describe('SidebarRoot shell', () => {
expect(screen.queryByRole('tooltip')).toBeNull()
expect(Array.from(expanded.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'N'])
cleanup()
render(<HeaderLeadingControls toggleSidebar={vi.fn()} startSession={vi.fn()} selectPanel={vi.fn()} t={t}
render(<HeaderLeadingControls toggleSidebar={vi.fn()} startSession={vi.fn()} selectPanel={vi.fn()} trackEntryClick={vi.fn()} t={t}
usePanelInfo={neverHook} useSessions={neverHook} useSessionStatus={neverHook}
useSessionRetainInfo={neverHook} useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
@@ -133,13 +135,26 @@ describe('SidebarRoot shell', () => {
const b = mountShell()
expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
expect(screen.getByTestId('custom-brand-name')).toBeTruthy()
// Expanded, both the wordmark and the capsule start a session.
// Expanded, both the wordmark and the capsule start a session and report a sidebar New Session click first.
const starters = screen.getAllByRole('button', { name: 'New session' })
expect(starters).toHaveLength(2)
for (const button of starters) fireEvent.click(button)
expect(b.startSession).toHaveBeenCalledTimes(2)
expect(b.trackEntryClick.mock.calls).toEqual([
[{ menu_name: 'new_session', position: 'sidebar' }],
[{ menu_name: 'new_session', position: 'sidebar' }],
])
expect(b.trackEntryClick.mock.invocationCallOrder[0]).toBeLessThan(b.startSession.mock.invocationCallOrder[0]!)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
expect(b.trackEntryClick).toHaveBeenCalledTimes(2)
})
it('reports the collapsed rail New Session as a sidebar click outside the Windows caption', () => {
const b = mountShell({ collapsed: true, width: 56 })
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledOnce()
expect(b.trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'sidebar' })
})
it('renders generic brand fallbacks when no package fills the slots', () => {
@@ -154,7 +169,7 @@ describe('SidebarRoot shell', () => {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t}
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
/>)
@@ -177,7 +192,7 @@ describe('SidebarRoot shell', () => {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t}
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
/>)
@@ -196,6 +211,7 @@ describe('SidebarRoot shell', () => {
.mockResolvedValueOnce(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const startSession = vi.fn()
const trackEntryClick = vi.fn<SidebarRootComponentProps['trackEntryClick']>()
render(<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
@@ -204,7 +220,7 @@ describe('SidebarRoot shell', () => {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={startSession} toggleSidebar={vi.fn()} t={t}
startSession={startSession} toggleSidebar={vi.fn()} trackEntryClick={trackEntryClick} t={t}
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
/>)
@@ -234,11 +250,14 @@ describe('SidebarRoot shell', () => {
expect(writeText).toHaveBeenCalledTimes(3)
expect(copiedToasts()).toEqual(['Version copied'])
expect(startSession).not.toHaveBeenCalled()
// Copying the version is not a New Session click.
expect(trackEntryClick).not.toHaveBeenCalled()
// The rest of the brand button still starts a session.
// The rest of the brand button still starts and reports a session.
if (!darwin) {
fireEvent.click(screen.getAllByRole('button', { name: 'New session' })[0]!)
expect(startSession).toHaveBeenCalledOnce()
expect(trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'sidebar' })
}
})
@@ -251,7 +270,7 @@ describe('SidebarRoot shell', () => {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t}
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
/>)
@@ -301,7 +320,7 @@ describe('SidebarRoot shell', () => {
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t}
renderSlot={((key: string) => key === 'sidebar.toggle.badge'
? <Tooltip label="Update — V1.2.3"><span data-testid="badge" /></Tooltip>
: null) as SidebarRootComponentProps['renderSlot']}
@@ -332,18 +351,50 @@ it('keeps the macOS sidebar toggle in its top strip', () => {
// New Session capsule starts a session.
expect(screen.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(shell.trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'sidebar' })
})
it('wires the shell.leading controls to the shared sidebar actions', () => {
const toggleSidebar = vi.fn()
const startSession = vi.fn()
// This occupant only consumes its two actions and locale, not Session hooks.
const props = { toggleSidebar, startSession, t, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
const trackEntryClick = vi.fn<HeaderLeadingControlsProps['trackEntryClick']>()
// This occupant only consumes its actions and locale, not Session hooks.
const props = { toggleSidebar, startSession, trackEntryClick, t, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
render(<HeaderLeadingControls {...props} />)
fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] }))
fireEvent.click(screen.getByRole('button', { name: en['session.new.label'] }))
expect(toggleSidebar).toHaveBeenCalledOnce()
expect(trackEntryClick).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: en['session.new.label'] }))
expect(startSession).toHaveBeenCalledOnce()
// The window-chrome New Session control reports a titlebar click before starting the Session.
expect(trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'titlebar' })
expect(trackEntryClick.mock.invocationCallOrder[0]).toBeLessThan(startSession.mock.invocationCallOrder[0]!)
})
describe('Windows caption New Session analytics', () => {
afterEach(() => { document.documentElement.removeAttribute('data-windows-titlebar') })
it('reports the collapsed caption New Session as a titlebar click', () => {
document.documentElement.setAttribute('data-windows-titlebar', '')
const b = mountShell({ collapsed: true, width: 0 })
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledOnce()
expect(b.trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'titlebar' })
})
it('reports the expanded brand and New Session button as sidebar clicks', () => {
document.documentElement.setAttribute('data-windows-titlebar', '')
const b = mountShell()
const starters = screen.getAllByRole('button', { name: 'New session' })
expect(starters).toHaveLength(2)
for (const button of starters) fireEvent.click(button)
expect(b.startSession).toHaveBeenCalledTimes(2)
expect(b.trackEntryClick.mock.calls).toEqual([
[{ menu_name: 'new_session', position: 'sidebar' }],
[{ menu_name: 'new_session', position: 'sidebar' }],
])
})
})
describe('Windows caption tooltips', () => {
@@ -3567,7 +3567,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:25',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:26',
},
{
key: 'sidebar.brand.name',
@@ -3600,7 +3600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:30',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:31',
},
{
key: 'sidebar.chat.conversation',
@@ -3692,7 +3692,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:55',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:56',
},
{
key: 'sidebar.panellist',
@@ -3745,7 +3745,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:36',
},
{
key: 'sidebar.panellist.actions',
@@ -3799,7 +3799,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist.actions\', () => ctx.slots.register(\n { name: \'sidebar.panellist.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:37',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:38',
},
{
key: 'sidebar.right.pane.tab',
@@ -4647,7 +4647,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:50',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:51',
},
{
key: 'sidebar.toggle.badge',
@@ -4678,7 +4678,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.toggle.badge\', () => ctx.slots.register(\n { name: \'sidebar.toggle.badge\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:19',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:20',
},
{
key: 'sidebar.workspaces',
@@ -4711,7 +4711,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:44',
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:45',
},
{
key: 'sidebar.workspaces.directoryFlow',
@@ -4925,6 +4925,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\';',
},
{
name: 'AnalyticsPreset',
declaration: 'export type AnalyticsPreset = \'creator\' | \'ptc\' | \'minimal\' | \'standard\';',
},
{
name: 'AnonymousTranslationProvider',
declaration: 'export type AnonymousTranslationProvider = \'google\' | \'bing\';',
@@ -6735,7 +6739,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProductEventMap',
declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record<string, never>;\n auth_page_view: Record<string, never>;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record<string, never>;\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<string, never>;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record<string, never>;\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 */',
declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record<string, never>;\n auth_page_view: Record<string, never>;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record<string, never>;\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<string, never>;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record<string, never>;\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 is_new_session: boolean;\n agent_preset?: AnalyticsPreset | \'other\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n /* …truncated — full shape in source */',
},
{
name: 'ProductTelemetryRecord',
+9
View File
@@ -3530,6 +3530,9 @@ importers:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:*
version: link:../locale
'@deepseek-ai/dsh-client-product-analytics':
specifier: workspace:*
version: link:../product-analytics
'@deepseek-ai/dsh-client-store':
specifier: workspace:*
version: link:../store
@@ -4235,6 +4238,9 @@ importers:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:*
version: link:../locale
'@deepseek-ai/dsh-client-product-analytics':
specifier: workspace:*
version: link:../product-analytics
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:*
version: link:../../test-support/client-runtime
@@ -5364,6 +5370,9 @@ importers:
'@deepseek-ai/dsh-client-modules':
specifier: workspace:*
version: link:../modules
'@deepseek-ai/dsh-client-product-analytics':
specifier: workspace:*
version: link:../product-analytics
'@deepseek-ai/dsh-client-shortcuts':
specifier: workspace:*
version: link:../shortcuts