From bcf0ba45cb06a3a1700c006b70add403f729e440 Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 16:16:17 +0800 Subject: [PATCH 001/102] fix(client): clarify update guidance and unnamed session labels --- apps/desktop/README.i18n.yaml | 8 +- apps/desktop/README.md | 4 + apps/desktop/README.zh.md | 4 + apps/desktop/renderer/mandatory-update.css | 6 +- apps/desktop/src/locale.ts | 132 +++++++++--------- apps/desktop/src/main.ts | 6 +- apps/desktop/src/mandatory-update-window.ts | 6 +- .../tests/expected/mandatory-update-zh.json | 4 +- .../tests/expected/policy-login-en.json | 14 +- .../tests/expected/update-guidance-en.json | 49 +++++++ .../tests/expected/update-guidance-zh-CN.json | 49 +++++++ .../expected/update-restart-darwin-en-US.json | 4 +- .../expected/update-restart-darwin-zh-CN.json | 4 +- .../expected/update-restart-win32-en-US.json | 2 +- .../expected/update-restart-win32-zh-CN.json | 2 +- .../tests/fixtures/workspace-updates.mjs | 2 +- apps/desktop/tests/main-startup.spec.ts | 10 +- .../tests/update-error-renderer.spec.ts | 38 ++++- .../ui-settings-general/README.i18n.yaml | 6 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 4 + .../src/client/DesktopUpdateIndicator.tsx | 3 + .../ui-settings-general/src/client/locales.ts | 28 ++-- .../tests/desktop-update.client.spec.tsx | 12 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../ui-workspace/src/client/contract/slots.ts | 2 +- .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/rows/Rows.tsx | 4 +- .../client/ui-workspace/src/client/tree.ts | 2 +- .../ui-workspace/tests/rows.client.spec.tsx | 8 ++ .../ui-workspace/tests/tree.client.spec.ts | 26 ++-- 33 files changed, 321 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/tests/expected/update-guidance-en.json create mode 100644 apps/desktop/tests/expected/update-guidance-zh-CN.json diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1152fefd53..bc17446cae 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -12,8 +12,8 @@ en: 31cbb7452be8e1ad zh: 502ec556e2ec301e /deepseek-harness-desktop/installation-ownership: - en: 8307baf6c5b691d0 - zh: 86e46b9552017ff6 + en: d186d3cfc9597688 + zh: 5f7094355c5f562c /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: 0d11af8639977a6d zh: 86b086b12d2210d3 @@ -63,5 +63,5 @@ en: 5b77213c86bbcfb6 zh: b34bf21773bb852b /deepseek-harness-desktop/dev-note: - en: 8e26b460ba31f21b - zh: fbcd0307b3867125 + en: 2b5d500798edeb32 + zh: 901a0e32ba2ff8be diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a4ee56e847..838f7be46a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -72,6 +72,8 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. +Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version. + Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. @@ -443,3 +445,5 @@ The account provider’s `embeddedPageDist` configuration adds a `dist` query pa ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). + +The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 108e7790e7..0dff7fea11 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -74,6 +74,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 +升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。 + Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 @@ -445,3 +447,5 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 + +强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。 diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css index 89cf0cf0d5..591a82b6ab 100644 --- a/apps/desktop/renderer/mandatory-update.css +++ b/apps/desktop/renderer/mandatory-update.css @@ -9,4 +9,8 @@ #copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; } #copy-message { color: #d93025 !important; } #manual-copy { display: block; margin-top: 8px; } -#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; } +#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; } + +#progress::-webkit-progress-bar { background: #e6e7eb; } +#progress::-webkit-progress-value { background: #0f1115; } +#progress::-moz-progress-bar { background: #0f1115; } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 7c589df70b..f64e8e7ab1 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -68,17 +68,18 @@ export const en = { updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', updateInstallFailed: 'Could not install the update. Please try again later.', - updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.', - updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.', - updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.', + updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.', + updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.', + updateInstallNetworkFailed: 'Could not install the update. Check your connection and try again.', unknownError: 'Unknown error', updateCheckTitle: 'Check for Updates', - updateCurrent: 'No updates available. Current version: V{version}', + updateCurrentDetail: 'Current version: {version}', + updateCurrent: 'You’re up to date!', updateChecking: 'Checking for updates…', updateDownload: 'Download update', - updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded', - updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.', - updateDownloadedTitleWindows: 'New version v{version} is ready', + updateDownloadedTitle: 'Version {version} is ready to install', + updateDownloadedDetail: 'The app will close during the update and reopen automatically when it is complete.', + updateDownloadedTitleWindows: 'Version {version} is ready to install', updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.', updateClose: 'Close', updateAcknowledge: 'OK', @@ -88,47 +89,48 @@ export const en = { updateInstalling: 'Preparing to restart…', updateRetry: 'Retry update', updateActiveTasks: 'Tasks are still in progress', - updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?', + updateActiveTasksDetail: 'Updating will stop the tasks in progress and restart the app. Continue?', updateStopTasks: 'Stop tasks and update', - updateTasksChanged: 'New tasks started. Review the update confirmation again.', + updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.', updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.', - updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.', + updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.', updateTechnicalDetails: 'View technical details', updateTitle: 'DeepSeek Harness Update', - updateAvailable: 'An update is available', - updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.', + updateAvailable: 'New version available: {version}', + updateDetail: 'Once the download is complete, you can install the update and restart the app.', installAndRestart: 'Install and Restart', later: 'Later', updateFailedTitle: 'Update Failed', - mandatoryTitle: 'Update required', - mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.', - mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.', + mandatoryTitle: 'Update to continue', + mandatoryDetail: 'This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.', + mandatoryUnavailable: 'Could not check update requirements. Please try again later.', policyLoginTitle: 'Sign in to the test environment', - policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.', + policyLoginRequired: 'Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.', policyLogin: 'Sign in with Feishu', - policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.', + policyLoginFailed: 'Feishu sign-in did not complete. Please try again.', policyLoginLoading: 'Loading sign-in page…', - mandatoryNoRelease: 'No applicable update is available. Check again or contact support.', + mandatoryNoRelease: 'No compatible update was found. Check again or contact support.', mandatoryRefresh: 'Check again', - mandatoryPage: 'Open download page', - mandatoryCopy: 'Copy download address', - mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.', - mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.', + mandatoryPage: 'Download from the official website', + mandatoryCopy: 'Copy download link', + mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.', + mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.', mandatoryReady: 'Update ready', - mandatoryVersion: 'V{version}', - mandatoryReadyDetail: 'Installing the update will restart the application.', - mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.', - mandatoryContinue: 'Continue installing update', + mandatoryVersion: 'New version: {version}', + mandatoryReadyDetail: 'The app will close during the update and reopen automatically when it is complete.', + mandatoryReadyDetailWindows: 'The app will close during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again until it finishes.', + mandatoryDeferred: 'Tasks in progress will keep running. Complete the update before using the app again.', + mandatoryContinue: 'Continue update', mandatoryInspecting: 'Checking tasks…', - mandatoryStopping: 'Safely stopping tasks in the application.', - mandatoryRestarting: 'The application will restart shortly. Please wait.', + mandatoryStopping: 'Stopping tasks…', + mandatoryRestarting: 'The app will restart shortly. Please wait.', mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.', - mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.', - mandatoryOpenHelp: 'If the page did not open, you can', - mandatoryReopen: 'Open download page again', + mandatoryInstallFailed: 'The update has not been installed. Check the tasks again and retry.', + mandatoryOpenHelp: 'Page didn’t open?', + mandatoryReopen: 'Open the official download page again', mandatoryCopied: 'Link copied', - mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.', - mandatoryAddress: 'Download address', + mandatoryCopyFailed: 'Could not copy the link. Select and copy it below.', + mandatoryAddress: 'Download link', mandatoryNotification: 'Return to the application to confirm installation and restart.', } as const @@ -203,17 +205,18 @@ export const zh = { updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', updateInstallFailed: '安装更新失败,请稍后重试。', - updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。', - updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。', - updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。', + updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。', + updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。', + updateInstallNetworkFailed: '安装更新失败,请检查网络连接后重试。', unknownError: '未知错误', updateCheckTitle: '检查更新', - updateCurrent: '当前暂无可用更新。当前版本:V{version}', + updateCurrentDetail: '当前版本:{version}', + updateCurrent: '已是最新版本', updateChecking: '正在检查更新…', updateDownload: '下载更新', - updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成', - updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。', - updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪', + updateDownloadedTitle: '新版本 {version} 已准备就绪', + updateDownloadedDetail: '更新期间应用将暂时关闭,完成后会自动打开。', + updateDownloadedTitleWindows: '新版本 {version} 已准备就绪', updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', updateClose: '关闭', updateAcknowledge: '确定', @@ -223,47 +226,48 @@ export const zh = { updateInstalling: '正在准备重启…', updateRetry: '重试更新', updateActiveTasks: '仍有进行中的任务', - updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?', + updateActiveTasksDetail: '更新将停止进行中的任务并重启应用,是否继续?', updateStopTasks: '停止任务并更新', - updateTasksChanged: '有新任务开始,请重新确认更新。', + updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。', updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。', - updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。', + updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。', updateTechnicalDetails: '查看技术详情', updateTitle: 'DeepSeek Harness 更新', - updateAvailable: '发现可用更新', - updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。', + updateAvailable: '发现新版本 {version}', + updateDetail: '下载完成后,可安装并重启应用。', installAndRestart: '安装并重启', later: '稍后', updateFailedTitle: '更新失败', - mandatoryTitle: '需要更新', - mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。', - mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。', + mandatoryTitle: '请更新后继续使用', + mandatoryDetail: '当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。', + mandatoryUnavailable: '暂时无法检查更新要求,请稍后重试。', policyLoginTitle: '登录测试环境', - policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。', + policyLoginRequired: '此测试版需要先通过飞书登录,才能检查更新要求。登录不会下载或安装更新。', policyLogin: '通过飞书登录', - policyLoginFailed: '测试环境登录未完成,请检查网络后重试。', + policyLoginFailed: '飞书登录未完成,请重试。', policyLoginLoading: '正在加载登录页面…', - mandatoryNoRelease: '暂时没有可用的更新,请重新检查或联系支持人员。', + mandatoryNoRelease: '暂未找到适用的更新,请重新检查或联系支持人员。', mandatoryRefresh: '重新检查', mandatoryPage: '前往官网下载', mandatoryCopy: '复制下载链接', - mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。', - mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。', + mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。', + mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。', mandatoryReady: '更新已准备就绪', - mandatoryVersion: 'V{version}', - mandatoryReadyDetail: '安装后将重新启动应用。', - mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。', - mandatoryContinue: '继续安装更新', + mandatoryVersion: '新版本:{version}', + mandatoryReadyDetail: '更新期间应用将暂时关闭,完成后会自动打开。', + mandatoryReadyDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', + mandatoryDeferred: '进行中的任务会继续运行。请完成更新后再操作应用。', + mandatoryContinue: '继续更新', mandatoryInspecting: '正在检查任务状态…', - mandatoryStopping: '正在安全结束应用中的任务。', + mandatoryStopping: '正在停止任务…', mandatoryRestarting: '应用即将重启,请稍候。', mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。', mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。', - mandatoryOpenHelp: '若页面未打开,可', - mandatoryReopen: '重新打开官网', - mandatoryCopied: '已复制链接', - mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。', - mandatoryAddress: '下载地址', + mandatoryOpenHelp: '页面未打开?', + mandatoryReopen: '重新前往官网下载', + mandatoryCopied: '链接已复制', + mandatoryCopyFailed: '复制失败,请手动选择并复制下方链接。', + mandatoryAddress: '下载链接', mandatoryNotification: '返回应用确认安装并重启。', } as const satisfies DesktopMessages @@ -307,7 +311,7 @@ export function formatDesktopMessage( /** * Select localized copy for an ordinary downloaded-update confirmation. * @param messages - Selected Desktop dictionary. - * @param version - Prepared update version without a leading v. + * @param version - Prepared update version, including any prerelease suffix. * @param platform - Operating system presenting the confirmation. * @returns The versioned title and installation guidance. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b43146683..345d55e5e5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -745,7 +745,8 @@ async function main(): Promise { if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } if (state.phase === 'idle') { await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle, - message: formatDesktopMessage(locale.messages.updateCurrent, { version: app.getVersion() }) }) + message: locale.messages.updateCurrent, + detail: formatDesktopMessage(locale.messages.updateCurrentDetail, { version: app.getVersion() }) }) return } if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { @@ -757,7 +758,8 @@ async function main(): Promise { } if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return if (manual) { - const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: locale.messages.updateAvailable, + const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, + message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }), detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }), buttons: [locale.messages.updateDownload], cancelId: 1 }) if (result.response !== 0) return diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 724df4ff97..cbaee74be7 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -243,7 +243,11 @@ export class DesktopMandatoryUpdateWindow { } private view(): MandatoryUpdateView { - return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(), + const locale = process.platform === 'win32' + ? { ...this.options.locale, messages: { ...this.options.locale.messages, + mandatoryReadyDetail: this.options.locale.messages.mandatoryReadyDetailWindows } } + : this.options.locale + return { locale, policy: this.options.policy(), update: this.options.update(), deferred: this.deferred, ...(this.confirmation === undefined ? {} : { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }), diff --git a/apps/desktop/tests/expected/mandatory-update-zh.json b/apps/desktop/tests/expected/mandatory-update-zh.json index fbe0356bcc..13a960e51e 100644 --- a/apps/desktop/tests/expected/mandatory-update-zh.json +++ b/apps/desktop/tests/expected/mandatory-update-zh.json @@ -1,8 +1,8 @@ { "title": "仍有进行中的任务", - "detail": "重启更新可能中断这些任务,是否要继续更新?", + "detail": "更新将停止进行中的任务并重启应用,是否继续?", "status": "", - "version": "V1.0.1-nightly.1", + "version": "新版本:1.0.1-nightly.1", "buttons": [ "停止任务并更新", "稍后更新" diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json index 3415d20680..822c1a0f30 100644 --- a/apps/desktop/tests/expected/policy-login-en.json +++ b/apps/desktop/tests/expected/policy-login-en.json @@ -1,18 +1,18 @@ { "returned": [ "Checking for updates…", - "No updates available. Current version: V1.0.0", - "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update." + "You’re up to date!", + "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update." ], "cancelled": [ "Checking for updates…", - "No updates available. Current version: V1.0.0", - "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update." + "You’re up to date!", + "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update." ], "failed": [ "Checking for updates…", - "No updates available. Current version: V1.0.0", - "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.", - "Test environment sign-in did not complete. Please check your connection and try again." + "You’re up to date!", + "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.", + "Feishu sign-in did not complete. Please try again." ] } diff --git a/apps/desktop/tests/expected/update-guidance-en.json b/apps/desktop/tests/expected/update-guidance-en.json new file mode 100644 index 0000000000..13becc60a1 --- /dev/null +++ b/apps/desktop/tests/expected/update-guidance-en.json @@ -0,0 +1,49 @@ +[ + { + "title": "Update to continue", + "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.", + "version": "New version: 0.1.7-alpha.2", + "update": "Download update" + }, + { + "title": "Update ready", + "detail": "The app will close during the update and reopen automatically when it is complete.", + "version": "New version: 0.1.7-alpha.2", + "update": "Install and Restart" + }, + { + "title": "Tasks are still in progress", + "detail": "Updating will stop the tasks in progress and restart the app. Continue?", + "version": "New version: 0.1.7-alpha.2", + "update": "Stop tasks and update", + "later": "Update later" + }, + { + "title": "Update ready", + "detail": "Tasks in progress will keep running. Complete the update before using the app again.", + "version": "New version: 0.1.7-alpha.2", + "update": "Continue update" + }, + { + "title": "Preparing to restart…", + "detail": "Stopping tasks…", + "version": "New version: 0.1.7-alpha.2" + }, + { + "title": "Update to continue", + "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.", + "version": "New version: 0.1.7-alpha.2", + "error": "The update has not been installed. Check the tasks again and retry.", + "update": "Retry update", + "page": "Download from the official website" + }, + { + "title": "Update to continue", + "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.", + "version": "", + "error": "No compatible update was found. Check again or contact support.", + "page": "Open the official download page again", + "browser-message": "Could not open the official download page. Copy the link and open it in your browser.", + "copy-message": "Could not copy the link. Select and copy it below." + } +] diff --git a/apps/desktop/tests/expected/update-guidance-zh-CN.json b/apps/desktop/tests/expected/update-guidance-zh-CN.json new file mode 100644 index 0000000000..8ff226d70e --- /dev/null +++ b/apps/desktop/tests/expected/update-guidance-zh-CN.json @@ -0,0 +1,49 @@ +[ + { + "title": "请更新后继续使用", + "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。", + "version": "新版本:0.1.7-alpha.2", + "update": "下载更新" + }, + { + "title": "更新已准备就绪", + "detail": "更新期间应用将暂时关闭,完成后会自动打开。", + "version": "新版本:0.1.7-alpha.2", + "update": "安装并重启" + }, + { + "title": "仍有进行中的任务", + "detail": "更新将停止进行中的任务并重启应用,是否继续?", + "version": "新版本:0.1.7-alpha.2", + "update": "停止任务并更新", + "later": "稍后更新" + }, + { + "title": "更新已准备就绪", + "detail": "进行中的任务会继续运行。请完成更新后再操作应用。", + "version": "新版本:0.1.7-alpha.2", + "update": "继续更新" + }, + { + "title": "正在准备重启…", + "detail": "正在停止任务…", + "version": "新版本:0.1.7-alpha.2" + }, + { + "title": "请更新后继续使用", + "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。", + "version": "新版本:0.1.7-alpha.2", + "error": "更新尚未安装,请重新检查任务后重试。", + "update": "重试更新", + "page": "前往官网下载" + }, + { + "title": "请更新后继续使用", + "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。", + "version": "", + "error": "暂未找到适用的更新,请重新检查或联系支持人员。", + "page": "重新前往官网下载", + "browser-message": "无法打开官网下载页面,请复制链接后在浏览器中打开。", + "copy-message": "复制失败,请手动选择并复制下方链接。" + } +] diff --git a/apps/desktop/tests/expected/update-restart-darwin-en-US.json b/apps/desktop/tests/expected/update-restart-darwin-en-US.json index 599f39fa68..744475cb22 100644 --- a/apps/desktop/tests/expected/update-restart-darwin-en-US.json +++ b/apps/desktop/tests/expected/update-restart-darwin-en-US.json @@ -1,6 +1,6 @@ { - "message": "DeepSeek Harness v0.1.99 downloaded", - "detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.", + "message": "Version 0.1.7-alpha.2 is ready to install", + "detail": "The app will close during the update and reopen automatically when it is complete.", "buttons": [ "Install and Restart" ] diff --git a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json index d7ea2daa57..0bc196f79a 100644 --- a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json +++ b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json @@ -1,6 +1,6 @@ { - "message": "DeepSeek Harness v0.1.99 下载完成", - "detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。", + "message": "新版本 0.1.7-alpha.2 已准备就绪", + "detail": "更新期间应用将暂时关闭,完成后会自动打开。", "buttons": [ "安装并重启" ] diff --git a/apps/desktop/tests/expected/update-restart-win32-en-US.json b/apps/desktop/tests/expected/update-restart-win32-en-US.json index 0e8af77ca2..71a7f43253 100644 --- a/apps/desktop/tests/expected/update-restart-win32-en-US.json +++ b/apps/desktop/tests/expected/update-restart-win32-en-US.json @@ -1,5 +1,5 @@ { - "message": "New version v0.1.99 is ready", + "message": "Version 0.1.7-alpha.2 is ready to install", "detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.", "buttons": [ "Install and Restart" diff --git a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json index 104f40b266..520dda5711 100644 --- a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json +++ b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json @@ -1,5 +1,5 @@ { - "message": "新版本 v0.1.99 已准备就绪", + "message": "新版本 0.1.7-alpha.2 已准备就绪", "detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。", "buttons": [ "安装并重启" diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index b136c79ce0..3c5014bf27 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -227,7 +227,7 @@ async function qualify() { server.select('healthy', '0.1.6-nightly.1') checkMenu.click() - const available = await dialogWith(messages.updateAvailable) + const available = await dialogWith(messages.updateAvailable.replace('{version}', '0.1.6-nightly.1')) server.select('corrupt', '0.1.6-nightly.1') await clickText(available, messages.updateDownload) await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index ea1e6c52b8..92f047a72a 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -516,7 +516,7 @@ describe('desktop main startup', () => { expect(testAuth.login).toHaveBeenCalledOnce() const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1) - expect(messages).toContain('No updates available. Current version: V1.0.0') + expect(messages).toContain('You’re up to date!') } finally { explanation.resolve({ response: 1 }) login.resolve('cancelled') @@ -1035,7 +1035,7 @@ describe('desktop main startup', () => { vi.stubGlobal('process', { ...process, platform }) vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language]) await readyForUpdate() - harness.updateState = { phase: 'ready', version: '0.1.99' } + harness.updateState = { phase: 'ready', version: '0.1.7-alpha.2' } harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) await expect(harness.prepareUpdate()).resolves.toBe(false) const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions @@ -1207,7 +1207,7 @@ describe('desktop main startup', () => { await prompt expect(signal.aborted).toBe(true) expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ - message: 'No updates available. Current version: V1.0.0', + message: 'You’re up to date!', detail: 'Current version: 1.0.0', })) }) @@ -1272,7 +1272,7 @@ describe('desktop main startup', () => { vi.stubGlobal('fetch', vi.fn().mockImplementation(() => policy.promise)) harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' }) harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => { - if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 }) + if (message !== en.updateAvailable.replace('{version}', '1.0.1-nightly.1') || signal === undefined) return Promise.resolve({ response: 1 }) available.resolve(signal) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) }) }) @@ -1309,7 +1309,7 @@ describe('desktop main startup', () => { const ordinaryShown = Promise.withResolvers() const policyShown = Promise.withResolvers() harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => { - if (message.startsWith('No updates available.')) { + if (message === en.updateCurrent) { ordinaryShown.resolve(undefined) return ordinaryResult.promise } diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index ad92f2df8f..54bc5d5e2e 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -95,7 +95,7 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori expect([p.element('error').textContent, p.element('technical-details-label').textContent, p.element('update').textContent]).toMatchInlineSnapshot(` [ - "未能安全停止任务,更新未安装。请稍后重试。", + "未能安全停止任务,更新尚未安装,请稍后重试。", "查看技术详情", "重试更新", ] @@ -182,7 +182,7 @@ it('uses the same modal for download, verification, inspected confirmation and t p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'preparing' }) expect(p.element('detail').textContent).toBe('应用即将重启,请稍候。') p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'stopping-tasks' }) - expect(p.element('detail').textContent).toBe('正在安全结束应用中的任务。') + expect(p.element('detail').textContent).toBe('正在停止任务…') expect(p.document.querySelector('main')).toBe(modal) }) @@ -191,7 +191,7 @@ it('reveals the complete selectable address only after copy failure, without rep await expect.poll(() => p.element('error').textContent).toBe('更新文件下载或准备失败,请重试。') const originalError = p.element('error').textContent p.publish({ ...p.initial, navigation: { page: 'requested' } }) - expect(p.element('browser-message').textContent).toBe('若页面未打开,可') + expect(p.element('browser-message').textContent).toBe('页面未打开?') expect(p.element('manual-copy').hidden).toBe(true) p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'failed' } }) expect(p.element('manual-copy').hidden).toBe(false) @@ -199,11 +199,11 @@ it('reveals the complete selectable address only after copy failure, without rep expect((p.element('address') as HTMLTextAreaElement).readOnly).toBe(true) expect(p.element('error').textContent).toBe(originalError) expect(p.element('technical-details-content').textContent).toBe('HASH_MISMATCH') - expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择下方地址复制。') + expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择并复制下方链接。') p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'copied' } }) expect(p.element('manual-copy').hidden).toBe(true) expect((p.element('address') as HTMLTextAreaElement).value).toBe('') - expect(p.element('copy').textContent).toBe('已复制链接') + expect(p.element('copy').textContent).toBe('链接已复制') expect(p.action).not.toHaveBeenCalled() }) @@ -215,3 +215,31 @@ it('renders server markup literally in a dedicated safety case', async () => { expect(p.element('title').childElementCount).toBe(0) expect(p.element('detail').childElementCount).toBe(0) }) + +it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across its states: %s', async (language) => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale(language) + const base: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false, + page: 'https://example.invalid/download' }, update: { phase: 'available', version: '0.1.7-alpha.2' } } + let publish!: (view: MandatoryUpdateView) => void + const api: MandatoryUpdateApi = { status: async () => base, action: async () => {}, + subscribe: (listener) => { publish = listener; return () => {} } } + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api }) + p.run() + await expect.poll(() => p.element('title').textContent).toBe(locale.messages.mandatoryTitle) + const states: MandatoryUpdateView[] = [base, + { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: false, version: '0.1.7-alpha.2', revision: 1 } }, + { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: true, version: '0.1.7-alpha.2', revision: 1 } }, + { ...base, update: { ...base.update, phase: 'ready' }, deferred: true }, + { ...base, update: { ...base.update, phase: 'installing' }, restart: 'stopping-tasks' }, + { ...base, update: { ...base.update, phase: 'error', failedOperation: 'install' } }, + { ...base, update: { phase: 'idle' }, navigation: { page: 'failed', copy: 'failed' } }, + ] + const snapshots = states.map((state) => { + publish(state) + return Object.fromEntries(['title', 'detail', 'version', 'error', 'update', 'later', 'page', 'browser-message', 'copy-message'] + .filter(id => !p.element(id).hidden && !p.element(id).closest('[hidden]')) + .map(id => [id, p.element(id).textContent])) + }) + await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`) +}) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 38e28a92f7..cdc7a6e66d 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -9,8 +9,8 @@ en: 1017c980d55e1945 zh: d3febd2062fce20f /deepseek-ai-dsh-client-ui-settings-general/summary: - en: 56ad6c35c1c4745c - zh: 1911baf2e1ebddab + en: 1051439c9946de0e + zh: baf773d0e7024b2f /deepseek-ai-dsh-client-ui-settings-general/table-of-contents: en: aeb9c4bfdb19bc56 zh: 45a29bea90cff239 @@ -55,4 +55,4 @@ zh: e9f1bb38af42de90 /deepseek-ai-dsh-client-ui-settings-general/known-limitations-and-deferred-work/dev-note: en: b36039e49a66b8e0 - zh: 306682cd90cce1db + zh: d99c26de8d49063f diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 6cf283dc7b..ccd82f7918 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -11,6 +11,8 @@ English | [中文](README.zh.md) Use this package to give the dsh web client a Settings panel, connection-recovery control, feature-contributed navigation, and sequential first-run onboarding. Users can open it from the sidebar, retry a failed connection immediately, and access a local configuration file when the Host makes one available on a loopback browser. Feature packages supply their own settings rows, sections, and onboarding steps; this package supplies their shared presentation and the Developer tools switch without adding onboarding copy. +Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 40fee771e3..4bdeaef63f 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -11,6 +11,8 @@ kind: "package-reference" 使用本包可为 dsh Web 客户端提供 Settings 面板、连接恢复控件、由功能包贡献的导航,以及依次进行的首次运行引导。用户可以从侧边栏打开面板、立即重试失败的连接,并在宿主为回环浏览器提供本地配置文件时访问该文件。各功能包提供自己的设置行、分区和引导步骤;本包提供共享的界面展示和开发者工具开关,但不添加引导文案。 +桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 + ## 目录 - [使用本包](#use-this-package) @@ -126,3 +128,5 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 **运行时不变式:** 不发布伴生入口。settings seam 校验并发布持久 onboarding section,slot core 会拒绝冲突;本地 document action 由 store 与组件测试覆盖。 + +检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。 diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 15c671155b..588b3befdb 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -23,6 +23,9 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l error: t('desktop.update.retry'), } const label = labels[state.phase] + if (state.phase === 'checking' || state.phase === 'verifying' || state.phase === 'installing') { + return { label, detail: state.version ?? label } + } if (state.phase === 'error') { const failures: Readonly> = { check: t('desktop.update.checkFailed'), diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index f0ca259c7f..ae5939ddbd 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -12,16 +12,16 @@ export const zh = { 'desktop.update.installing': '正在准备重启…', 'desktop.update.ready': '安装并重启', 'desktop.update.retry': '重试更新', - 'desktop.update.versionDetail': '{label} — V{version}', - 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:V{version}', + 'desktop.update.versionDetail': '{label}:{version}', + 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:{version}', 'desktop.update.checkFailed': '检查更新失败,请稍后重试。', 'desktop.update.downloadFailed': '下载更新失败,请重试。', 'desktop.update.installFailed': '安装更新失败,请稍后重试。', - 'desktop.update.checkNetworkFailed': '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。', - 'desktop.update.downloadNetworkFailed': '下载更新失败,请重试。网络连接异常,请检查网络后重试。', - 'desktop.update.installNetworkFailed': '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。', - 'desktop.update.stopFailed': '未能安全停止任务,更新未安装。请稍后重试。', - 'desktop.update.tasksChanged': '有新任务开始,请重新确认更新。', + 'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。', + 'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。', + 'desktop.update.installNetworkFailed': '安装更新失败,请检查网络连接后重试。', + 'desktop.update.stopFailed': '未能安全停止任务,更新尚未安装,请稍后重试。', + 'desktop.update.tasksChanged': '有新任务开始运行,请重新确认是否停止任务并更新。', 'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。', 'title': '设置', 'close': '关闭', @@ -54,16 +54,16 @@ export const en = { 'desktop.update.installing': 'Preparing to restart…', 'desktop.update.ready': 'Install and Restart', 'desktop.update.retry': 'Retry update', - 'desktop.update.versionDetail': '{label} — V{version}', - 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: V{version}', + 'desktop.update.versionDetail': '{label}: {version}', + 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: {version}', 'desktop.update.checkFailed': 'Could not check for updates. Please try again later.', 'desktop.update.downloadFailed': 'Could not download the update. Please try again.', 'desktop.update.installFailed': 'Could not install the update. Please try again later.', - 'desktop.update.checkNetworkFailed': 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.', - 'desktop.update.downloadNetworkFailed': 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.', - 'desktop.update.installNetworkFailed': 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.', - 'desktop.update.stopFailed': 'Tasks could not be stopped safely. The update was not installed. Please try again later.', - 'desktop.update.tasksChanged': 'New tasks started. Review the update confirmation again.', + 'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.', + 'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.', + 'desktop.update.installNetworkFailed': 'Could not install the update. Check your connection and try again.', + 'desktop.update.stopFailed': 'Could not safely stop the tasks. The update has not been installed. Please try again later.', + 'desktop.update.tasksChanged': 'New tasks have started. Confirm again to stop the tasks and update.', 'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.', 'title': 'Settings', 'close': 'Close', diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx index 7e81c3f649..35970bff6f 100644 --- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -152,3 +152,15 @@ it('shows fallback progress and error details when the shell omits optional fiel expect((await screen.findByRole('tooltip')).textContent).toBe('安装更新失败,请稍后重试。') } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } }) + +it.each(['checking', 'verifying', 'installing'] as const)('shows only the version during %s in both locales', async (phase) => { + const f = fixture() + try { + await f.emit({ phase, version: '0.1.7-alpha.2' }) + fireEvent.focus(screen.getByRole('button')) + expect((await screen.findByRole('tooltip')).textContent).toBe('0.1.7-alpha.2') + f.view.rerender() + fireEvent.focus(screen.getByRole('button')) + expect((await screen.findByRole('tooltip')).textContent).toBe('0.1.7-alpha.2') + } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } +}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index e9a2f19a98..66edb5cf00 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -69,5 +69,5 @@ en: 00d7b1ddda7390f5 zh: 1e5a1055d1be4d1d /deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work/dev-note: - en: 585813ff2c68c20c - zh: 18b43c0d64fa8a72 + en: 77d2291f37844028 + zh: b95797542a367620 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d19423f195..335cd84f99 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -222,3 +222,5 @@ None. **Runtime invariant:** No companion is published. This is a pure-consumer plugin that registers presentational components into two host-declared slots and registers its locale dictionaries; its inject face consists of stateless RPC wrappers plus a create-and-open call. It emits no Cordis events and owns no cross-plugin mutable state. + +History rows without a stored title use the localized unnamed label (未命名 / untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f2bf73d483..130f865434 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -222,3 +222,5 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor **运行时不变式:** 不发布伴生入口。这是一个纯消费方插件,只向两个由宿主声明的 slot 注册展示组件,并注册自身的 locale dictionaries;inject face 由无状态 RPC 包装层和一次 create-and-open 调用组成;本插件不发出 Cordis 事件,也不持有跨插件可变状态。 + +未保存标题的历史会话显示本地化名称「未命名 / untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。 diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 830cc3570a..e805ffa076 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -76,7 +76,7 @@ export interface DirectoryFlowOwnerProps { export interface SessionRowOwnerProps { /** Session the row shows. */ sessionId: SessionId - /** Row display title: persisted title, project basename, or Session id. */ + /** Row display title: persisted title or the localized unnamed label. */ displayTitle: string } diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 08d49b9660..a8462fa282 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'defaultWorkspace.failed': '无法创建默认工作区,请通过“选择工作区”选择文件夹', 'group.ungrouped': '未分组', 'session.new': '新会话', + 'session.untitled': '未命名', 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': '请先选择一个会话', 'shortcut.noPicker': '目录选择器不可用', @@ -128,6 +129,7 @@ export const en = { 'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', + 'session.untitled': 'untitled', 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': 'Select a session first', 'shortcut.noPicker': 'Directory picker unavailable', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index bdf201e7e9..8a3ff43230 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -46,7 +46,7 @@ type RowRenderSlots = PropsRenderSlots< /** Row display title: blank rows show the localized New Session label. */ function displayTitle(node: SessionNode, t: RowTranslate): string { - return node.blank ? t('session.new') : node.title + return node.blank ? t('session.new') : (node.title || t('session.untitled')) } /* Overflow this small hides no meaningful tail; scrolling for it reads as an @@ -495,7 +495,7 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: )} - {result.title} + {result.title || t('session.untitled')} {result.archived && ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 0be603f662..1857367fff 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -292,7 +292,7 @@ function sectionMembers( * and the renderer localizes its display label. */ function sessionTitle(session: SessionSummary): string { - return session.blank ? '' : session.displayTitle + return session.blank ? '' : (session.title?.trim() || '') } /** Build one group without projecting session lineage into presentation. */ diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index cbe570e527..f0a7cd8be2 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1080,3 +1080,11 @@ describe('session row schedule seats', () => { } }) }) + +it.each([['未命名', t], ['untitled', tEn]])('labels unnamed history as %s', (label, translate) => { + const node: SessionNode = { id: sid('unnamed'), title: '', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false } + render( null} t={translate} />) + expect(screen.getByText(label)).toBeTruthy() +}) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index f5dc42aa81..9fb549c5bd 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -16,7 +16,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, + id: sid(id), title: id, displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), retainedBy: {}, }) const list = (...items: SessionSummary[]): SessionListState => ({ @@ -586,9 +586,9 @@ describe('deriveFlat', () => { describe('deriveSearchResults archive filtering', () => { it('archived sessions never match — not by title and not via a backend content hit', () => { const hit = summary('hit', 2) - hit.displayTitle = 'Needle row' + hit.title = hit.displayTitle = 'Needle row' const gone = summary('gone', 1) - gone.displayTitle = 'Needle archived' + gone.title = gone.displayTitle = 'Needle archived' const result = deriveSearchResults( list(hit, gone), [], @@ -604,7 +604,7 @@ describe('deriveSearchResults archive filtering', () => { it('matches archived sessions and flags them while the view shows them', () => { const gone = summary('gone', 1) - gone.displayTitle = 'Needle archived' + gone.title = gone.displayTitle = 'Needle archived' const result = deriveSearchResults( list(gone), [], 'needle', archived('gone'), 'show', noAttention, { items: [], hasMore: false }, 10, ) @@ -613,9 +613,9 @@ describe('deriveSearchResults archive filtering', () => { it('matches only archived sessions under the only filter', () => { const hit = summary('hit', 2) - hit.displayTitle = 'Needle row' + hit.title = hit.displayTitle = 'Needle row' const gone = summary('gone', 1) - gone.displayTitle = 'Needle archived' + gone.title = gone.displayTitle = 'Needle archived' const result = deriveSearchResults( list(hit, gone), [], 'needle', archived('gone'), 'only', noAttention, { items: [], hasMore: false }, 10, ) @@ -626,9 +626,9 @@ describe('deriveSearchResults archive filtering', () => { describe('deriveSearchResults', () => { it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => { const titleHit = summary('title-hit', 30, '/projects/a') - titleHit.displayTitle = 'Needle title' + titleHit.title = titleHit.displayTitle = 'Needle title' const workspaceHit = summary('workspace-hit', 20, '/projects/b') - workspaceHit.displayTitle = 'Ordinary title' + workspaceHit.title = workspaceHit.displayTitle = 'Ordinary title' const contentHit = summary('content-hit', 10, '/projects/c') const sessions = list(titleHit, workspaceHit, contentHit) const result = deriveSearchResults( @@ -721,7 +721,7 @@ describe('deriveSearchResults', () => { it('uses the supplied cap and preserves either local overflow or backend hasMore', () => { const rows = Array.from({ length: 5 }, (_, index) => { const item = summary(`s-${String(index).padStart(2, '0')}`, index) - item.displayTitle = `Needle ${String(index)}` + item.title = item.displayTitle = `Needle ${String(index)}` return item }) const overflow = deriveSearchResults( @@ -877,3 +877,11 @@ describe('parent folder membership', () => { expect(owningParentFolder(path, parents)).toBe(expected) }) }) + +it('leaves unnamed history titles empty for locale-owned row labels', () => { + const item = summary('unnamed', 1, '/work/Default workspace') + delete item.title + item.displayTitle = 'Default workspace' + const sessions = list(item) + expect(deriveFlat(sessions, [item.id], noRows, noAttention)[0]?.title).toBe('') +}) From f134ad960c859f053ade1646d33a1ae1f1b44a6b Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 16:18:56 +0800 Subject: [PATCH 002/102] docs(client): sync update guidance and session title catalog --- packages/client/ui-settings-general/README.i18n.yaml | 10 +++++----- packages/client/ui-settings-general/README.md | 4 +--- packages/client/ui-settings-general/README.zh.md | 6 +----- .../cordis-client-runner/src/client/slot-catalog.ts | 4 ++-- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index cdc7a6e66d..bf7f28710e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -9,14 +9,14 @@ en: 1017c980d55e1945 zh: d3febd2062fce20f /deepseek-ai-dsh-client-ui-settings-general/summary: - en: 1051439c9946de0e - zh: baf773d0e7024b2f + en: 56ad6c35c1c4745c + zh: 1911baf2e1ebddab /deepseek-ai-dsh-client-ui-settings-general/table-of-contents: en: aeb9c4bfdb19bc56 zh: 45a29bea90cff239 /deepseek-ai-dsh-client-ui-settings-general/use-this-package: - en: 516a33787b02cf42 - zh: 591e5eb93640bbb1 + en: e59c691c3c7f2246 + zh: 93d786c7eb3c5f08 /deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section: en: de484a8406f3fc17 zh: a3831774d457c49a @@ -55,4 +55,4 @@ zh: e9f1bb38af42de90 /deepseek-ai-dsh-client-ui-settings-general/known-limitations-and-deferred-work/dev-note: en: b36039e49a66b8e0 - zh: d99c26de8d49063f + zh: 306682cd90cce1db diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ccd82f7918..320ddd93cb 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -11,8 +11,6 @@ English | [中文](README.zh.md) Use this package to give the dsh web client a Settings panel, connection-recovery control, feature-contributed navigation, and sequential first-run onboarding. Users can open it from the sidebar, retry a failed connection immediately, and access a local configuration file when the Host makes one available on a loopback browser. Feature packages supply their own settings rows, sections, and onboarding steps; this package supplies their shared presentation and the Developer tools switch without adding onboarding copy. -Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs. - ## Table of Contents - [Use this package](#use-this-package) @@ -33,7 +31,7 @@ Users reach the shell through the sidebar's bottom Settings control; feature plu When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed. -In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. +In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs. Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 4bdeaef63f..d93e800174 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -11,8 +11,6 @@ kind: "package-reference" 使用本包可为 dsh Web 客户端提供 Settings 面板、连接恢复控件、由功能包贡献的导航,以及依次进行的首次运行引导。用户可以从侧边栏打开面板、立即重试失败的连接,并在宿主为回环浏览器提供本地配置文件时访问该文件。各功能包提供自己的设置行、分区和引导步骤;本包提供共享的界面展示和开发者工具开关,但不添加引导文案。 -桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 - ## 目录 - [使用本包](#use-this-package) @@ -33,7 +31,7 @@ kind: "package-reference" 当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 -在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 +在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(默认为 `Mod+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。 @@ -128,5 +126,3 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 **运行时不变式:** 不发布伴生入口。settings seam 校验并发布持久 onboarding section,slot core 会拒绝冲突;本地 document action 由 store 与组件测试覆盖。 - -检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。 diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1f0ffab903..e32693bb83 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3849,7 +3849,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title, project basename, or Session id. */\n displayTitle: string\n}', + '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title or the localized unnamed label. */\n displayTitle: string\n}', ], ownerPropsReferences: [ 'SessionId', @@ -3904,7 +3904,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title, project basename, or Session id. */\n displayTitle: string\n}', + '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title or the localized unnamed label. */\n displayTitle: string\n}', ], ownerPropsReferences: [ 'SessionId', From be5a84ad0bec2bdc839ee74760e71f45af5bddbd Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 16:49:23 +0800 Subject: [PATCH 003/102] fix(workspace): capitalize Untitled and align title fixtures --- .../first-batch.expected.md | 24 +++++++++---------- .../sidebar.expected.md | 10 ++++---- .../workspace-new-session-folding.e2e.ts | 4 ++-- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/locales.ts | 2 +- .../ui-workspace/tests/rows.client.spec.tsx | 2 +- .../tests/workspace-browser.client.spec.tsx | 2 +- .../search-results.expected.md | 2 +- 10 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md index 6009227833..ec04e12324 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md @@ -1,18 +1,18 @@ - tree "Sessions": - treeitem "{{workspace}}" [expanded] - treeitem "New Session" [selected] - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session": - - text: {{workspace}} - - button "Session actions for {{workspace}}" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled Session actions for Untitled Archive session Pin session": + - text: Untitled + - button "Session actions for Untitled" - button "Archive session" - button "Pin session" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" - button "Show 6 more sessions" diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 8778baac12..1aeb3e5a77 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -4,9 +4,9 @@ - button "Workspace actions for {{workspace}}" - button "New session in {{workspace}}" - treeitem "New Session" [selected] - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" - - treeitem "{{workspace}} 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" - button "Show 11 more sessions" diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index e23e0e9a80..9567153471 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -71,7 +71,7 @@ describe('web e2e: blank New Session folding quota', () => { const sidebar = page.getByRole('tree', { name: 'Sessions' }) await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7) expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1) - expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6) + expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(5) const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' }) await showMore.waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( @@ -82,7 +82,7 @@ describe('web e2e: blank New Session folding quota', () => { await showMore.click() await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12) - expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11) + expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(10) await compareOrRefreshGolden( join(EXPECTED_DIR, 'first-batch.expected.md'), await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1bdd9019a6..83686cce32 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -69,5 +69,5 @@ en: 00d7b1ddda7390f5 zh: 1e5a1055d1be4d1d /deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work/dev-note: - en: 77d2291f37844028 - zh: b95797542a367620 + en: 72e98246e3f8430c + zh: 5d17178d6122cc42 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index ce167af31c..52c42b4820 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -223,4 +223,4 @@ None. **Runtime invariant:** No companion is published. This is a pure-consumer plugin that registers presentational components into two host-declared slots and registers its locale dictionaries; its inject face consists of stateless RPC wrappers plus a create-and-open call. It emits no Cordis events and owns no cross-plugin mutable state. -History rows without a stored title use the localized unnamed label (未命名 / untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. +History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 58168dd0fd..90abe627aa 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -223,4 +223,4 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor **运行时不变式:** 不发布伴生入口。这是一个纯消费方插件,只向两个由宿主声明的 slot 注册展示组件,并注册自身的 locale dictionaries;inject face 由无状态 RPC 包装层和一次 create-and-open 调用组成;本插件不发出 Cordis 事件,也不持有跨插件可变状态。 -未保存标题的历史会话显示本地化名称「未命名 / untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。 +未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index a8462fa282..7e181f0232 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -129,7 +129,7 @@ export const en = { 'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', - 'session.untitled': 'untitled', + 'session.untitled': 'Untitled', 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': 'Select a session first', 'shortcut.noPicker': 'Directory picker unavailable', diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f0a7cd8be2..f1c8ef7574 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1081,7 +1081,7 @@ describe('session row schedule seats', () => { }) }) -it.each([['未命名', t], ['untitled', tEn]])('labels unnamed history as %s', (label, translate) => { +it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', (label, translate) => { const node: SessionNode = { id: sid('unnamed'), title: '', blank: false, running: false, runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false } render( id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + id: sid(id), title: overrides.displayTitle ?? id, displayTitle: id, running: false, blank: false, updatedAt, ...overrides, retainedBy: overrides.retainedBy ?? {}, }) const sessionState = ( diff --git a/snapshots/web/navigation-panes/search-results.expected.md b/snapshots/web/navigation-panes/search-results.expected.md index 49de115594..6be6bf1767 100644 --- a/snapshots/web/navigation-panes/search-results.expected.md +++ b/snapshots/web/navigation-panes/search-results.expected.md @@ -1,2 +1,2 @@ - tree "Search results": - - 'treeitem "{{workspace}} {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"' + - 'treeitem "Untitled {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"' From b1cf871b2549a4384a668b8856417e843be1a496 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 17:08:31 +0800 Subject: [PATCH 004/102] feat(desktop): collect product analytics through OTel --- apps/desktop-host/src/index.ts | 1 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/package.json | 3 +- apps/desktop/src/client/WelcomePage.tsx | 14 +- apps/desktop/src/main.ts | 19 ++- apps/desktop/src/preload-welcome.ts | 1 + apps/desktop/src/update-coordinator.ts | 4 + apps/desktop/src/welcome-api.ts | 5 + apps/desktop/src/welcome-backend.ts | 11 +- apps/desktop/src/welcome-window.ts | 19 ++- apps/desktop/tests/main-startup.spec.ts | 21 ++- apps/desktop/tests/update-coordinator.spec.ts | 14 +- apps/desktop/tests/welcome-host.spec.ts | 14 ++ .../tests/welcome-renderer.client.spec.tsx | 14 ++ apps/desktop/tsconfig.host.json | 3 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.md | 19 +++ docs/config-catalog.zh.md | 19 +++ docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/product-telemetry.i18n.yaml | 4 +- docs/subsystems/product-telemetry.md | 25 +++- docs/subsystems/product-telemetry.zh.md | 25 +++- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../src/client/contract/sessions.ts | 9 +- .../src/client/sessions/service.ts | 2 + .../tests/sessions-service.client.spec.ts | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 + packages/bundle/web-app/README.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 14 ++ packages/bundle/web-app/package.json | 4 +- .../client/product-analytics/README.i18n.yaml | 34 +++++ packages/client/product-analytics/README.md | 65 ++++++++++ .../client/product-analytics/README.zh.md | 65 ++++++++++ .../client/product-analytics/package.json | 80 ++++++++++++ .../product-analytics/src/client/index.ts | 44 +++++++ .../client/product-analytics/src/events.ts | 64 +++++++++ .../client/product-analytics/src/index.ts | 86 +++++++++++++ .../tests/collection.client.spec.ts | 54 ++++++++ .../tests/collection.host.spec.ts | 121 ++++++++++++++++++ .../product-analytics/tsconfig.client.json | 26 ++++ .../product-analytics/tsconfig.host.json | 44 +++++++ .../client/product-analytics/tsconfig.json | 11 ++ .../client/product-analytics/tsdown.config.ts | 3 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/package.json | 3 +- packages/client/ui-chat/src/client/apply.ts | 7 +- .../src/client/chat/TurnTailNodeView.tsx | 2 +- .../ui-chat/src/client/contract/slots.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 6 +- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- packages/client/ui-chat/tsconfig.client.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + packages/client/ui-conversation/package.json | 3 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/draft-editor.ts | 2 +- .../src/client/contract/input.ts | 2 +- .../src/client/input/facade.ts | 6 +- .../ui-conversation/src/client/input/hub.ts | 28 ++++ .../src/client/skeleton/InputBar.tsx | 2 +- .../input-reference-submit.client.spec.ts | 17 +++ .../service-orchestration.client.spec.ts | 21 +++ .../ui-conversation/tsconfig.client.json | 3 + .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 + .../client/ui-model-selection/README.zh.md | 2 + .../client/ui-model-selection/package.json | 3 +- .../src/client/directory.ts | 14 ++ .../ui-model-selection/src/client/service.ts | 2 + .../tests/browser-plugin.client.spec.ts | 24 +++- .../client/ui-model-selection/tsconfig.json | 3 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../client/ui-plugin-manager/package.json | 3 +- .../ui-plugin-manager/src/client/index.ts | 2 +- .../src/client/manager-store.ts | 48 ++++++- .../tests/manager-store.client.spec.ts | 60 ++++++++- .../ui-plugin-manager/tsconfig.client.json | 3 + .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../client/ui-settings-account/package.json | 3 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/AccountOnboarding.tsx | 4 +- .../src/client/AccountSection.tsx | 3 + .../src/client/DesktopOnboarding.tsx | 43 +++++-- .../src/client/OnboardingConfirmation.tsx | 5 +- .../src/client/SignInDialog.tsx | 12 +- .../ui-settings-account/src/client/index.ts | 7 +- .../src/client/onboarding-contract.ts | 3 + .../tests/desktop-onboarding.client.spec.tsx | 23 +++- .../client/ui-settings-account/tsconfig.json | 3 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../client/ui-settings-models/package.json | 3 +- .../src/client/DeepSeekOnboardingDialog.tsx | 5 +- .../src/client/ProviderEditor.tsx | 4 +- .../ui-settings-models/src/client/index.ts | 2 + .../client/ui-settings-models/tsconfig.json | 11 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 + packages/client/ui-sidebar/README.zh.md | 2 + packages/client/ui-sidebar/package.json | 3 +- .../client/ui-sidebar/src/client/index.ts | 6 +- packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 3 +- .../client/ui-workspace/src/client/index.ts | 5 +- .../ui-workspace/src/client/navigation.ts | 7 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/shortcuts.client.spec.ts | 2 +- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/api-catalog.ts | 8 +- .../src/client/slot-catalog.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 31 +++++ .../product-telemetry-otel/README.i18n.yaml | 4 +- .../host/product-telemetry-otel/README.md | 2 +- .../host/product-telemetry-otel/README.zh.md | 2 +- .../references/packages.md | 1 + pnpm-lock.yaml | 76 +++++++++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 7 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 149 files changed, 1555 insertions(+), 126 deletions(-) create mode 100644 packages/client/product-analytics/README.i18n.yaml create mode 100644 packages/client/product-analytics/README.md create mode 100644 packages/client/product-analytics/README.zh.md create mode 100644 packages/client/product-analytics/package.json create mode 100644 packages/client/product-analytics/src/client/index.ts create mode 100644 packages/client/product-analytics/src/events.ts create mode 100644 packages/client/product-analytics/src/index.ts create mode 100644 packages/client/product-analytics/tests/collection.client.spec.ts create mode 100644 packages/client/product-analytics/tests/collection.host.spec.ts create mode 100644 packages/client/product-analytics/tsconfig.client.json create mode 100644 packages/client/product-analytics/tsconfig.host.json create mode 100644 packages/client/product-analytics/tsconfig.json create mode 100644 packages/client/product-analytics/tsdown.config.ts diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index ecbff835ce..50c7f9c8af 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -20,6 +20,7 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) + process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = process.env.DSH_PRODUCT_ANALYTICS_ENABLED === '0' ? '0' : '1' const application = runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop', diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3778fa7c85..468cbf7678 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 45561fc0dd3a1b20 - zh: f4b03355d4bd0693 + en: 84dbd7387e8791c9 + zh: ec1ce14b79e953f5 /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5520b62967..f379b6f630 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,6 +2,8 @@ English | [中文](README.zh.md) +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its launch-time switch. Web usage is excluded. + The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 66dc8e16e8..70e48c7597 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,6 +2,8 @@ [English](README.md) | 中文 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。 + 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0326324fae..9e3ed75bcb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,6 +73,7 @@ "vite": "^6.0.0", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", - "@deepseek-ai/dsh-atomic-write": "workspace:*" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index b7f4069bcb..606a7615d0 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { const [expiryNotice, setExpiryNotice] = useState(false) const [page, setPage] = useState('entry') const pageRef = useRef('entry') + const visiblePage = useRef('entry') const [attempt, setAttempt] = useState(null) const attemptRef = useRef(null) const [starting, setStarting] = useState(false) @@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('view') + visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { focusEntry.current = false keyButton.current?.focus() } - }, [page]) + }, [page, api]) useEffect(() => { if (copyState !== 'copied' && copyState !== 'failed') return @@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return + void api.analytics?.('save-key') const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index f07b505d8a..edc2c4d4a5 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -1,5 +1,6 @@ /** Account authorization dialog; errors allow retry after cancelling any active attempt. */ -import { useEffect, useState } from 'react' +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' +import { useEffect, useRef, useState } from 'react' import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountSnapshot } from './AccountSection.tsx' import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' @@ -8,7 +9,8 @@ import { authorizeUrlWithTheme } from './authorize-url.ts' import css from './SignInDialog.module.css' /** @param props - safe account state, localized copy, and user actions. @returns login dialog. */ -export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: { +export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t, track }: { + track?: TrackProductEvent | undefined account: AccountSnapshot /** Resolved scheme of the active Desktop theme; the copied link carries it. */ colorScheme: 'light' | 'dark' @@ -18,6 +20,8 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp useApiKey: () => void t: (key: AccountKey) => string }) { + const viewed = useRef(false) + useEffect(() => { if (!viewed.current) { viewed.current = true; track?.('auth_page_view', {}) } }, [track]) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) const [copyResult, setCopyResult] = useState<{ messageKey: 'copiedLink' | 'copyFailed' } | null>(null) @@ -76,9 +80,9 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp
+ onClick={active ? dismiss : () => { track?.('auth_page_click', { button_name: 'api-key' }); useApiKey() }}>{t(active ? 'cancel' : 'addApiKey')}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 9e4eef835c..fab9001aaf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -1,4 +1,5 @@ /** Desktop account settings registration and reconnecting Remote subscription. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -24,7 +25,7 @@ import { en, zh, type AccountKey } from './locales.ts' import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from './AccountQuotaNotice.tsx' import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts' import { DesktopOnboardingController } from './onboarding-state.ts' -import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx' +import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx' import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts' import { refreshAfterReturn } from './account-refresh.ts' export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx' @@ -155,6 +156,7 @@ export function apply(ctx: Context): void { if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead) }) const operations: AccountSectionInjected = { + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener), subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, @@ -229,13 +231,14 @@ export function apply(ctx: Context): void { }, 'account: desktop credential readiness') ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account', - inject: () => ({ + inject: (): DesktopOnboardingInjected => ({ hooks: { account: operations.hooks.account, onboarding: controller.state }, // Onboarding's recharge return re-reads profile and balance only. ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refresh) }, update: (change: OnboardingChange) => controller.update(change), complete: (reason: 'completed' | 'skipped') => controller.complete(reason), retry: () => controller.retry(), + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), }), }, DesktopOnboardingEntry)) } diff --git a/packages/client/ui-settings-account/src/client/onboarding-contract.ts b/packages/client/ui-settings-account/src/client/onboarding-contract.ts index 5c1a82081c..4d3815d90e 100644 --- a/packages/client/ui-settings-account/src/client/onboarding-contract.ts +++ b/packages/client/ui-settings-account/src/client/onboarding-contract.ts @@ -1,4 +1,5 @@ /** Pure view inputs for the desktop onboarding flow. */ +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import type { OnboardingProgress } from '../onboarding-settings.ts' import type { AccountSnapshot } from './AccountSection.tsx' import type { PlatformPages } from './platform-pages.ts' @@ -21,6 +22,8 @@ export type OnboardingChange = Partial { cleanup(); vi.unstubAllGlobals() }) function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', balance: 'zero' | 'positive' | 'bonus' | 'failed' | 'loading' = 'positive', status: DesktopOnboardingState['status'] = 'ready', copy: typeof zh = zh, creditFunded = false) { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + const track = vi.fn() const complete = vi.fn(async () => true) const retry = vi.fn(async () => true) // The shared host owns the native view; this flow only requests a page from it. @@ -25,7 +26,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) - return copy[key]} complete={complete} retry={retry} + return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) const saved = await update(change) @@ -38,7 +39,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, + complete, openPlatformPage, release, update, retry, track, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -330,3 +331,21 @@ it('returns from purpose to credit without dropping the selected purpose', async await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingBack })) }) expect(h.update).toHaveBeenCalledWith({ step: 'credit' }) }) + + +it('reports visible pages once, funded Continue, popup actions, and no close event', async () => { + const b = mount('credit', 'positive', 'ready', zh, true) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) + b.track.mockClear() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) + expect(b.track).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'next' }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' }) +}) + +it('does not report the loading surface as a page', () => { + expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-settings-account/tsconfig.json b/packages/client/ui-settings-account/tsconfig.json index a2c29cb37a..ac170f07db 100644 --- a/packages/client/ui-settings-account/tsconfig.json +++ b/packages/client/ui-settings-account/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../settings/settings" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3db8dbb74f..11a87aa0d3 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -6,8 +6,8 @@ en: d1bc4d9ef67bfb19 zh: d095e4dc76f1c5e0 /deepseek-ai-dsh-client-ui-settings-models: - en: 65e1e8a17a23e576 - zh: 01de4e98a002d376 + en: 0db6fa836e1ff511 + zh: 0e2968763e1fb7a5 /deepseek-ai-dsh-client-ui-settings-models/summary: en: 53301a5d29936f69 zh: 9ac408a33571dbdd diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 3b21e7465a..2131749232 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -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 `dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned internal-testing notice and the conditional official-DeepSeek credential step. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 242263798e..b6762de368 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 `dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 587093b505..22bac7f522 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -57,7 +57,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/dsh-remote-mock": "workspace:*" + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index fdc32c4045..63c090b3d5 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -5,7 +5,7 @@ * official DeepSeek route. The step reuses that page's credential editor in * the onboarding plugin's shared modal, so the key is entered once. */ - +import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -22,6 +22,8 @@ import styles from './DeepSeekOnboardingDialog.module.css' /** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */ export interface DeepSeekOnboardingInjected { + /** Desktop-only event collection. */ + track?: TrackProductEvent /** Whether first-run setup should show automatically. Explicit requests remain available. */ automatic: boolean hooks: { @@ -120,6 +122,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): readOnly={false} hideTitle credentialOnly + onSubmitCredential={() => { props.track?.('api_key_save_click', {}) }} credentialRequired autoFocusCredential cancelLabelKey="onboardingLater" diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 3ed7b42d87..cc21bade9c 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -87,6 +87,8 @@ export interface ProviderEditorProps { submitBusyLabelKey?: keyof typeof en /** Close the editor; `changed` reports whether an Apply committed. */ onClose: (changed: boolean) => void + /** Called on a credential form submission before the save starts. */ + onSubmitCredential?: () => void /** * Called once per change with whether the apply or the model list's * endpoint interrogation is in flight, so the owner can hold its surface @@ -529,7 +531,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'} {...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }} onCancel={() => { props.onClose(false) }} - onSubmit={() => { void apply() }} + onSubmit={() => { props.onSubmitCredential?.(); void apply() }} /> ) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index a96665b11f..c80634a2b6 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -6,6 +6,7 @@ * Export discipline: * packages/client/AGENTS.md. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -98,6 +99,7 @@ export function apply(ctx: ClientContext): void { }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ automatic: credentialOnboarding, + track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes), controller, hooks: { models: controller.store }, operations, diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 13bf556904..121f44cdde 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -8,8 +8,12 @@ "src" ], "references": [ - { "path": "../../host/webserver" }, - { "path": "../../../vendor/schemastery" }, + { + "path": "../../host/webserver" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../../vendor/cordis" }, @@ -33,6 +37,9 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index ee4d7543ff..83ac74dbf0 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -6,8 +6,8 @@ en: 7884381c2c554eca zh: e6d7199a9bb2f0f8 /deepseek-ai-dsh-client-ui-sidebar: - en: 3b3885c78b619c8a - zh: 2cabf9cfec748497 + en: e357f49fb1d1710e + zh: 2e61922b104f77eb /deepseek-ai-dsh-client-ui-sidebar/summary: en: af17f03561e5372b zh: 91a1b9f6c7a51025 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 2750ab706a..31839e55ad 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -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 The dsh web client sidebar lets users recognize the active build, start a new session, collapse navigation to a 56px rail, browse Workspaces and Sessions, and open Settings. It preserves a bottom-pinned Settings entry and hides idle scrollbars without moving browser rows. New Session uses an explicitly selected Workspace, then the current Session's Workspace, then the most recently active Workspace; if none exists, it opens a blank New Session page. Deployments can replace the brand mark or name while retaining the navigation controls and rail geometry. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 32364811ae..11fcb415c3 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将导航折叠为 56px 轨道、浏览 Workspace 与 Session,以及打开 Settings。它会将 Settings 入口固定在底部,并在隐藏空闲滚动条时避免浏览器行发生位移。New Session 优先使用显式选择的 Workspace,其次使用当前 Session 所属的 Workspace,再其次使用最近活跃的 Workspace;如果都不存在,则打开空白的 New Session 页面。部署可以替换品牌标记或名称,同时保留导航控件和轨道几何。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index ab290ffde2..b0bace3247 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -62,7 +62,8 @@ "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "clsx": "^2.0.0", - "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 603c143fa7..3cd24cfc12 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,4 +1,5 @@ /** Registers the sidebar shell and global panel navigation. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -66,7 +67,10 @@ export function apply(ctx: ClientContext): void { // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, - selectPanel: (id) => { ctx.layout.selectPanel(id) }, + selectPanel: (id) => { + if (id === 'plugins' || id === 'schedules') ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: id === 'plugins' ? 'plugin' : 'cron' }) + ctx.layout.selectPanel(id) + }, hooks: { panels, shortcuts: ctx.shortcuts.catalog }, }) ctx.slots.inject('sidebar', () => ctx.slots.register({ diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 903b24e14e..788b1b7a53 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../shortcuts/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index e9a2f19a98..75d4c1068c 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -6,8 +6,8 @@ en: 7c0f613105d4008a zh: c36c0cdff7e77576 /deepseek-ai-dsh-client-ui-workspace: - en: c611184cd49f130f - zh: caddcda48dc30f11 + en: b286adf3aca51710 + zh: 6122f6e7c8015197 /deepseek-ai-dsh-client-ui-workspace/summary: en: 1a76ace6a18e3ab5 zh: b274d1e98e133851 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d19423f195..9b17e88d4b 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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 This package lets users browse grouped or flat Session lists, choose a Workspace for a new Session, and manage Workspaces and Sessions through add, rename, reorder, search, fork, archive, and Workspace deletion; the Session row menu and its hover buttons are slot lists that client plugins extend. Pending interactions appear as warning dots, and subagent-origin Sessions remain hidden. An idle, unarchived Session row with active scheduled tasks shows a clock mark, and its hover card lists those tasks. Canonically distinct folder paths remain separate Workspaces. Adding a Workspace requires a composed directory picker; without one, adding is unavailable. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f2bf73d483..6a375a1a61 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。 + ## 概述 本包让用户浏览分组或扁平的 Session 列表、为新 Session 选择 Workspace,并通过添加、重命名、重排序、搜索、fork、归档和删除 Workspace 来管理 Workspace 与 Session;Session 行菜单及其悬停按钮是可由客户端插件扩展的 slot 列表。待处理交互显示为警告点,subagent 来源的 Session 则保持隐藏。处于空闲状态且未归档、其 Session 有活动定时任务的 Session 行会显示时钟标记,其悬浮卡片会列出这些任务。规范化后仍有差异的文件夹路径会保留为独立 Workspace。添加 Workspace 需要组合目录选择器;没有目录选择器时,添加操作不可用。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c9f220a07a..24b32b8120 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -78,7 +78,8 @@ "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", - "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 98ff858e35..2ecaf7e264 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -15,6 +15,7 @@ * its relative time and its trailing status line. Export discipline: * packages/client/AGENTS.md. */ +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' @@ -208,7 +209,9 @@ export function apply(ctx: Context): void { }) const forkInjected = (): ForkSessionInjected => ({ forkSession: (sessionId) => { - uiWorkspace.forkSession(sessionId).catch(() => { + uiWorkspace.forkSession(sessionId, (childId) => { + ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, click_position: 'sidebar' }) + }).catch(() => { // Fork or child-title failure leaves the list as it was. }) }, diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1b9ff0be25..fbdc7d03e6 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -45,9 +45,10 @@ export interface UiWorkspace { /** * Fork a Session without changing the current selection. * @param sessionId - source Session. + * @param onCreated - observer before the optional child-title update. * @returns completion after child creation and inherited-title increment. */ - forkSession(sessionId: SessionId): Promise + forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise /** * Resolve the reusable or newly created blank Session for a Workspace. * @param workspaceId - target Workspace. @@ -215,8 +216,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(sessionId, navigation, 'reveal', beforeOpen) } - async forkSession(sessionId: SessionId): Promise { - await this.sessions.fork({ sessionId, increaseTitle: true }) + async forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise { + return this.sessions.fork({ sessionId, increaseTitle: true, ...onCreated === undefined ? {} : { onCreated } }) } startSession(workspaceId?: WorkspaceId): void { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index daea79d72c..7d64d78aba 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -485,7 +485,7 @@ describe('ui-workspace apply', () => { b.retain.mockClear() fork.forkSession('session' as never) await forkSession.mock.results[0]!.value - expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true }) + expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }) expect(b.retain).not.toHaveBeenCalled() // The rename row raises the request the dialog entry reads; settling clears it. diff --git a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts index 2a5fce24ed..0db600df11 100644 --- a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts +++ b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts @@ -48,7 +48,7 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop') { locale.setLocale('en') ctx.provide('locale', locale) const navigation = { - startSession: vi.fn(), forkSession: vi.fn(async () => {}), archiveSession: vi.fn(async (_sessionId: SessionId) => {}), + startSession: vi.fn(), forkSession: vi.fn(async () => 'fork-child' as SessionId), archiveSession: vi.fn(async (_sessionId: SessionId) => {}), } const controls = createWorkspaceShortcutControls() const fiber = ctx.plugin((scoped) => { diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 496d215d1d..ab3b3c6025 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -73,6 +73,9 @@ }, { "path": "../shortcuts/tsconfig.client.json" + }, + { + "path": "../product-analytics/tsconfig.client.json" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 41e5ec6321..325cd0fb36 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -213,9 +213,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'bounded results, or a business/transport error.', }, { - signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise', + signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void }): Promise', description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.', - parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving.' }], + parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.' }], returns: 'the child session id.', throws: ['when the fork fails, or when a requested child-title rename fails after creation.'], }, @@ -348,9 +348,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['on failure; a refused creation is also shown through the Workspace notice unless a later navigation or disposal superseded the request.'], }, { - signature: 'forkSession(sessionId: SessionId): Promise', + signature: 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', description: 'Fork a Session without changing the current selection.', - parameters: [{ name: 'sessionId', description: 'source Session.' }], + parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }], returns: 'completion after child creation and inherited-title increment.', }, { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1f0ffab903..c288ffe5d4 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -241,10 +241,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number, messageId?: MessageId) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', + 'MessageId', 'MessageImageLoader', 'OpenFileOptions', 'RenderMessageImages', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c044c101e8..c40f8cdfb5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1697,6 +1697,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'productAnalytics', + summary: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + description: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + methods: [ + { + signature: '@Remote enabled(): boolean', + description: 'Read the collection policy.', + parameters: [], + returns: 'whether this Host currently accepts Desktop analytics.', + }, + { + signature: '@Remote async report(event: ProductEvent): Promise', + description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', + parameters: [{ name: 'event', description: 'typed product event without message contents or credentials.' }], + returns: 'after local submission; no delivery or warehouse acknowledgement.', + }, + ], + }, { key: 'productTelemetry', summary: 'Host analytics sender.', @@ -5789,6 +5808,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OfficeToPdfResult', declaration: 'export interface OfficeToPdfResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: OfficeToPdfKey;\n readonly generation: OfficeToPdfGeneration;\n}', }, + { + name: 'OnboardingPage', + declaration: 'export type OnboardingPage = \'onboarding_welcome\' | \'onboarding_recharge\' | \'onboarding_use_case\' | \'onboarding_process\';', + }, { name: 'OneShotScheduleRecord', declaration: 'export type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord;', @@ -5929,6 +5952,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, + { + name: 'ProductEvent', + declaration: 'export type ProductEvent = {\n [K in keyof ProductEventMap]: {\n eventName: K;\n attributes: ProductEventMap[K];\n timestamp: number;\n };\n}[keyof ProductEventMap];', + }, + { + name: 'ProductEventMap', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + }, { name: 'ProductTelemetryRecord', declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}', diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index e35c7672f8..287dbe064f 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: 08e0bcbc259d131b - zh: 1d7cbde2688a903f + en: 26d751042e98819b + zh: 3186108a702b348c /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: 5d2c06a357a1c06a zh: 9a1a17535889f5e3 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index af1f2c415f..bee01e58cc 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index ffdb682d9a..b39912619d 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 020a479a2e..a16e349f66 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -62,6 +62,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-hmr` | yes | Web client graph synchronization and rebuilt-bundle reload transport | | `@deepseek-ai/dsh-client-locale` | no | Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries | | `@deepseek-ai/dsh-client-modules` | no | Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam | +| `@deepseek-ai/dsh-client-product-analytics` | yes | Desktop product event collection and authenticated Host reporting | | `@deepseek-ai/dsh-client-resources` | no | Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook | | `@deepseek-ai/dsh-client-shortcuts` | yes | Application keyboard command registry and physical-key routing | | `@deepseek-ai/dsh-client-ui-agent-preset` | no | Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcc5c5e0a..6ba4846f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,9 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../packages/util/atomic-write + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../packages/client/product-analytics '@deepseek-ai/dsh-client-shortcuts': specifier: workspace:* version: link:../../packages/client/shortcuts @@ -1171,6 +1174,9 @@ importers: '@deepseek-ai/dsh-client-file-upload': specifier: workspace:* version: link:../../client/file-upload + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../client/product-analytics '@deepseek-ai/dsh-client-ui-plugin-manager': specifier: workspace:* version: link:../../client/ui-plugin-manager @@ -2377,6 +2383,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:* version: link:../../client/modules + '@deepseek-ai/dsh-client-product-analytics': + specifier: workspace:* + version: link:../../client/product-analytics '@deepseek-ai/dsh-client-resources': specifier: workspace:* version: link:../../client/resources @@ -2578,6 +2587,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:* version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-host-product-telemetry-otel': + specifier: workspace:* + version: link:../../host/product-telemetry-otel '@deepseek-ai/dsh-host-webserver': specifier: workspace:* version: link:../../host/webserver @@ -2902,6 +2914,46 @@ importers: specifier: workspace:* version: link:../../util/package-manifest + packages/client/product-analytics: + dependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:* + version: link:../../credentials/credentials + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../../api/gateway + '@deepseek-ai/dsh-compaction': + specifier: workspace:* + version: link:../../compaction/compaction + '@deepseek-ai/dsh-deepseek-account': + specifier: workspace:* + version: link:../../credentials/deepseek-account + '@deepseek-ai/dsh-host-product-telemetry-otel': + specifier: workspace:* + version: link:../../host/product-telemetry-otel + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:* + version: link:../../host/webserver + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + packages/client/resources: devDependencies: '@deepseek-ai/cordis': @@ -3182,6 +3234,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 @@ -3372,6 +3427,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-shortcuts': specifier: workspace:* version: link:../shortcuts @@ -3952,6 +4010,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 @@ -4229,6 +4290,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 @@ -4615,6 +4679,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 @@ -4797,6 +4864,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 @@ -5088,6 +5158,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-shortcuts': specifier: workspace:* version: link:../shortcuts @@ -6023,6 +6096,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-shortcuts': specifier: workspace:* version: link:../shortcuts diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 666cebd31f..32813a3c33 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -53,6 +53,7 @@ export const SERVICE_PAGE: Record = { speechToText: 'voice-input.md', speechController: 'voice-input.md', productTelemetry: 'product-telemetry.md', + productAnalytics: 'product-telemetry.md', connection: 'web-server.md', pluginManager: 'boot.md', pluginRegistryProbe: 'boot.md', @@ -826,6 +827,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ /** Project types deliberately documented outside the subsystems catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { + ProductEvent: 'Desktop event fields are owned by packages/client/product-analytics/README.md and src/events.ts', ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts', ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0dc61657a9..2395937974 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -405,6 +405,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-pi-ai'], note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.', }, + { + key: 'productAnalytics', + pkg: 'client-product-analytics', + title: 'Desktop interaction collection', + mode: 'service', + note: 'Accepts selected Desktop events, enriches available login identity, and observes live compaction under the launch-time collection policy.', + }, { key: 'productTelemetry', pkg: 'host-product-telemetry-otel', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 842708cc12..8d93abe461 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/client/product-analytics': { kind: 'none', reason: 'Desktop analytics observes selected interactions without contributing model context or Session events.' }, 'packages/experimental/speech-to-text': { kind: 'none', reason: 'Routes transient recognition without adding model requests or Session events.' }, 'packages/experimental/api-speech-to-text': { kind: 'none', reason: 'Transports audio and preparation state; ordinary user submission owns model-visible text.' }, 'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index e2b7da61e8..074ddf99d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,9 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-client-product-analytics": ["./packages/client/product-analytics/src/index.ts"], + "@deepseek-ai/dsh-client-product-analytics/types": ["./packages/client/product-analytics/src/events.ts"], + "@deepseek-ai/dsh-client-product-analytics/client": ["./packages/client/product-analytics/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-settings-account": ["./packages/client/ui-settings-account/src/index.ts"], "@deepseek-ai/dsh-client-ui-settings-account/client": ["./packages/client/ui-settings-account/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/stream-protocol": ["./packages/api/gateway/src/stream-protocol.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0471bfa13b..6d6d5bf726 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -43,6 +43,7 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.client.json" }, { "path": "./tsconfig.desktop-keyboard-tests.json" }, { "path": "./apps/desktop/tsconfig.client.json" }, { "path": "./packages/experimental/client-ui-voice-input" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 718da78670..7ae07c6fe5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/client/product-analytics/tsconfig.host.json" }, { "path": "./packages/experimental/speech-to-text" }, { "path": "./packages/experimental/speech-to-text-sensevoice" }, { "path": "./packages/experimental/api-speech-to-text" }, From 274bb97e0a65db37c82fef54b189f9067028c8b9 Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 17:13:12 +0800 Subject: [PATCH 005/102] test(client): cover unnamed results and versionless update tooltips --- .../tests/desktop-update.client.spec.tsx | 13 +++++++++++++ .../tests/rename-assembly.client.spec.tsx | 6 +++--- .../client/ui-workspace/tests/rows.client.spec.tsx | 10 ++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx index 35970bff6f..505a02d969 100644 --- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -164,3 +164,16 @@ it.each(['checking', 'verifying', 'installing'] as const)('shows only the versio expect((await screen.findByRole('tooltip')).textContent).toBe('0.1.7-alpha.2') } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } }) + +it.each([ + ['checking', '正在检查更新…'], + ['verifying', '正在校验更新文件…'], + ['installing', '正在准备重启…'], +] as const)('retains the state label when %s has no version', async (phase, label) => { + const f = fixture() + try { + await f.emit({ phase }) + fireEvent.focus(screen.getByRole('button', { name: label })) + expect((await screen.findByRole('tooltip')).textContent).toBe(label) + } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } +}) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index cc785a041b..0187272d53 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -119,8 +119,8 @@ describe('session rename through the assembled browser', () => { registerAction('last', 600, -2, 'Last action') const view = runtime.renderRoot() - const row = (await view.findByText('Session title')).closest('[role="treeitem"]')! - const trigger = within(row as HTMLElement).getByLabelText('会话“Session title”的操作') + const row = (await view.findByText('Persisted title')).closest('[role="treeitem"]')! + const trigger = within(row as HTMLElement).getByLabelText('会话“Persisted title”的操作') fireEvent.click(trigger) expect(view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ '置顶会话', '重命名', '分叉会话', '归档会话', 'Export action', 'Last action', @@ -134,7 +134,7 @@ describe('session rename through the assembled browser', () => { fireEvent.keyDown(last, { key: 'ArrowUp' }) expect(document.activeElement).toBe(exportRow) fireEvent.click(exportRow) - expect(selected).toHaveBeenCalledWith('export', SID, 'Session title') + expect(selected).toHaveBeenCalledWith('export', SID, 'Persisted title') // The plugin row dismissed the menu through the bound open-state hook; // the list returns focus to the trigger. expect(view.queryByRole('menu')).toBeNull() diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f1c8ef7574..a2338d6284 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1088,3 +1088,13 @@ it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', ( renderSlot={() => null} t={translate} />) expect(screen.getByText(label)).toBeTruthy() }) + +it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed search results as %s', (label, translate) => { + const result: SearchResultNode = { id: sid('unnamed-search'), title: '', workspace: 'Project', + running: false, runningSubagentCount: 0, completed: false, archived: false } + const onOpen = vi.fn() + render() + fireEvent.click(screen.getByText(label)) + expect(onOpen).toHaveBeenCalledWith(result.id) +}) From a4ac7e9eb36b7eecf6bca3b8a5e0abaae297eb44 Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 17:31:42 +0800 Subject: [PATCH 006/102] fix(workspace): align unnamed rename entry points and hover actions --- apps/desktop/README.i18n.yaml | 8 ++++---- apps/desktop/README.md | 4 ++-- apps/desktop/README.zh.md | 4 ++-- apps/desktop/src/main.ts | 2 +- apps/desktop/tests/fixtures/workspace-updates.mjs | 2 +- apps/desktop/tests/mandatory-update-window.spec.ts | 7 +++++++ packages/client/ui-settings-general/README.i18n.yaml | 2 +- packages/client/ui-settings-general/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 8 ++++---- packages/client/ui-workspace/README.md | 4 ++-- packages/client/ui-workspace/README.zh.md | 4 ++-- .../client/ui-workspace/src/client/contract/slots.ts | 2 +- .../client/ui-workspace/src/client/rows/Rows.tsx | 2 +- .../src/client/session-actions/RenameSession.tsx | 2 +- packages/client/ui-workspace/src/client/shortcuts.ts | 2 +- packages/client/ui-workspace/src/client/tree.ts | 7 ++++--- .../client/ui-workspace/tests/rows.client.spec.tsx | 5 ++++- .../ui-workspace/tests/shortcuts.client.spec.ts | 12 ++++++++++++ .../cordis-client-runner/src/client/slot-catalog.ts | 4 ++-- 19 files changed, 53 insertions(+), 30 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 962c45fe92..0b34a8ff2c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -6,8 +6,8 @@ en: 45561fc0dd3a1b20 zh: f4b03355d4bd0693 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: fb852f3bd70d8602 - zh: 3d2b0a8d580d7f0a + en: c323e02e3f0305fe + zh: 3c730db632136443 /deepseek-harness-desktop/key-technical-decisions: en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 @@ -66,5 +66,5 @@ en: 5b77213c86bbcfb6 zh: b34bf21773bb852b /deepseek-harness-desktop/dev-note: - en: 2b5d500798edeb32 - zh: 901a0e32ba2ff8be + en: 8e26b460ba31f21b + zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ccf65274ca..b593fa2766 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,6 +28,8 @@ Windows keeps a tray icon for the whole run. Its tooltip is the product name, a Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again. +The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation. + The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. Installer-owned quit cancels any pending ordinary quit decision; late inspection results and dialog answers cannot open another confirmation or repeat cleanup. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened. The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions. @@ -457,5 +459,3 @@ The account provider’s `embeddedPageDist` configuration adds a `dist` query pa ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). - -The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 794301baee..9ccead93ee 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,6 +28,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。 +强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。 + 以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。安装器接管退出时会取消尚未结束的普通退出决策;晚到的查询结果和弹框答复不会再次打开确认框或重复清理。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。 托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 @@ -459,5 +461,3 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 - -强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ea05aa24fa..aaed8c96b1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -793,7 +793,7 @@ async function main(): Promise { if (manual) { const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }), - detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }), + detail: locale.messages.updateDetail, buttons: [locale.messages.updateDownload], cancelId: 1 }) if (result.response !== 0) return } diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index 3c5014bf27..b7f79c777b 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -218,7 +218,7 @@ async function qualify() { const checking = await dialogWith(messages.updateChecking) await screenshot(checking, 'checking.png') server.release() - const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion())) + const current = await dialogWith(messages.updateCurrent) assert.equal(current, checking) await clickText(current, messages.updateAcknowledge) assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0) diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 484d01065f..901205bdb6 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -205,3 +205,10 @@ it('rebinds policy updates and reloads to a replacement Windows main window', () sender: window.webContents, senderFrame: window.webContents.mainFrame, })).not.toThrow() }) + +it.each(['darwin', 'win32'] as const)('includes the installation wait notice only on Windows: %s', (platform) => { + const f = setup(platform) + expect(f.view().locale.messages.mandatoryReadyDetail).toBe(platform === 'win32' + ? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。' + : '更新期间应用将暂时关闭,完成后会自动打开。') +}) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 1cd0fef9cf..46b8bfc53b 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -16,7 +16,7 @@ zh: 45a29bea90cff239 /deepseek-ai-dsh-client-ui-settings-general/use-this-package: en: 473e1f0843f7a896 - zh: eaae0c2618b7f93c + zh: acfbc21efc458bbf /deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section: en: de484a8406f3fc17 zh: a3831774d457c49a diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index fcf925f667..3b1b57adcf 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 -在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 +在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(默认为 `Mod+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 83686cce32..15a26aac9f 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-workspace/use-this-package: - en: c8d579e1e65bc8c5 - zh: 56f8e45351445ec7 + en: b164014ee565be16 + zh: 6a741647932b93e6 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: en: 1fe0693758e0536b zh: ef30198bc767fc04 @@ -69,5 +69,5 @@ en: 00d7b1ddda7390f5 zh: 1e5a1055d1be4d1d /deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work/dev-note: - en: 72e98246e3f8430c - zh: 5d17178d6122cc42 + en: 585813ff2c68c20c + zh: 18b43c0d64fa8a72 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 52c42b4820..43662a985d 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,6 +27,8 @@ This package lets users browse grouped or flat Session lists, choose a Workspace Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection. +History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying. + ### Reordering and view options Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads. @@ -222,5 +224,3 @@ None. **Runtime invariant:** No companion is published. This is a pure-consumer plugin that registers presentational components into two host-declared slots and registers its locale dictionaries; its inject face consists of stateless RPC wrappers plus a create-and-open call. It emits no Cordis events and owns no cross-plugin mutable state. - -History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 90abe627aa..a24fc2689c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。 +未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 + ### 重排序与视图选项 分组和平铺视图都先显示置顶 Session,再显示普通 Session。**最近更新**在各分区内严格按最近一次用户提示词或 steering(中途引导)时间降序排列,置顶时间不影响它。**手动排序**使用同一条完整 Session 序列中的相对位置,包括隐藏的归档项。返回最近更新会丢弃手动布局,再次进入手动排序时冻结当时的时间顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。 @@ -222,5 +224,3 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor **运行时不变式:** 不发布伴生入口。这是一个纯消费方插件,只向两个由宿主声明的 slot 注册展示组件,并注册自身的 locale dictionaries;inject face 由无状态 RPC 包装层和一次 create-and-open 调用组成;本插件不发出 Cordis 事件,也不持有跨插件可变状态。 - -未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。 diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e805ffa076..45f8564c8e 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -76,7 +76,7 @@ export interface DirectoryFlowOwnerProps { export interface SessionRowOwnerProps { /** Session the row shows. */ sessionId: SessionId - /** Row display title: persisted title or the localized unnamed label. */ + /** Row display title: persisted title, or empty when the Session has none. */ displayTitle: string } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 8a3ff43230..36fbbfd6fa 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -695,7 +695,7 @@ export function SessionNodeItem({ content={} openDelayMs={800} disabled={menuOpen || drag?.active === true} - copyText={row.blank ? undefined : row.title} + copyText={row.blank || row.title === '' ? undefined : row.title} copyLabel={t('copy')} copiedLabel={t('hover.copied')} /> diff --git a/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx b/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx index ee9819ace2..5e67673e77 100644 --- a/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx @@ -40,7 +40,7 @@ export function RenameSessionMenuItem({ * one dialog per request (keyed by the Session, so a new request starts a * fresh draft). Sessions have no client-side name-conflict rule (the host * normalizes), and unlike Workspace rename an unchanged title is NOT - * blocked: confirming the current automatic title is the gesture that pins it. + * blocked. An unnamed Session starts with an empty draft and requires a name. * @param props - the request hook, its settlement, the rename hop, and the locale seat. * @returns the open dialog, or null. */ diff --git a/packages/client/ui-workspace/src/client/shortcuts.ts b/packages/client/ui-workspace/src/client/shortcuts.ts index d4f21a720e..669b240b32 100644 --- a/packages/client/ui-workspace/src/client/shortcuts.ts +++ b/packages/client/ui-workspace/src/client/shortcuts.ts @@ -96,7 +96,7 @@ export function installWorkspaceShortcuts( register('session.rename', () => t('rename.session.title'), ['rename session'], 'KeyR', ['primary', 'alt'], ['primary', 'shift'], () => { const target = current() return target === undefined ? { status: 'blocked', reason: t('shortcut.noSession') } - : { status: 'handled', run: () => { controls.rename(target.id, target.displayTitle) } } + : { status: 'handled', run: () => { controls.rename(target.id, target.title?.trim() ?? '') } } }) register('session.fork', () => t('menu.fork'), ['fork session'], 'KeyF', ['primary', 'alt'], ['primary', 'shift'], () => { const target = current() diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 1857367fff..eb67d0ebe3 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -43,7 +43,7 @@ function mainSessionId(list: SessionListState): SessionId | undefined { /** One top-level session row in a group or the flat list. */ export interface SessionNode { id: SessionId - /** Stored display title; the renderer substitutes the localized New Session label for blank rows. */ + /** Stored title, or empty; the renderer localizes blank and unnamed row labels. */ title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean @@ -289,10 +289,11 @@ function sectionMembers( /** * A blank session is the selected Workspace's provisional New Session row; * its canonical title never enters search (blank rows are query-excluded) - * and the renderer localizes its display label. + * and the renderer localizes its display label. Unnamed history also yields an + * empty title for localization and does not match a directory-name title search. */ function sessionTitle(session: SessionSummary): string { - return session.blank ? '' : (session.title?.trim() || '') + return session.blank ? '' : (session.title?.trim() ?? '') } /** Build one group without projecting session lineage into presentation. */ diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index a2338d6284..6a32265a17 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1084,9 +1084,12 @@ describe('session row schedule seats', () => { it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', (label, translate) => { const node: SessionNode = { id: sid('unnamed'), title: '', blank: false, running: false, runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false } + const onRenameRequest = vi.fn() render( null} t={translate} />) + renderSlot={() => null} onRenameRequest={onRenameRequest} t={translate} />) expect(screen.getByText(label)).toBeTruthy() + fireEvent.doubleClick(screen.getByText(label)) + expect(onRenameRequest).toHaveBeenCalledWith(node.id, '') }) it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed search results as %s', (label, translate) => { diff --git a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts index 2a5fce24ed..531c93bab4 100644 --- a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts +++ b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts @@ -168,3 +168,15 @@ describe('workspace shortcut ownership', () => { expect(b.loadOlder).not.toHaveBeenCalled() }) }) + +it('opens an empty rename draft for unnamed history instead of its directory label', async () => { + const b = await bench() + const unnamed = row('a', true) + delete unnamed.title + unnamed.displayTitle = 'Default workspace' + b.list.set({ ...b.list.getSnapshot(), byId: { [unnamed.id]: unnamed } }) + const command = b.commands.get('session.rename')!.resolve(context) + expect(command.status).toBe('handled') + if (command.status === 'handled') command.run() + expect(b.controls.state.getSnapshot().renameTarget).toEqual({ sessionId: 'a', currentTitle: '' }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e32693bb83..6659ef98b7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3849,7 +3849,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title or the localized unnamed label. */\n displayTitle: string\n}', + '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title, or empty when the Session has none. */\n displayTitle: string\n}', ], ownerPropsReferences: [ 'SessionId', @@ -3904,7 +3904,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title or the localized unnamed label. */\n displayTitle: string\n}', + '/** Owner share of one Session row action occurrence: the row the action belongs to. */\nexport interface SessionRowOwnerProps {\n /** Session the row shows. */\n sessionId: SessionId\n /** Row display title: persisted title, or empty when the Session has none. */\n displayTitle: string\n}', ], ownerPropsReferences: [ 'SessionId', From 5fc3dafa491c615fae84a5ee9462c4bf706fd0fe Mon Sep 17 00:00:00 2001 From: liyao Date: Thu, 24 Sep 2026 19:06:18 +0800 Subject: [PATCH 007/102] fix(desktop): reuse Windows installation copy --- apps/desktop/src/locale.ts | 6 +----- apps/desktop/src/mandatory-update-window.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index d8e7360d33..066418e6bb 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -88,7 +88,6 @@ export const en = { updateDownload: 'Download update', updateDownloadedTitle: 'Version {version} is ready to install', updateDownloadedDetail: 'The app will close during the update and reopen automatically when it is complete.', - updateDownloadedTitleWindows: 'Version {version} is ready to install', updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.', updateClose: 'Close', updateAcknowledge: 'OK', @@ -127,7 +126,6 @@ export const en = { mandatoryReady: 'Update ready', mandatoryVersion: 'New version: {version}', mandatoryReadyDetail: 'The app will close during the update and reopen automatically when it is complete.', - mandatoryReadyDetailWindows: 'The app will close during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again until it finishes.', mandatoryDeferred: 'Tasks in progress will keep running. Complete the update before using the app again.', mandatoryContinue: 'Continue update', mandatoryInspecting: 'Checking tasks…', @@ -234,7 +232,6 @@ export const zh = { updateDownload: '下载更新', updateDownloadedTitle: '新版本 {version} 已准备就绪', updateDownloadedDetail: '更新期间应用将暂时关闭,完成后会自动打开。', - updateDownloadedTitleWindows: '新版本 {version} 已准备就绪', updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', updateClose: '关闭', updateAcknowledge: '确定', @@ -273,7 +270,6 @@ export const zh = { mandatoryReady: '更新已准备就绪', mandatoryVersion: '新版本:{version}', mandatoryReadyDetail: '更新期间应用将暂时关闭,完成后会自动打开。', - mandatoryReadyDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。', mandatoryDeferred: '进行中的任务会继续运行。请完成更新后再操作应用。', mandatoryContinue: '继续更新', mandatoryInspecting: '正在检查任务状态…', @@ -339,7 +335,7 @@ export function desktopUpdateReadyConfirmation( platform: string, ): { message: string; detail: string } { return { - message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }), + message: formatDesktopMessage(messages.updateDownloadedTitle, { version }), detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail, } } diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index cbaee74be7..3713883600 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -245,7 +245,7 @@ export class DesktopMandatoryUpdateWindow { private view(): MandatoryUpdateView { const locale = process.platform === 'win32' ? { ...this.options.locale, messages: { ...this.options.locale.messages, - mandatoryReadyDetail: this.options.locale.messages.mandatoryReadyDetailWindows } } + mandatoryReadyDetail: this.options.locale.messages.updateDownloadedDetailWindows } } : this.options.locale return { locale, policy: this.options.policy(), update: this.options.update(), deferred: this.deferred, From 1dc523299972f2b4828a3c35bace96ab64862947 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 19:29:30 +0800 Subject: [PATCH 008/102] fix(desktop): isolate analytics and redact installer inputs --- apps/desktop-host/src/index.ts | 4 +- .../tests/platform-session.spec.ts | 1 + apps/desktop/src/main.ts | 13 ++-- apps/desktop/src/update-coordinator.ts | 4 +- apps/desktop/src/welcome-api.ts | 2 - apps/desktop/src/welcome-window.ts | 6 +- apps/desktop/tests/update-coordinator.spec.ts | 10 +++ docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/credentials.md | 6 ++ docs/subsystems/credentials.zh.md | 6 ++ packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 3 + packages/boot/app-boot/README.zh.md | 3 + packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/launch-flag.ts | 15 +++++ .../boot/app-boot/tests/launch-flag.spec.ts | 11 ++++ packages/bundle/web-app/cordis.patch.yml | 9 ++- .../web-app/tests/product-analytics.spec.ts | 48 +++++++++++++++ .../client/product-analytics/README.i18n.yaml | 4 +- packages/client/product-analytics/README.md | 10 +-- .../client/product-analytics/README.zh.md | 10 +-- .../client/product-analytics/src/events.ts | 2 +- .../client/product-analytics/src/index.ts | 18 ++---- .../tests/collection.host.spec.ts | 39 +++++++----- .../tests/apply-inject.client.spec.tsx | 9 +++ .../src/client/input/editor/view-binding.ts | 2 +- .../src/client/input/facade.ts | 29 +++++++-- .../ui-conversation/src/client/input/hub.ts | 23 +++---- .../input-reference-submit.client.spec.ts | 61 ++++++++++++++++--- .../src/client/install-analytics.ts | 13 ++++ .../src/client/manager-store.ts | 7 ++- .../tests/install-analytics.client.spec.ts | 17 ++++++ .../tests/manager-store.client.spec.ts | 38 ++++++++++-- .../src/client/DesktopOnboarding.tsx | 4 +- .../src/client/OnboardingConfirmation.tsx | 4 +- .../tests/apply.client.spec.ts | 7 +++ .../tests/desktop-onboarding.client.spec.tsx | 14 ++++- .../tests/sign-in-dialog.client.spec.tsx | 9 +++ .../tests/apply.client.spec.ts | 6 ++ .../ui-workspace/src/client/navigation.ts | 2 +- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/index.ts | 20 +++++- .../tests/account.spec.ts | 17 ++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 5 ++ .../src/client/api-catalog.ts | 2 +- .../tests/api-catalog.client.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 8 ++- .../product-telemetry-otel/README.i18n.yaml | 4 +- .../host/product-telemetry-otel/README.md | 2 +- .../host/product-telemetry-otel/README.zh.md | 2 +- .../client-runtime/src/sessions.ts | 2 +- 59 files changed, 446 insertions(+), 120 deletions(-) create mode 100644 packages/boot/app-boot/src/launch-flag.ts create mode 100644 packages/boot/app-boot/tests/launch-flag.spec.ts create mode 100644 packages/bundle/web-app/tests/product-analytics.spec.ts create mode 100644 packages/client/ui-plugin-manager/src/client/install-analytics.ts create mode 100644 packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 32ab8d9dee..34c4f88f45 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -2,7 +2,7 @@ import { delimiter, join } from 'node:path' import { inspect } from 'node:util' -import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, loadProfileDirectory, resolveLaunchFlag } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -21,7 +21,7 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) - process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = process.env.DSH_PRODUCT_ANALYTICS_ENABLED === '0' ? '0' : '1' + process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) ? '1' : '0' const application = runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop', diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index fbc26da6e1..4abc1b2055 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -9,6 +9,7 @@ const state: AccountView = { } class Account extends DeepSeekAccount { + getDeviceIdentity = async () => ({ osVersion: 'test-os' }) fail = false ended = Promise.withResolvers() reading = Promise.withResolvers() diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 95687d0e2e..91859afd3d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import { resolveLaunchFlag } from '@deepseek-ai/dsh-app-boot' import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -378,9 +379,7 @@ async function main(): Promise { let injections: readonly unknown[] = [] let welcomeBackend: DesktopWelcomeBackend | undefined let reportedLaunch = false - const analyticsSetting = process.env.DSH_PRODUCT_ANALYTICS_ENABLED - if (analyticsSetting !== undefined && analyticsSetting !== '0' && analyticsSetting !== '1') throw new Error('DSH_PRODUCT_ANALYTICS_ENABLED must be 0 or 1') - const analyticsEnabled = analyticsSetting !== '0' + const analyticsEnabled = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) const track = async (eventName: K, attributes: ProductEventMap[K]): Promise => { if (!analyticsEnabled) return const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent @@ -578,7 +577,7 @@ async function main(): Promise { const result = await updateDialog.show(parent, confirmation) if (result.response !== 0 || isMandatory()) return false } - await track('desktop_upgrade_install_restart_click', {}) + void track('desktop_upgrade_install_restart_click', {}) if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) try { const stillActive = await host.updateTasks('lock') @@ -604,7 +603,7 @@ async function main(): Promise { return true }, undefined, undefined, undefined, - (success) => { void track('desktop_upgrade_download_result', { is_success: success, ...success ? {} : { error_reason: 'download_failed' } }) }, + (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) }, ) const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) @@ -1098,14 +1097,14 @@ async function main(): Promise { const showWelcome = (): Promise => { if (quitting) return Promise.resolve() if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) { + if (!welcomeWindow.isVisible()) void track('auth_page_view', {}) welcomeWindow.show() welcomeWindow.focus() return Promise.resolve() } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { - analyticsEnabled, - analytics: action => action === 'view' ? track('auth_page_view', {}) : action === 'save-key' ? track('api_key_save_click', {}) : track('auth_page_click', { button_name: action }), + ...analyticsEnabled ? { analytics: (action: 'view' | 'save-key' | 'sign_in' | 'api-key') => action === 'view' ? track('auth_page_view', {}) : action === 'save-key' ? track('api_key_save_click', {}) : track('auth_page_click', { button_name: action }) } : {}, takeNotice: () => { const notice = pendingWelcomeNotice pendingWelcomeNotice = undefined diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 7aa82baf9b..8d6eb808e7 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -53,7 +53,7 @@ export class DesktopUpdateCoordinator { private readonly updater: AppUpdater = autoUpdater, private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), - private readonly downloadResult?: (success: boolean) => void, + private readonly downloadResult?: (success: boolean, reason?: string) => void, ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -114,7 +114,7 @@ export class DesktopUpdateCoordinator { return this.setState({ phase: 'ready', version }) } catch (error) { this.downloaded = false - this.downloadResult?.(false) + this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed') return this.setState(this.failure(error, 'download')) } }).finally(() => { this.downloadOperation = undefined }) diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 210e6f94ef..8f6b8d560a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -25,8 +25,6 @@ export type WelcomeNotice = 'session-expired' export interface WelcomeOperations { /** @param action - fixed welcome gesture; never carries credential contents. */ analytics?(action: 'sign_in' | 'api-key' | 'save-key' | 'view'): Promise - /** Whether native welcome gestures may be collected. */ - analyticsEnabled?: boolean /** @returns the pending notification, clearing it before another renderer can receive it. */ takeNotice(): Promise /** @returns account state after starting a login attempt. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 70a65ed234..ef37f3f882 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -58,7 +58,7 @@ let disposeActiveHandlers: (() => void) | undefined */ export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise { const options = welcomeWindowOptions(process.platform, locale) - if (operations.analyticsEnabled && options.webPreferences?.additionalArguments) { + if (operations.analytics !== undefined && options.webPreferences?.additionalArguments) { options.webPreferences.additionalArguments.push('--dsh-product-analytics') } const window = new BrowserWindow(options) @@ -84,7 +84,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco ipcMain.handle(WELCOME_IPC.analytics, async (event, action: unknown) => { assertSender(event) if (action !== 'sign_in' && action !== 'api-key' && action !== 'save-key' && action !== 'view') throw new Error('desktop welcome: invalid analytics action') - if (operations.analyticsEnabled) await operations.analytics?.(action) + await operations.analytics?.(action) }) ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { @@ -120,7 +120,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile. if (active && !window.isDestroyed()) { window.show() - if (operations.analyticsEnabled) void operations.analytics?.('view') + void operations.analytics?.('view') } return window } diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index b5583b0cdf..790ad63fcf 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -232,3 +232,13 @@ it('reports one download result when callers share an operation and none for cac await f.coordinator.download('1.1.0-rc.2') expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true) }) + + +it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => { + const f = fixture() + await f.coordinator.check() + f.downloadUpdate.mockRejectedValueOnce(error) + await f.coordinator.download('1.1.0-rc.2') + expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed') + expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private') +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 570e517bf8..7cc08b9fc5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -500,8 +500,8 @@ export interface Config { ## `@deepseek-ai/dsh-client-product-analytics` -- `inject`: `credentials` · `deepseekAccount` · `webServer` -- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) +- `inject`: `deepseekAccount` · `webServer` +- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4411d10304..ac180f0acb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -502,8 +502,8 @@ export interface Config { ## `@deepseek-ai/dsh-client-product-analytics` -- `inject`: `credentials` · `deepseekAccount` · `webServer` -- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) +- `inject`: `deepseekAccount` · `webServer` +- `source`: [`packages/client/product-analytics/src/index.ts:13`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6626b801e8..c78dcbff5c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 11d630cdc7..8651e8c86a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -29,7 +29,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index decd5b088d..eb708f95f9 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + +/** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ +abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> ``` Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index b649f3c1ff..9a6ce9f3fa 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + +/** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ +abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> ``` Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 6c0d9fcddc..05537f4d34 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-app-boot/use-this-package: - en: f6fa378225009c8f - zh: 324c0e4640bb1358 + en: be4004cb1e04f7b4 + zh: eace2e738f6b6f97 /deepseek-ai-dsh-app-boot/use-this-package/when-to-use-it: en: 2fdfe02f2ed4a258 zh: d748228ece579c4a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index c32f8e4dd9..24f4c3a38b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -11,6 +11,7 @@ English | [中文](README.zh.md) `dsh-app-boot` is the shared Loader boot library behind `dsh` profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the `dsh` launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, configure HMR through profile YAML, and let a terminal-owning app restore its terminal before a fatal exit. + ## Table of Contents - [Use this package](#use-this-package) @@ -25,6 +26,8 @@ English | [中文](README.zh.md) ## Use this package +`resolveLaunchFlag(name, value, defaultValue)` accepts only `0`, `1`, or an absent value; other values fail with the variable name. + Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section. ### When to use it diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0bbdab2a5d..7edfc26de1 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -11,6 +11,7 @@ kind: "package-library" `dsh-app-boot` 是 `dsh` profile(包括 Python 运行时 wheel 包所含的 CLI(命令行界面))背后的共享 Loader 启动库。它加载环境层、组合 profile 组合包与 patch、启动每个插件,再返回运行中的应用,或指出失败插件与原因。产品应用使用 `dsh` launcher 而不发布单独 bin;直接配置 helper 只保留给低层嵌入方与测试。你还可以在启动前预览生效配置,按 profile 选择实时或仅启动时应用 patch,并让持有终端的应用在致命退出前恢复终端。 + ## 目录 - [使用本包](#use-this-package) @@ -25,6 +26,8 @@ kind: "package-library" ## 使用本包 +`resolveLaunchFlag(name, value, defaultValue)` 仅接受 `0`、`1` 或缺失值;其他值会抛出包含变量名的错误。 + 用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。 ### 何时使用 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 00515b4ad2..537a5a6b01 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' +export { resolveLaunchFlag } from './launch-flag.ts' export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { diff --git a/packages/boot/app-boot/src/launch-flag.ts b/packages/boot/app-boot/src/launch-flag.ts new file mode 100644 index 0000000000..5c090f4f5b --- /dev/null +++ b/packages/boot/app-boot/src/launch-flag.ts @@ -0,0 +1,15 @@ +/** Strict boolean parsing for launcher-owned environment switches. */ + +/** + * Resolve an optional binary environment switch, rejecting misspelled values. + * @param name - environment variable used in configuration errors. + * @param value - inherited environment value. + * @param defaultValue - policy when the variable is absent. + * @returns the resolved switch. + */ +export function resolveLaunchFlag(name: string, value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) return defaultValue + if (value === '0') return false + if (value === '1') return true + throw new Error(`${name} must be 0 or 1`) +} diff --git a/packages/boot/app-boot/tests/launch-flag.spec.ts b/packages/boot/app-boot/tests/launch-flag.spec.ts new file mode 100644 index 0000000000..b7ea1e1cb8 --- /dev/null +++ b/packages/boot/app-boot/tests/launch-flag.spec.ts @@ -0,0 +1,11 @@ +import { expect, it } from 'vitest' +import { resolveLaunchFlag } from '../src/launch-flag.ts' + +it.each([true, false])('uses the configured absent-value default: %s', (fallback) => { + expect(resolveLaunchFlag('FLAG', undefined, fallback)).toBe(fallback) + expect(resolveLaunchFlag('FLAG', '0', fallback)).toBe(false) + expect(resolveLaunchFlag('FLAG', '1', fallback)).toBe(true) +}) +it.each(['', 'false', 'no', '2'])('rejects invalid binary flags: %s', (value) => { + expect(() => resolveLaunchFlag('FLAG', value, true)).toThrow('FLAG must be 0 or 1') +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b03be5ce40..1bbff405e4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -44,16 +44,19 @@ - insert: - id: desktop-product-telemetry name: '@deepseek-ai/dsh-host-product-telemetry-otel' - disabled: !!js process.env.DSH_DESKTOP_PRODUCT_ANALYTICS !== '1' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop' || process.env.DSH_DESKTOP_PRODUCT_ANALYTICS !== '1'" config: + timeoutMillis: 1000 + exportTimeoutMillis: 1500 + shutdownTimeoutMillis: 2000 serviceName: deepseek-harness-desktop - serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION ?? 'unknown' + serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL - id: product-analytics name: '@deepseek-ai/dsh-client-product-analytics' config: - enabled: !!js process.env.DSH_DESKTOP_PRODUCT_ANALYTICS === '1' + enabled: !!js "ctx.get('profileContext')?.name === 'desktop' && process.env.DSH_DESKTOP_PRODUCT_ANALYTICS === '1'" appVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION # Host-owned opt-in sampled when a new Web session receives its preset diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts new file mode 100644 index 0000000000..38d0340422 --- /dev/null +++ b/packages/bundle/web-app/tests/product-analytics.spec.ts @@ -0,0 +1,48 @@ +/** Desktop-only collector policy and bounded shutdown against an unresponsive receiver. */ +import { createServer } from 'node:http' +import { once } from 'node:events' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import * as Telemetry from '@deepseek-ai/dsh-host-product-telemetry-otel' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' + +afterEach(() => { vi.unstubAllEnvs() }) + +it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop launch: %s', async (profile) => { + const received = Promise.withResolvers() + const server = createServer((req) => { req.resume(); req.once('end', () => { received.resolve(undefined) }) }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('missing collector address') + vi.stubEnv('DSH_DESKTOP_PRODUCT_ANALYTICS', '1') + vi.stubEnv('DSH_PRODUCT_ANALYTICS_APP_VERSION', 'test-version') + vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json', + cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + ctx.loader.builtins.telemetry = Telemetry + const rows = loadOverlayPatches('analytics', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'desktop-product-telemetry') + await ctx.loader.root.update(rows.map(row => ({ ...row, name: 'cordis:telemetry' }))) + await ctx.loader.await() + const entry = ctx.loader.resolve('desktop-product-telemetry') + expect(entry.disabled).toBe(profile !== 'desktop') + if (profile !== 'desktop') { + expect(ctx.get('productTelemetry')).toBeUndefined() + return + } + expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 }) + ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() }) + const disposal = entry.fiber!.dispose() + await received.promise + await disposal +}) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index d7c6ceb7ed..9fe052f618 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: 7636ab854070af43 - zh: 1a8ce4c24b7bbd51 + en: 704784e88b546490 + zh: a156401e08250c2b /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: a02729768db615ed zh: ed65aa11e5dab306 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 118fe1a3b4..f2f3b59d87 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -21,15 +21,17 @@ Collect selected Desktop interactions through the existing OTel product exporter ## Use this package -The Desktop launcher enables product analytics by default during testing. Set `DSH_PRODUCT_ANALYTICS_ENABLED=0` in its launch environment to disable it on the next start; `1` enables it. Other values are rejected. This setting controls native welcome and upgrade events, renderer events, and Host compaction collection together. Disabled launches create no exporter, inspect no analytics identity, and retain no events for later enablement. Session-feedback telemetry has its own policy. +The Desktop launcher enables product analytics by default. Set `DSH_PRODUCT_ANALYTICS_ENABLED=0` in its launch environment to disable it on the next start; `1` enables it. Other values are rejected. This setting controls native welcome and upgrade events, renderer events, and Host compaction collection together. Disabled launches create no exporter, inspect no analytics identity, and retain no events for later enablement. Session-feedback telemetry has its own policy. The shared composition supplies `enabled` (default `false`) and optional `appVersion` to this plugin. The Desktop Host enables it; an ordinary Web Host leaves it disabled. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery. -Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host selects only the account ID from the account session. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields. +Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields. -The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Send captures `submit_source`, `submit_type`, model, effort, and `run_mode` at submission; plan takes precedence over an active goal. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. +The desktop Host derives internal `DSH_DESKTOP_PRODUCT_ANALYTICS=0|1` from the validated launch switch. Electron supplies internal `DSH_PRODUCT_ANALYTICS_APP_VERSION` from its running version; an enabled exporter requires it. These variables belong to the Desktop launcher-to-Host protocol and are not Web opt-in controls. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. Enabling collection without the exporter logs a configuration warning. -Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from inspection through the terminal result, including internal registry retries. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. +The [event types](src/events.ts) own names and allowed fields. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the default message sink reports, so handled commands are excluded. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. + +Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. ## Understand the implementation diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index d8f3b2a521..34398d5206 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -21,15 +21,17 @@ kind: "package-reference" ## 使用本包 -桌面启动器在测试阶段默认开启产品埋点。在启动环境中设置 `DSH_PRODUCT_ANALYTICS_ENABLED=0`,下次启动时关闭;`1` 表示开启。其他值会被拒绝。该设置统一控制原生登录与升级事件、渲染进程事件和 Host 压缩事件采集。关闭后不会创建导出器、读取埋点身份或保留事件供以后补发。会话反馈遥测采用独立策略。 +桌面启动器默认开启产品埋点。在启动环境中设置 `DSH_PRODUCT_ANALYTICS_ENABLED=0`,下次启动时关闭;`1` 表示开启。其他值会被拒绝。该设置统一控制原生登录与升级事件、渲染进程事件和 Host 压缩事件采集。关闭后不会创建导出器、读取埋点身份或保留事件供以后补发。会话反馈遥测采用独立策略。 共享组合向本插件提供 `enabled`(默认 `false`)和可选的 `appVersion`。桌面 Host 会开启它,普通 Web Host 保持关闭。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。 -公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 只从账户会话中选择账户 ID。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 +公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 -[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。发送时采集 `submit_source`、`submit_type`、模型、思考强度和 `run_mode`;计划模式优先于活跃目标。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 +桌面 Host 从经过校验的启动开关派生内部变量 `DSH_DESKTOP_PRODUCT_ANALYTICS=0|1`。Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;启用导出器时该值必填。这些变量属于桌面启动器与 Host 之间的协议,不是 Web 开启采集的控件。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。启用采集但未挂载导出器时会记录配置警告。 -插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从检查输入开始,到最终结果结束,包含内部源重试。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 +[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅默认消息发送路径上报,已处理的命令不计入。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 + +插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 ## 了解实现 diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts index ceb0975b86..18e7c72e77 100644 --- a/packages/client/product-analytics/src/events.ts +++ b/packages/client/product-analytics/src/events.ts @@ -24,7 +24,7 @@ export interface ProductEventMap { model_name?: string thinking_effort?: string run_mode: 'plan' | 'goal' | 'default' - submit_source: 'click' | 'enter' + submit_source?: 'click' | 'enter' submit_type: 'normal' | 'steer' | 'queue' } model_switch: { session_id?: SessionId; switch_from: string; switch_to: string } diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts index df9baf0748..49202a943b 100644 --- a/packages/client/product-analytics/src/index.ts +++ b/packages/client/product-analytics/src/index.ts @@ -1,10 +1,8 @@ /** Desktop-only analytics RPC and live compaction collection. */ import { type Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { platform, release } from 'node:os' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel' -import { credentialKey } from '@deepseek-ai/dsh-credentials' import type {} from '@deepseek-ai/dsh-deepseek-account' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-session' @@ -27,7 +25,7 @@ declare module '@deepseek-ai/cordis' { /** Authenticated event intake; disabled instances neither inspect identity nor construct an exporter. */ export default class ProductAnalytics extends TypertRemoteService { - static inject = ['credentials', 'deepseekAccount', 'webServer'] + static inject = ['deepseekAccount', 'webServer'] static Config = z.object({ enabled: z.boolean().default(false), appVersion: z.string() }) private readonly collection: { active: boolean } @@ -39,6 +37,7 @@ export default class ProductAnalytics extends TypertRemoteService { table.push({ kind: 'global', name: '__DSH_PRODUCT_ANALYTICS__', value: this.collection.active }) }) if (!this.collection.active) return + if (ctx.get('productTelemetry') === undefined) ctx.logger.warn('Product analytics is enabled without productTelemetry; configure the exporter') ctx.on('session/event', (session, event) => { if (event.type !== 'compaction/start') return void this.report({ eventName: 'context_compression', timestamp: Date.now(), @@ -62,21 +61,16 @@ export default class ProductAnalytics extends TypertRemoteService { async report(event: ProductEvent): Promise { if (!this.collection.active) return try { - const [device, account] = await Promise.all([ - this.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'device')).catch(() => undefined), - this.ctx.deepseekAccount.getPlatformSession().catch(() => undefined), - ]) + const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined) if (!this.enabled()) return - const payload = device?.kind === 'grant' ? device.payload : undefined - const deviceId = typeof payload === 'object' && payload !== null && 'id' in payload && typeof payload.id === 'string' ? payload.id : undefined this.ctx.get('productTelemetry')?.emit({ ...event, body: event.eventName, attributes: { ...event.attributes, - ...deviceId === undefined ? {} : { device_id: deviceId }, - ...account?.userId == null ? {} : { user_id: account.userId }, + ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId }, + ...identity?.userId === undefined ? {} : { user_id: identity.userId }, ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion }, - os_version: `${platform()} ${release()}`, + ...identity === undefined ? {} : { os_version: identity.osVersion }, }, }) } catch (error) { diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index 1046aeefd9..106f9be988 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -14,30 +14,27 @@ afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await async function setup(enabled: boolean) { const ctx = new Context() cleanup.push(() => ctx.fiber.dispose()) - const readRecord = vi.fn().mockResolvedValue({ kind: 'grant', payload: { id: 'login-device', ignored: 'private-device-field' } }) - const getPlatformSession = vi.fn().mockResolvedValue({ userId: 'user-1', token: 'private-platform-token' }) + const getDeviceIdentity = vi.fn().mockResolvedValue({ deviceId: 'login-device', userId: 'user-1', osVersion: 'fixture-os', ignored: 'private-field' }) const emit = vi.fn<(record: ProductTelemetryRecord) => void>() - ctx.provide('credentials', { readRecord } as never) - ctx.provide('deepseekAccount', { getPlatformSession } as never) + ctx.provide('deepseekAccount', { getDeviceIdentity } as never) ctx.provide('webServer', {} as never) ctx.provide('productTelemetry', { emit } as never) const fiber = await ctx.plugin(Analytics, { enabled, appVersion: 'test-version' }) - return { ctx, fiber, readRecord, getPlatformSession, emit } + return { ctx, fiber, getDeviceIdentity, emit } } it('disabled collection does not read identity or submit an event', async () => { const b = await setup(false) expect(b.ctx.productAnalytics.enabled()).toBe(false) await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} }) - expect(b.readRecord).not.toHaveBeenCalled() - expect(b.getPlatformSession).not.toHaveBeenCalled() + expect(b.getDeviceIdentity).not.toHaveBeenCalled() expect(b.emit).not.toHaveBeenCalled() }) it('reuses login identity and copies only approved common fields', async () => { const b = await setup(true) await b.ctx.productAnalytics.report({ eventName: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in' } }) - expect(b.readRecord).toHaveBeenCalledWith('deepseek-account-platform/device') + expect(b.getDeviceIdentity).toHaveBeenCalledWith() expect(b.emit).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string }, @@ -47,16 +44,15 @@ it('reuses login identity and copies only approved common fields', async () => { it('missing identity does not discard an otherwise valid event', async () => { const b = await setup(true) - b.readRecord.mockRejectedValueOnce(new Error('credential store unavailable')) - b.getPlatformSession.mockResolvedValueOnce(undefined) + b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable')) await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} }) - expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version', os_version: expect.any(String) as string }) + expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' }) }) it('unload suppresses an identity lookup that settles after disposal', async () => { const b = await setup(true) const pending = Promise.withResolvers() - b.readRecord.mockReturnValueOnce(pending.promise) + b.getDeviceIdentity.mockReturnValueOnce(pending.promise) const reporting = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} }) await b.fiber.dispose() pending.resolve(undefined) @@ -79,7 +75,7 @@ it('writes the selected event through the real exporter to an isolated collector const ctx = new Context() cleanup.push(() => ctx.fiber.dispose()) ctx.provide('credentials', { readRecord: async () => undefined } as never) - ctx.provide('deepseekAccount', { getPlatformSession: async () => undefined } as never) + ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never) ctx.provide('webServer', {} as never) const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' })) await ctx.plugin(Analytics, { enabled: true }) @@ -101,7 +97,7 @@ it.each([true, false])('collects live manual and automatic compaction only when } if (enabled) await vi.waitFor(() => { expect(b.emit).toHaveBeenCalledTimes(2) }) expect(b.emit.mock.calls.map(([event]) => event.attributes?.trigger_type)).toEqual(enabled ? ['manual', 'auto'] : []) - expect(b.readRecord).toHaveBeenCalledTimes(enabled ? 2 : 0) + expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0) }) it.each([true, false])('publishes the launch-time collection flag to renderers: %s', async (enabled) => { @@ -113,9 +109,22 @@ it.each([true, false])('publishes the launch-time collection flag to renderers: it('omits an unavailable account and isolates exporter submission failure', async () => { const b = await setup(true) - b.getPlatformSession.mockRejectedValueOnce(new Error('account unavailable')) + b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable')) await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} }) expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('user_id') b.emit.mockImplementationOnce(() => { throw new Error('exporter unavailable') }) await expect(b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} })).resolves.toBeUndefined() }) + + +it('warns when enabled without an exporter, while a disabled Host needs none', async () => { + for (const enabled of [false, true]) { + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never) + ctx.provide('webServer', {} as never) + const warn = vi.spyOn(ctx.logger, 'warn') + await ctx.plugin(Analytics, { enabled }) + expect(warn).toHaveBeenCalledTimes(enabled ? 1 : 0) + } +}) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 2b2590f908..619994a831 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -1,3 +1,4 @@ +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' // @vitest-environment jsdom /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -170,6 +171,14 @@ describe('Chat inject API', () => { void injected.loadThrough(SessionSeq(42)) expect(b.session.loadThrough).toHaveBeenCalledWith(42) + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { track } as never) + const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT }) + injected.forkAt(17) + expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' }) + injected.forkAt(17, 'message-id' as MessageId) + expect(track).toHaveBeenCalledWith('branch_session_click', expect.objectContaining({ parent_message_id: 'message-id' })) + forkCreated.mockRestore() injected.forkAt(17) await vi.waitFor(() => { expect(b.openSession).toHaveBeenCalledWith(ROOT) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 89218a887c..f42b3a42be 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -134,7 +134,7 @@ export function installDraftKeymap( g.running, accelerated ? 'accelerated' : 'enter', g.steeringAvailable, - )) + ), 'enter') }, intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, pasteText: (text) => { diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 05811388b5..c13412be8d 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -42,7 +42,7 @@ export interface SessionInputDeps { /** Session-scope ctx handed to claim.submit transactions. */ actx: Context /** Capture mode and gesture before asynchronous submission or queue admission. */ - onSubmit?: (mode: InputSubmitMode, source: 'click' | 'enter') => void + captureSubmit?: (mode: InputSubmitMode, source: 'click' | 'enter' | undefined) => (() => void) | undefined /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */ inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ @@ -132,6 +132,7 @@ export class SessionInputShell implements SessionInput { submit: () => { this.submit('queue') }, } + private readonly submittedAnalytics = new WeakMap void>() private readonly core = new SubmitMachine() private readonly draftEditor: DraftEditorRuntime private get projection(): EditorProjection { @@ -292,9 +293,12 @@ export class SessionInputShell implements SessionInput { * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. */ - submit(mode: InputSubmitMode = 'queue', source: 'click' | 'enter' = 'enter'): void { + submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void { if (this.disposed) return - if ((this.snapshot.phase === 'plain' || this.snapshot.phase === 'claimed') && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) this.deps.onSubmit?.(mode, source) + let report: (() => void) | undefined + if (this.snapshot.phase === 'plain' && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) { + try { report = this.deps.captureSubmit?.(mode, source) } catch (_error) { /* Analytics cannot interrupt submission. */ } + } if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) { if (this.snapshot.phase === 'plain') { const attachmentIds = [...this.attachmentIds] @@ -303,6 +307,7 @@ export class SessionInputShell implements SessionInput { const flight = this.attachmentFlightSeq this.attachmentFlights.set(flight, { controller, attachmentIds }) this.commitSend(attachmentIds) + this.reportSubmission(report) void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { if (this.disposed || !this.attachmentFlights.delete(flight)) return if (outcome.kind === 'success') return @@ -325,7 +330,7 @@ export class SessionInputShell implements SessionInput { this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft)) return } - this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) + this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText }, report) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -574,9 +579,15 @@ export class SessionInputShell implements SessionInput { } /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */ - private dispatchRun(ev: Parameters[0]): void { + private dispatchRun(ev: Parameters[0], report?: () => void): void { const beforeToken = this.activeClaimToken() - this.run(this.core.dispatch(ev)) + const effects = this.core.dispatch(ev) + if (report !== undefined) { + for (const effect of effects) { + if ('attempt' in effect) this.submittedAnalytics.set(effect.attempt, report) + } + } + this.run(effects) if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration() } @@ -637,6 +648,8 @@ export class SessionInputShell implements SessionInput { draft: string, mode: InputSubmitMode, ): void { + this.reportSubmission(this.submittedAnalytics.get(attempt)) + this.submittedAnalytics.delete(attempt) const attachmentIds = [...this.attachmentIds] this.attachmentIds = [] const occurrences = this.projection.occurrences @@ -681,6 +694,10 @@ export class SessionInputShell implements SessionInput { ) } + private reportSubmission(report: (() => void) | undefined): void { + try { report?.() } catch (_error) { /* Analytics cannot interrupt submission. */ } + } + /** Settle one detached default send independently of other sends. */ private settleSink( attempt: SubmitAttempt, diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index cacd6e7aed..d2fcf8be65 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -8,7 +8,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' -import type { ModelSelection, ModelSelectionProjection, ModelProviderGroup } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/types' import type { Context } from '@deepseek-ai/cordis' @@ -94,29 +94,22 @@ export class InputHub implements SessionInputResolver { const { session, ctx: actx } = binding const shell = new SessionInputShell({ actx, - onSubmit: (mode, source) => { + captureSubmit: (mode, source) => { const analytics = this.rootCtx.get('productAnalytics') if (!analytics?.enabled) return const state = session.getSnapshot() 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 directories = this.rootCtx.get('modelDirectories') as { - directoryFor(id: SessionId): { - store: ObservableSnapshot<{ current: ModelSelection | null; groups: readonly ModelProviderGroup[] }> - } - } | undefined - const directory = directories?.directoryFor(state.sessionId).store.getSnapshot() - const selection = directory?.current ?? model?.next ?? model?.lastUsed - const selectedGroup = directory?.groups.find(group => group.id === selection?.provider) - const selectedModel = selectedGroup?.models.find(candidate => candidate.id === selection?.model) - const effort = selection?.reasoningEffort ?? selectedModel?.reasoning?.defaultEffort - analytics.track('send_button_click', { + const selection = model?.next ?? model?.lastUsed + const effort = selection?.reasoningEffort + const attributes = { ...state.blank ? {} : { session_id: state.sessionId }, ...selection === undefined || selection === null ? {} : { model_name: `${selection.provider}/${selection.model}`, ...effort === undefined ? {} : { thinking_effort: effort } }, run_mode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default', - submit_source: source, submit_type: state.running ? mode : 'normal', - }) + ...source === undefined ? {} : { submit_source: source }, submit_type: state.running ? mode : 'normal', + } as const + return () => { analytics.track('send_button_click', attributes) } }, inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 6eb65a5d84..0d7e472a4f 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' -import type { InputTriggerController, SubmitOutcome } from '../src/client/contract/input.ts' +import type { InputTriggerController, SubmitOutcome, PickOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' @@ -286,17 +286,64 @@ describe('submit transaction hardening', () => { it('captures click and Enter submission intent before async admission, excluding empty submits', async () => { - const onSubmit = vi.fn() - const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, onSubmit }) + const report = vi.fn() + const captureSubmit = vi.fn(() => report) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, captureSubmit }) try { shell.submit('queue', 'click') - expect(onSubmit).not.toHaveBeenCalled() + expect(captureSubmit).not.toHaveBeenCalled() shell.setDraft('first') shell.submit('steer', 'click') - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('steer', 'click') + expect(captureSubmit).toHaveBeenCalledExactlyOnceWith('steer', 'click') await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) shell.setDraft('second') - shell.submit('queue') - expect(onSubmit).toHaveBeenLastCalledWith('queue', 'enter') + shell.submit('queue', 'enter') + expect(captureSubmit).toHaveBeenLastCalledWith('queue', 'enter') + expect(report).toHaveBeenCalledTimes(2) + } finally { shell.dispose() } +}) + + +it.each(['capture', 'report'])('analytics %s failure does not interrupt a message', async (stage) => { + const sink = vi.fn(async (): Promise => ({ kind: 'success' })) + const fail = () => { throw new Error('analytics unavailable') } + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments, + captureSubmit: stage === 'capture' ? fail : () => fail }) + try { + shell.setDraft('message') + expect(() => { shell.submit('queue', 'click') }).not.toThrow() + expect(sink).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + } finally { shell.dispose() } +}) + + +it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => { + const pending = Promise.withResolvers() + const report = vi.fn() + const captureSubmit = vi.fn(() => report) + const command = vi.fn(async (): Promise => ({ kind: 'success' })) + const sink = vi.fn(async (): Promise => ({ kind: 'success' })) + const inputTriggers: InputTriggerController = { + launcher: { getSnapshot: () => null, subscribe: () => () => {} }, + lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} }, + track: () => {}, arbitrate: () => 'pass', onSpace: () => false, + serializeReference: async () => '', openReference: () => false, toggleSource: () => {}, + adjudicate: () => pending.promise, + } + const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers, + captureSubmit, defaultSink: sink, commandAttachments }) + try { + shell.setDraft('/compact') + shell.submit('queue', 'enter') + expect(report).not.toHaveBeenCalled() + pending.resolve(kind === 'handled' ? 'handled' : kind === 'claim' ? { claim: { name: 'compact', token: '/compact', submit: command } } : undefined) + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(report).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0) + expect(sink).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0) + expect(captureSubmit).toHaveBeenCalledExactlyOnceWith('queue', 'enter') + shell.setDraft('programmatic') + shell.actions.submit() + expect(captureSubmit).toHaveBeenLastCalledWith('queue', undefined) } finally { shell.dispose() } }) diff --git a/packages/client/ui-plugin-manager/src/client/install-analytics.ts b/packages/client/ui-plugin-manager/src/client/install-analytics.ts new file mode 100644 index 0000000000..f082408d3b --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/install-analytics.ts @@ -0,0 +1,13 @@ +/** Privacy-safe installation input classification shared by click and result events. */ + +/** + * Keep registry package names and plain versions; classify other installer inputs without their contents. + * @param spec - user-entered installation spec. + * @returns an identifier safe to send without URL credentials, tokens, or local paths. + */ +export function installationAnalyticsInput(spec: string): string { + if (/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*(?:@[a-z0-9.*^~+<>=| -]+)?$/iu.test(spec)) return spec + if (/^(?:git[+:]|git@|github:|gitlab:|bitbucket:)/iu.test(spec)) return '[git]' + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(spec)) return '[url]' + return '[path-or-other]' +} diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 1691c3dc76..c7a7acf3ab 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -1,3 +1,4 @@ +import { installationAnalyticsInput } from './install-analytics.ts' /** * The plugin manager's state: the Host's bundles joined with its plugin * entries, the action in flight, the install run, and the confirmation an @@ -788,8 +789,8 @@ export class PluginManagerController { const spec = install.spec.trim() if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return if (this.ctx.get('productAnalytics')?.enabled) { - this.analyticsAttempt = { input: spec, started: Date.now() } - this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: spec, plugin_type: 'bundle' }) + this.analyticsAttempt = { input: installationAnalyticsInput(spec), started: Date.now() } + this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: installationAnalyticsInput(spec), plugin_type: 'bundle' }) } // A name the list already shows is refused at once, before the Host is asked. if (state.packages.some(pkg => pkg.name === spec)) { @@ -934,7 +935,7 @@ export class PluginManagerController { const install = this.getSnapshot().install const pending = install.failure?.pendingBuilds if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return - if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: install.subject.spec, started: Date.now() } + if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: installationAnalyticsInput(install.subject.spec), started: Date.now() } await this.startInstall(install.subject, pending) } diff --git a/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts b/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts new file mode 100644 index 0000000000..0a9eb89a7c --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/install-analytics.client.spec.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest' +import { installationAnalyticsInput } from '../src/client/install-analytics.ts' + +it.each(['dsh-example', '@scope/plugin', '@scope/plugin@1.2.3', 'plugin@latest'])('retains a registry identifier: %s', (spec) => { + expect(installationAnalyticsInput(spec)).toBe(spec) +}) + +it.each([ + ['git+https://user:secret@example.invalid/org/repo.git?token=secret#secret', '[git]'], + ['https://user:secret@example.invalid/archive.tgz?signature=secret#secret', '[url]'], + ['git@example.invalid:private/repo', '[git]'], + ['/Users/private-name/plugin', '[path-or-other]'], + ['C:\\Users\\private-name\\plugin', '[path-or-other]'], + ['plugin@https://user:secret@example.invalid/archive.tgz', '[path-or-other]'], +])('removes private installer input: %s', (spec, expected) => { + expect(installationAnalyticsInput(spec)).toBe(expected) +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 46c1b0b6dc..2eec275f4c 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -71,7 +71,7 @@ const NO_CONFIG: HostObservable = { subscribe: () => () => {}, } -function bench(overrides: Partial>> = {}) { +function bench(overrides: Partial>> = {}, enabled = true) { const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } const plugins = { listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))), @@ -89,7 +89,7 @@ function bench(overrides: Partial>> = {} const probe = { fastest: overrides.fastest ?? vi.fn(() => Promise.resolve(ok(null))) } const track = vi.fn() const ctx = { - get: () => ({ enabled: true, track }), + get: () => ({ enabled, track }), configForms: { describe: () => ({ getSnapshot: () => ({ view: { namespaces: [] } }), subscribe: () => () => {} }), get: vi.fn((id: string) => `form:${id}`) }, remote: { pluginManager: plugins, pluginInventory: inventory, pluginRegistryProbe: probe }, } as never @@ -859,7 +859,7 @@ describe('PluginManagerController', () => { expect(state().highlight).toBeNull() }) - it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => { + it.each([true, false])('offers the scripts a blocked run left pending, and retries with analytics enabled=%s', async (enabled) => { const gates: ReturnType> | ReturnType>>>[] = [] const { face, state, controller, plugins, started } = bench({ installBundle: vi.fn(() => { @@ -867,7 +867,7 @@ describe('PluginManagerController', () => { gates.push(gate) return gate.promise }), - }) + }, enabled) await controller.load() face.openInstall() face.editInstallSpec('x') @@ -1417,6 +1417,16 @@ it('recognizes the official registry without a trailing slash and with uppercase describe('desktop analytics outcomes', () => { + it('redacts authenticated installer URLs from click and failed-result events', async () => { + const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'refused' })) }) + b.face.openInstall() + b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token') + b.face.runInstall() + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]', plugin_type: 'bundle' }) + expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token') + }) + it('reports input inspection failure separately from cancellation', async () => { const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'not a bundle' })) }) b.face.openInstall() @@ -1470,3 +1480,23 @@ describe('desktop analytics outcomes', () => { expect(b.track.mock.calls.filter(call => call[0] === 'plugin_toggle')).toHaveLength(2) }) }) + + +it('does not retain install identity when analytics is disabled', async () => { + const b = bench({}, false) + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_install_click' || name === 'install_plugin_result')).toEqual([]) +}) + +it('reports restart-required row toggles and tolerates a vanished inventory entry', async () => { + const b = bench({ setPluginEnabled: vi.fn(async () => ok({ ...APPLIED, application: 'restart-required' })) }) + await b.controller.load() + b.face.setRowEnabled(ROW_ENTRY, true) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', expect.objectContaining({ plugin_type: 'plugin' })) }) + b.face.setEnabled('vanished-package', true) + await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) + expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1) +}) diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index fbf7a7bd36..e8cbd0ac90 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -48,8 +48,8 @@ export function DesktopOnboarding({ const pageName = step === 'welcome' ? 'onboarding_welcome' : step === 'credit' ? 'onboarding_recharge' : step === 'purpose' ? 'onboarding_use_case' : 'onboarding_process' const shownPage = useRef(null) useEffect(() => { - const visible = state.visible && state.status !== 'loading' && step !== 'done' && !exiting - if (!visible) { shownPage.current = null; return } + if (!state.visible || step === 'done' || exiting) { shownPage.current = null; return } + if (state.status === 'loading') return if (shownPage.current === pageName) return shownPage.current = pageName track?.('onboarding_page_view', { page_name: pageName }) diff --git a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx index e499bf60f9..b4ed52eb76 100644 --- a/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx +++ b/packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx @@ -13,7 +13,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on busy: boolean canRecharge: boolean onClose: () => void - onKeepSetting?: () => void + onKeepSetting: () => void onContinue: () => void onRecharge: () => void onSkip: () => void @@ -27,7 +27,7 @@ export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, on

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 20a430fec6..f465553e57 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -271,8 +271,15 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) await c.reload(SELF) const actions = operations(c) + const track = vi.fn() + const analytics = c.ctx.get('productAnalytics')! + vi.spyOn(analytics, 'track').mockImplementation(track) + actions.track?.('auth_page_view', {}) + expect(track).toHaveBeenCalledWith('auth_page_view', {}) const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') .find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected + onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' }) + expect(track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) c.mock.remote.account.getBalance.mockResolvedValue(ok(null)) diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx index 37dfa527f0..1ee7324909 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -20,12 +20,14 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal returnFromPage = onClose return release }) + let changeStatus: (value: DesktopOnboardingState['status']) => void = () => {} const update = vi.fn(async (_change: Parameters[0]) => true) function App() { const [state, setState] = useState({ status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) + changeStatus = (value) => { setState(current => ({ ...current, status: value })) } return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) @@ -39,7 +41,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal } render() return { - complete, openPlatformPage, release, update, retry, track, + complete, openPlatformPage, release, update, retry, track, changeStatus: (value: DesktopOnboardingState['status']) => { changeStatus(value) }, /** The viewer returning through the shared host's Back action. */ back: () => { returnFromPage?.() }, } @@ -349,3 +351,13 @@ it('reports visible pages once, funded Continue, popup actions, and no close eve it('does not report the loading surface as a page', () => { expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled() }) + + +it('does not repeat page or popup exposure during save and refresh', () => { + const b = mount('credit') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + act(() => { b.changeStatus('saving') }) + act(() => { b.changeStatus('loading') }) + act(() => { b.changeStatus('ready') }) + expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1) +}) diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx index 0cf569c39a..9eebc21673 100644 --- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx @@ -178,3 +178,12 @@ it('keeps an outstanding start open until the request settles', async () => { await act(async () => { pending.resolve(undefined); await pending.promise }) expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false) }) + + +it('reports entry once across rerenders with a supplied sender', () => { + const track = vi.fn() + const props = dialogProps(null) + const view = render() + view.rerender( { track(name, attributes) }} colorScheme="light" />) + expect(track).toHaveBeenCalledExactlyOnceWith('auth_page_view', {}) +}) diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 62196478f7..2372690ba2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -141,6 +141,12 @@ describe('ui-settings-models apply', () => { }) const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')! expect(deepSeek.component).toBe(DeepSeekOnboardingDialog) + const analytics = (deepSeek.inject!() as object) as import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected + analytics.track?.('api_key_save_click', {}) + const track = vi.fn() + before.ctx.provide('productAnalytics', { track } as never) + analytics.track?.('api_key_save_click', {}) + expect(track).toHaveBeenCalledWith('api_key_save_click', {}) expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 }) const deepSeekInjected = ( deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index fbdc7d03e6..2ce1c187cf 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -46,7 +46,7 @@ export interface UiWorkspace { * Fork a Session without changing the current selection. * @param sessionId - source Session. * @param onCreated - observer before the optional child-title update. - * @returns completion after child creation and inherited-title increment. + * @returns the child SessionId after creation and inherited-title increment. */ forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise /** diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index c1ff5b44ef..95c17ea670 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -9,8 +9,8 @@ en: 7be860d52e1202db zh: a2612757253d67a6 /deepseek-ai-dsh-deepseek-account-platform/summary: - en: 6af5ed65ad6982e1 - zh: ee34205c3732739a + en: ce3da383e4b065d8 + zh: 56d68ad8ee1c27a8 /deepseek-ai-dsh-deepseek-account-platform/table-of-contents: en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 3358684d58..abaa130b40 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -19,6 +19,8 @@ Profile and balance HTTP 401 responses or top-level response code `40003` (inval Sign in through the system browser and keep the account credential in the existing local credential store. Local cancellation prevents late callbacks and exchange responses from signing the user in. +Device identity reads validate the existing login device record and expose only its ID, the current account ID, and the shared login OS version string. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 5b0c9ada91..f976a1890b 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -19,6 +19,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 +设备身份读取会校验已有登录设备记录,仅提供设备 ID、当前账户 ID 和与登录共用的操作系统版本字符串。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 90668486c7..382cd41dcf 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -343,6 +343,19 @@ export class PlatformAccount extends DeepSeekAccount { await this.removing } + override async getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> { + const [record, session] = await Promise.all([ + this.ctx.credentials.readRecord(DEVICE), + this.getPlatformSession(), + ]) + const parsed = record?.kind === 'grant' ? device.safeParse(record.payload) : undefined + return { + ...parsed?.success ? { deviceId: parsed.data.id } : {}, + ...session?.userId == null ? {} : { userId: session.userId }, + osVersion: deviceOsVersion(), + } + } + override async getPlatformSession(): Promise { const lifetime = this.detailsLifetime const stored = await this.readCurrentGrant(lifetime) @@ -569,7 +582,7 @@ export class PlatformAccount extends DeepSeekAccount { const identity = device.parse(deviceRecord.payload) const result = exchange.safeParse(await this.request('auth_exchange', { code: receivedCode, code_verifier: verifier, redirect_uri: redirectUri, - device_id: identity.id, device_model: `${platform()}-${arch()}`, os_version: `${platform()} ${release()}`, + device_id: identity.id, device_model: `${platform()}-${arch()}`, os_version: deviceOsVersion(), }, signal, attempt.clientHeaders), { reportInput: true }) if (!result.success) this.rejectPayload('auth_exchange', result.error) const completionUrl = new URL(browserUrl(result.data.authorized_url, this.origin, '/dsh/authorized', this.rewriteBrowserOrigin)) @@ -640,3 +653,8 @@ export class PlatformAccount extends DeepSeekAccount { } } export default PlatformAccount + +/** OS identification shared by login and credential-free identity reads. */ +function deviceOsVersion(): string { + return `${platform()} ${release()}` +} diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 49375797ab..938a6f30c0 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1657,3 +1657,20 @@ it('clears a completed login from snapshots while credential deletion is still s deletion.mockRestore() } }) + + +it('reads credential-free device identity without minting a device', async () => { + const f = await fixture() + expect(await f.account.getDeviceIdentity()).toEqual({ osVersion: expect.any(String) as string }) + const key = credentialKey('deepseek-account-platform', 'device') + expect(await f.ctx.credentials.readRecord(key)).toBeUndefined() + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { id: 'invalid' } })) + expect(await f.account.getDeviceIdentity()).not.toHaveProperty('deviceId') + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'api-key', key: 'private' })) + expect(await f.account.getDeviceIdentity()).not.toHaveProperty('deviceId') + const deviceId = '01990000-0000-4000-8000-000000000001' + await f.ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { id: deviceId } })) + await storeAccount(f) + await f.account.getProfile(clientMetadata()) + expect(await f.account.getDeviceIdentity()).toEqual({ deviceId, userId: 'test-user', osVersion: expect.any(String) as string }) +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index b7cbd724f6..4771c5591d 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -9,8 +9,8 @@ en: fcacd6de573d7636 zh: 6dbbc05a66dbb126 /deepseek-ai-dsh-deepseek-account/summary: - en: 68ef9cc302633361 - zh: e7222c009825046c + en: c41643b81a07a489 + zh: 2c16999de40a67b9 /deepseek-ai-dsh-deepseek-account/table-of-contents: en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index f5510f4a9f..0c134ca325 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -21,6 +21,8 @@ getUnnotifiedBonuses returns the granted bonuses Platform has not yet recorded a Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. +`getDeviceIdentity()` returns existing device and account IDs plus the login OS version string, without credentials or device creation. + ## Table of Contents - [Use this package](#use-this-package) diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index bec732e65e..24844185dc 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -21,6 +21,8 @@ getUnnotifiedBonuses 返回平台尚未记录为已展示的赠金及其所属 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 +`getDeviceIdentity()` 返回已有设备和账户 ID 以及登录使用的操作系统版本字符串,不返回凭据,也不创建设备标识。 + ## 目录 - [使用此包](#use-this-package) diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 626654b625..8fd19ecc33 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -109,6 +109,11 @@ export abstract class DeepSeekAccount extends Service { * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise + /** + * Read existing login identity without creating a device or returning credentials. + * @returns optional device/account identifiers and the provider's OS version string. + */ + abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }> } export default DeepSeekAccount diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 325cd0fb36..42d03757cd 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -351,7 +351,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', description: 'Fork a Session without changing the current selection.', parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }], - returns: 'completion after child creation and inherited-title increment.', + returns: 'the child SessionId after creation and inherited-title increment.', }, { signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index a4fb6cfef2..c9039d726f 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -24,7 +24,7 @@ describe('Client Cordis inspect catalog', () => { .toEqual([ 'openSession(target: SessionTarget): void', 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', - 'forkSession(sessionId: SessionId): Promise', + 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', 'startSession(workspaceId?: WorkspaceId): void', 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 004911c968..a7fdbc5b46 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -919,6 +919,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'a Host-only snapshot, or null while signed out or when the credential changed during the read.', }, + { + signature: 'abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>', + description: 'Read existing login identity without creating a device or returning credentials.', + parameters: [], + returns: 'optional device/account identifiers and the provider\'s OS version string.', + }, ], }, { @@ -5958,7 +5964,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProductEventMap', - declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source?: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', }, { name: 'ProductTelemetryRecord', diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml index 287dbe064f..0c330f9c0d 100644 --- a/packages/host/product-telemetry-otel/README.i18n.yaml +++ b/packages/host/product-telemetry-otel/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 9aa34fb1dc95d8cf /deepseek-ai-dsh-host-product-telemetry-otel/use-this-package: - en: 26d751042e98819b - zh: 3186108a702b348c + en: a62d2f83bd8b4186 + zh: f439a20ac0c4858e /deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation: en: 5d2c06a357a1c06a zh: 9a1a17535889f5e3 diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md index bee01e58cc..b387f3b3f4 100644 --- a/packages/host/product-telemetry-otel/README.md +++ b/packages/host/product-telemetry-otel/README.md @@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam ## Use this package -Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version. +Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_PRODUCT_ANALYTICS_APP_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version. ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md index b39912619d..dea0d39e24 100644 --- a/packages/host/product-telemetry-otel/README.zh.md +++ b/packages/host/product-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。 +在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_PRODUCT_ANALYTICS_APP_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。 ```yaml - name: '@deepseek-ai/dsh-host-product-telemetry-otel' diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 5ad261b5b0..ccfa899ffc 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -677,7 +677,7 @@ export class TestSessions implements ISessions { * @param opts - source session id, optional cut anchor, and client title policy. * @returns the source id (no child record is created). */ - fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise { + fork(opts: Parameters[0]): Promise { this.calls.push({ method: 'fork', args: [opts] }) return Promise.resolve(opts.sessionId) } From 847890a526b85e7e2b102a72ee38f3bf1c291b7b Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 19:56:50 +0800 Subject: [PATCH 009/102] test(desktop): provide launch flag parser in Host lifecycle fixture --- apps/cli/tests/desktop-host.e2e.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index e486354a33..f177adadec 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,7 +28,13 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})') + copyFileSync(fileURLToPath(new URL('../../../packages/boot/app-boot/lib/types/launch-flag.js', import.meta.url)), join(modules, 'dsh-app-boot', 'launch-flag.js')) + writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` + export { resolveLaunchFlag } from './launch-flag.js'; + export const loadProfileDirectory = () => ({ skippedBundles: [] }); + export const reportSkippedBundles = () => {}; + export const loadLayeredEnv = () => ({}); + `) writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}') writeFileSync(join(modules, 'dsh', 'profile-boot.js'), ` import { writeFileSync } from 'node:fs'; From 41c29fa8326818acdc4dab617995ec50ecd42199 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 21:03:26 +0800 Subject: [PATCH 010/102] feat(schedule): ship the Schedule switch as an optional bundle The shipped Web composition carries `time-context`, `schedule`, and `ui-schedule` disabled. This adds `@deepseek-ai/dsh-experimental-schedule-bundle`, whose patch sets `disabled: false` on those three rows and inserts none, and names it in `OPTIONAL_BUNDLES` with a runtime dependency of `apps/cli`, so every installation ships it switched off and the Web Plugins page offers it in the Official group. The two Web suites and the built-Web Schedule Remote scenario enable the rows through that bundle instead of a scenario-local fixture. --- ...-schedule-opt-in-optional-bundle.i18n.yaml | 19 ++++ ...6-09-24-schedule-opt-in-optional-bundle.md | 32 +++++++ ...9-24-schedule-opt-in-optional-bundle.zh.md | 32 +++++++ ...web-default-schedule-composition.i18n.yaml | 22 ----- ...-09-24-web-default-schedule-composition.md | 39 -------- ...-24-web-default-schedule-composition.zh.md | 39 -------- apps/cli/package.json | 1 + .../web/tests/fixtures/schedule.patch.yml | 2 - .../web/tests/schedule-timing.expected.e2e.ts | 3 +- .../time-context-every-step.patch.yml | 7 +- apps/web/tests/schedule-after.e2e.ts | 7 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/schedule.i18n.yaml | 8 +- docs/subsystems/schedule.md | 4 +- docs/subsystems/schedule.zh.md | 4 +- docs/user/guide/schedule.i18n.yaml | 4 +- docs/user/guide/schedule.md | 2 +- docs/user/guide/schedule.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 1 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 2 +- packages/context/README.zh.md | 2 +- .../context/time-context/README.i18n.yaml | 4 +- packages/context/time-context/README.md | 2 +- packages/context/time-context/README.zh.md | 2 +- packages/experimental/README.i18n.yaml | 8 +- packages/experimental/README.md | 3 +- packages/experimental/README.zh.md | 3 +- .../schedule-bundle/README.i18n.yaml | 46 +++++++++ .../experimental/schedule-bundle/README.md | 94 +++++++++++++++++++ .../experimental/schedule-bundle/README.zh.md | 94 +++++++++++++++++++ .../schedule-bundle/cordis.patch.yml | 12 +++ .../experimental/schedule-bundle/icon.svg | 11 +++ .../schedule-bundle/locale/en.json | 6 ++ .../schedule-bundle/locale/zh.json | 6 ++ .../experimental/schedule-bundle/package.json | 48 ++++++++++ .../experimental/schedule-bundle/src/index.ts | 2 + .../schedule-bundle/tests/patch.spec.ts | 56 +++++++++++ .../schedule-bundle/tsconfig.json | 15 +++ .../schedule-bundle/tsdown.config.ts | 3 + packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- pnpm-lock.yaml | 15 +++ scripts/release/families.spec.ts | 1 + tsconfig.base.json | 1 + 55 files changed, 550 insertions(+), 148 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md create mode 100644 .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md delete mode 100644 apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml create mode 100644 packages/experimental/schedule-bundle/README.i18n.yaml create mode 100644 packages/experimental/schedule-bundle/README.md create mode 100644 packages/experimental/schedule-bundle/README.zh.md create mode 100644 packages/experimental/schedule-bundle/cordis.patch.yml create mode 100644 packages/experimental/schedule-bundle/icon.svg create mode 100644 packages/experimental/schedule-bundle/locale/en.json create mode 100644 packages/experimental/schedule-bundle/locale/zh.json create mode 100644 packages/experimental/schedule-bundle/package.json create mode 100644 packages/experimental/schedule-bundle/src/index.ts create mode 100644 packages/experimental/schedule-bundle/tests/patch.spec.ts create mode 100644 packages/experimental/schedule-bundle/tsconfig.json create mode 100644 packages/experimental/schedule-bundle/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml new file mode 100644 index 0000000000..901bf1470d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-24-schedule-opt-in-optional-bundle.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md +/agent-note-schedule-as-an-opt-in-optional-bundle: + en: 8c2381fd778a9888 + zh: 8877255cb980eea1 +/agent-note-schedule-as-an-opt-in-optional-bundle/problem: + en: 26d8e4f92b6cf3c5 + zh: 48dcb89bbb4a7169 +/agent-note-schedule-as-an-opt-in-optional-bundle/decision: + en: 5edbd3d4f9007745 + zh: e12eb39513bed213 +/agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered: + en: d12affa9097a1a50 + zh: 4190d195c6a6eb8d +/agent-note-schedule-as-an-opt-in-optional-bundle/consequences: + en: 00d9287c82256837 + zh: eb85623524e59b60 diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md new file mode 100644 index 0000000000..b0c4eba715 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md @@ -0,0 +1,32 @@ +# Agent Note: Schedule as an opt-in optional bundle + +Status: implemented + +English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md) + +## Problem + +The shipped Web composition mounted `time-context`, `schedule`, and `ui-schedule`, so every Web session carried four Schedule tool schemas and one durable clock message per eligible step whether or not the deployment wanted reminders. The rows had no switch on the product surface: a deployment that wanted them off edited its own profile patch layer, and the `--patch apps/cli/config/examples/schedule/cordis.yml` overlay could only switch them on from a launch flag. + +## Decision + +`packages/bundle/web-app/cordis.patch.yml` inserts `time-context`, `schedule`, and `ui-schedule` with `disabled: true`, so the shipped Web composition mounts none of the three. The rows stay in the composition because an id-targeted patch overrides an existing row: a profile patch or a bundle sets a row's `disabled` to `false` without inserting it again. + +`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) carries three id-targeted patches in its `cordis.patch.yml`, one per row, each setting `disabled: false`; it inserts no row. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group. + +Enabling the bundle turns on `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition that carries the disabled rows. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row stays disabled; stored task records remain in the Schedule domain. + +## Alternatives considered + +**Ship Schedule on by default.** Every Web session then pays four tool schemas in each request header and one durable user message per eligible step, and a conversation that never creates a reminder pays both costs. The arrangement also left the capability without an off switch on the product surface. + +**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. It cannot re-insert the rows either: `applyEntryPatches` appends an `insert` list without de-duplicating ids, so an overlay that inserts `time-context` and `schedule` mounts each Host plugin a second time. + +**Extract the three rows out of the Web composition into the bundle.** The bundle would insert the rows instead of overriding them, so any profile that also inserts a row would mount the same Host plugin twice for the same reason. The disabled rows are what an override-style switch targets. + +## Consequences + +- A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`. +- An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message. +- The bundle changes nothing in a composition that does not carry the three Web rows, and its package declares no plugin dependency, so enabling it cannot mount a Host service twice. +- The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion. diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md new file mode 100644 index 0000000000..871bd7b52e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Schedule 作为按需开启的可选 bundle + +Status: implemented + +[English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文 + +## Problem + +发布的 Web 组合挂载了 `time-context`、`schedule` 与 `ui-schedule`,因此每个 Web 会话都会带上四个 Schedule 工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,无论该部署是否需要提醒。产品界面上没有开关:想要关闭它们的部署只能改自己的 profile patch 层,而 `--patch apps/cli/config/examples/schedule/cordis.yml` overlay 只能通过启动参数打开它们。 + +## Decision + +`packages/bundle/web-app/cordis.patch.yml` 以 `disabled: true` 插入 `time-context`、`schedule` 与 `ui-schedule`,因此随发行版交付的 Web 组合不挂载其中任何一行。这三行仍留在组合中,因为按 id 定位的 patch 覆盖的是已存在的行:profile patch 或 bundle 把某行的 `disabled` 设为 `false`,无需再次插入它。 + +`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中携带三个按 id 定位的 patch,每行一个,各自把 `disabled` 设为 `false`;它不插入任何行。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在 Official 分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。 + +启用该 bundle 会打开 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`),以及 `ui-schedule`(Session 提醒目录与自动化任务页面)。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;承载这些禁用行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行保持禁用期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。 + +## Alternatives considered + +**默认发布 Schedule。** 那样每个 Web 会话都要在每个请求头中带上四个工具 schema,并在每个符合条件的步骤追加一条持久 user 消息,从不创建提醒的对话也要承担这两项成本。这种安排还让该能力在产品界面上没有关闭开关。 + +**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。它也无法重新插入这些行:`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,插入 `time-context` 与 `schedule` 的 overlay 会把每个 Host 插件挂载两次。 + +**把这三行从 Web 组合中抽出、移入 bundle。** 这样 bundle 会插入这些行而不是覆盖它们,任何同样插入某行的 profile 都会因同样的原因把同一个 Host 插件挂载两次。按需开关针对的正是这些禁用行。 + +## Consequences + +- 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。 +- 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。 +- 在不携带这三行 Web 行的组合中,该 bundle 不改变任何东西;其包不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。 +- 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml deleted file mode 100644 index fec9a85467..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-24-web-default-schedule-composition.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md -/agent-note-schedule-in-the-shipped-web-composition: - en: 39229d7ab6ed433b - zh: 54287471d371a11c -/agent-note-schedule-in-the-shipped-web-composition/problem: - en: 2871fd5ed881c007 - zh: d03c78d402d186da -/agent-note-schedule-in-the-shipped-web-composition/decision: - en: bb19ad12b2ab0ffc - zh: 971f33e95474a36c -/agent-note-schedule-in-the-shipped-web-composition/recorded-sessions: - en: 5539a42052f4ddb7 - zh: 98cc190ae63b4549 -/agent-note-schedule-in-the-shipped-web-composition/alternatives-considered: - en: ca032a407c2ee8cb - zh: 748a4fcf2485a3da -/agent-note-schedule-in-the-shipped-web-composition/consequences: - en: 4f23bda8fda37987 - zh: 9f7c4a3538bd2979 diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md b/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md deleted file mode 100644 index 91f533950a..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Schedule in the shipped Web composition - -Status: implemented - -English | [中文](2026-09-24-web-default-schedule-composition.zh.md) - -## Problem - -The Automation tasks page, the Session reminder catalog, and the `schedule_*` tools reached a Web deployment only through `--patch apps/cli/config/examples/schedule/cordis.yml`. `packages/bundle/web-app/cordis.patch.yml` carried the `ui-schedule` client row with `disabled: true`, and the Host rows `time-context` and `schedule` existed in that overlay alone. A person running the shipped `web` profile therefore saw no Automation tasks entry and no reminder tools, while every consumer that wanted them repeated the same three rows: the repository preview image kept its own overlay list, and two Web suites hard-coded the overlay path. - -## Decision - -`packages/bundle/web-app/cordis.patch.yml` inserts `time-context` and `schedule` in its Host row list and leaves `ui-schedule` enabled; `packages/bundle/web-app/package.json` declares both packages, which `verify-cordis-config` requires for a bare row name in a bundle patch. The `web` profile consequently ships the Automation tasks page, the Session-header reminder clock, the idle Session row's clock mark and hover list, the right-Sidebar task tab, and `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on every live root Agent. - -`apps/cli/config/examples/schedule/cordis.yml` is deleted. `applyEntryPatches` appends an `insert` list without de-duplicating ids, so keeping the overlay would mount `time-context` and `schedule` a second time; the two Web suites and the preview packer that named the overlay compose the shipped profile alone. - -`time-context` ships with Schedule because a reminder request states a wall-clock target. The plugin appends one durable user message per eligible step carrying the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. The sampling instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value; the [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns that interpretation boundary and the explicit-zone requirement it feeds. - -The Host service, its storage domain, and the client half are unchanged. This decision moves which composition mounts them, and the [Host-owned scheduled messages decision](2026-09-16-host-schedule-storage.md) continues to own task storage, activation, dispatch, and delivery records. - -## Recorded sessions - -A Web scenario that drives a live step now logs one time-context reading per step, so its committed Session fixture and header sidecars were refreshed. A reading's sampled instant, browser zone, and elapsed duration are volatile, and `normalizeWebSessionVolatiles` in `apps/web/tests/scaffold.ts` replaces them with `{{timeContextTimestamp}}`, `{{clientTimeZone}}`, and `{{elapsed}}`. The turn and step numbers and the preceding-event baseline stay readable, so a fixture still shows what the model received. Refresh writes the current writer generation (`session.v4.jsonl`) beside the retained predecessors, which remain committed replay baselines. - -## Alternatives considered - -**Keep the overlay and change nothing.** The shipped surface would keep contradicting the product documentation that describes the Automation tasks entry, and each consumer would keep its own copy of the three rows. The overlay also cannot satisfy the request that the default `dsh web` process exposes the page without a flag. - -**Ship the Schedule rows without `time-context`.** The model would then have no sampling instant of its own, so an unqualified date or time in a reminder request would depend on a separate clock source; the overlay paired the two rows for that reason. - -**Reach for a separate optional bundle.** `OPTIONAL_BUNDLES` exists for bundles a person switches on from the plugin manager, which is the same opt-in shape as the overlay; the decision here is that a default Web surface owns the page and the tools. - -## Consequences - -- Every Web session's request header carries four additional tool schemas, and every eligible step appends one durable user message. A conversation that never creates a reminder pays that token cost. -- The clock reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message. -- A deployment that wants the previous behavior disables the `time-context`, `schedule`, and `ui-schedule` rows in its own profile patch layer; the capability itself is untouched. -- The `cordis_inspect_query` `listTools` answer for a full preset table now passes the base composition's 12,500-token inline budget, so the spill policy retains that answer's head and tail with a spill path instead of the complete JSON. -- The repository preview image and both Web suites drop their overlay arguments, so one composition change reaches every surface at once. diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md b/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md deleted file mode 100644 index af561fe63e..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 默认 Web 组合中的 Schedule - -Status: implemented - -[English](2026-09-24-web-default-schedule-composition.md) | 中文 - -## Problem - -自动化任务页面、Session 提醒目录以及 `schedule_*` 工具此前只能通过 `--patch apps/cli/config/examples/schedule/cordis.yml` 进入某个 Web 部署:`packages/bundle/web-app/cordis.patch.yml` 里的 `ui-schedule` 客户端行带 `disabled: true`,而宿主行 `time-context` 与 `schedule` 只存在于那个 overlay 中。因此运行发布版 `web` profile 的人既看不到自动化任务入口,也拿不到提醒工具;同时每个需要它们的消费者都要重复同样的三行:仓库预览镜像自行维护了一份 overlay 列表,两个 Web 测试套件各自硬编码了 overlay 路径。 - -## Decision - -`packages/bundle/web-app/cordis.patch.yml` 在其宿主行列表中插入 `time-context` 与 `schedule`,并让 `ui-schedule` 保持启用;`packages/bundle/web-app/package.json` 声明这两个包,这是 `verify-cordis-config` 对 bundle patch 中裸包名的要求。因此 `web` profile 会交付自动化任务页面、Session 页头的提醒时钟、空闲 Session 行的时钟标记与悬停列表、右侧栏任务页签,并在每个 live 根 Agent 上注册 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`。 - -`apps/cli/config/examples/schedule/cordis.yml` 已删除。`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,保留该 overlay 会把 `time-context` 与 `schedule` 挂载两次;原本引用它的两个 Web 测试套件与预览打包器现在只组合发布版 profile。 - -`time-context` 随 Schedule 一起发布,因为提醒请求本身给出的是墙上时钟目标。该插件在每个符合条件的步骤追加一条持久 user 消息,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。正是采样瞬时让模型能把「明天九点」这类请求落成带偏移量的 `at` 值;该解释边界及其要求的显式时区由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责。 - -宿主服务、其存储 domain 与客户端半边都没有改动。本决策改变的是由哪个组合挂载它们,任务存储、激活、投递与投递记录仍由[宿主定时消息决策](2026-09-16-host-schedule-storage.zh.md)负责。 - -## Recorded sessions - -驱动 live 步骤的 Web 场景现在每步记录一条 time-context 读数,因此其已提交的 Session 夹具与页头 sidecar 都已重刷。一条读数中的采样瞬时、浏览器时区与经过时长是易变值,`apps/web/tests/scaffold.ts` 的 `normalizeWebSessionVolatiles` 会把它们替换为 `{{timeContextTimestamp}}`、`{{clientTimeZone}}` 和 `{{elapsed}}`。turn 与 step 序号以及"前一条事件"的基线保持可读,因此夹具仍能显示模型收到的内容。重刷会在已保留的上一代旁写入当前 writer 代次(`session.v4.jsonl`),旧代次继续作为已提交的回放基线保留。 - -## Alternatives considered - -**保留 overlay,不做任何改动。** 发布版界面会继续与描述该自动化任务入口的产品文档相矛盾,而每个消费者都要各自保留那三行的副本。overlay 也无法满足「默认 `dsh web` 进程不带 flag 就暴露该页面」这一要求。 - -**只发布 Schedule 两行,不带 `time-context`。** 模型将没有自己的采样瞬时,于是提醒请求中未明确限定时区的日期或时间要依赖另一个时钟来源;overlay 当初正是为此把两行配对。 - -**另建一个可选 bundle。** `OPTIONAL_BUNDLES` 面向的是用户在插件管理器里主动开启的 bundle,与 overlay 是同一种 opt-in 形态;本决策要的是默认 Web 界面自己拥有该页面与这些工具。 - -## Consequences - -- 每个 Web 会话的请求头多出四个工具 schema,且每个符合条件的步骤追加一条持久 user 消息。从不创建提醒的对话也要承担这份 token 成本。 -- 时钟读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。 -- 想要恢复旧行为的部署,可在自己的 profile patch 层禁用 `time-context`、`schedule` 与 `ui-schedule` 三行;能力本身没有改动。 -- 完整预设表的 `cordis_inspect_query` `listTools` 答案现在超过基础组合 12,500 token 的内联预算,因此 spill 策略改为保留该答案的首尾并给出 spill 路径,而不是完整 JSON。 -- 仓库预览镜像与两个 Web 测试套件都不再传 overlay 参数,一处组合改动即同时作用于所有界面。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 33eac62c0b..7e9fb3286b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", + "@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", diff --git a/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml b/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml deleted file mode 100644 index fd53f05e15..0000000000 --- a/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml +++ /dev/null @@ -1,2 +0,0 @@ -- id: schedule - disabled: false diff --git a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts index 0cf380636f..a6846024da 100644 --- a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts @@ -8,7 +8,8 @@ import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh import { expect, it } from 'vitest' import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts' -const patches = [fileURLToPath(new URL('./fixtures/schedule.patch.yml', import.meta.url))] +/** The optional Schedule bundle is the switch this built-Web scenario turns on. */ +const patches = [fileURLToPath(new URL('../../../../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url))] const id = 'retained-remote-task' as DailyScheduleRecord['id'] const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId'] diff --git a/apps/web/tests/fixtures/time-context-every-step.patch.yml b/apps/web/tests/fixtures/time-context-every-step.patch.yml index dcff9f2b61..7a1937d44d 100644 --- a/apps/web/tests/fixtures/time-context-every-step.patch.yml +++ b/apps/web/tests/fixtures/time-context-every-step.patch.yml @@ -1,9 +1,6 @@ # Browser-zone assertions need a fresh reading after the non-browser preparation turn. +# The Schedule rows stay disabled in the shipped composition; the scenario enables them +# with the shipped `@deepseek-ai/dsh-experimental-schedule-bundle` patch. - id: time-context - disabled: false config: refreshIntervalMs: 0 -- id: schedule - disabled: false -- id: ui-schedule - disabled: false diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 84cd580b8b..c0f131f3d3 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -44,6 +44,9 @@ declare module '@deepseek-ai/dsh-llm' { } const MODE = webSnapshotMode() +/** The optional Schedule bundle is the switch these scenarios turn on. */ +const SCHEDULE_BUNDLE = fileURLToPath(new URL('../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url)) +const TIME_CONTEXT_EVERY_STEP = fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/schedule-after', import.meta.url)) const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md') const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md') @@ -272,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - extraOverlayPath: fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)), + extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP], }) scaffold.ctx.effect( () => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter), @@ -710,7 +713,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { beforeAll(async () => { const fixture = await readFile(CATALOG_FIXTURE, 'utf8') scaffold = await launchWebScaffold({ - extraOverlayPath: fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)), + extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP], }) await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard') const records = foldScheduleEvents(fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)).active diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7978a4afaf..da7af50cea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4424,6 +4424,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | | `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | +| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) | | `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 12383e538d..41b92912b4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4426,6 +4426,7 @@ export interface Config { | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | | `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | +| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) | | `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index b005413ba3..95370f261d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -258,6 +258,7 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_experimental_schedule_bundle["experimental-schedule-bundle"] pkg_experimental_speech_to_text["experimental-speech-to-text"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] @@ -1452,6 +1453,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | +| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — | | [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index fe41849447..fa7306530d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -260,6 +260,7 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_experimental_schedule_bundle["experimental-schedule-bundle"] pkg_experimental_speech_to_text["experimental-speech-to-text"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] @@ -1454,6 +1455,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | +| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — | | [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index d6d12d5a95..13c6d192fe 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -9,8 +9,8 @@ en: 41cd197b5c74bfa3 zh: 18ed11259036d726 /host-wide-schedule/absolute-time-input: - en: e730fda52ba2854f - zh: 01a6c682e327a133 + en: 0876a14a60ba2160 + zh: e7d4bd9099c14fb2 /host-wide-schedule/daily-wall-clock-input: en: 2d249edc3dfccb8a zh: 33fecda132b03b84 @@ -27,8 +27,8 @@ en: 0656173de50ee132 zh: 54176540371d0f28 /host-wide-schedule/web-catalog: - en: 39f24636dd788034 - zh: ba3ff541fee5854a + en: 60c88b08abde9bb0 + zh: 0a3644cfa86d014d /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 0784c0b92a..571084d0ae 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -116,7 +116,7 @@ interface LocalAtInput { type AtInput = string | LocalAtInput ``` -The shipped Web bundle mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context. +The shipped Web composition carries the `time-context` row disabled; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context. Schedule rejects invalid offsets and zones, offset-free strings, non-future targets, and local times inside daylight-saving gaps. A daylight-saving overlap chooses its first, earlier instant. Successful creation stores only canonical UTC `scheduledAt`, so replay never depends on ambient time-zone state. @@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. -The Web bundle mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. +The shipped Web composition carries the `ui-schedule` row disabled; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. ## Timing edits diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index d1a726fa97..bd210a11a2 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -116,7 +116,7 @@ interface LocalAtInput { type AtInput = string | LocalAtInput ``` -发布的 Web bundle 会挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。 +随发行版交付的 Web 组合把 `time-context` 行保持禁用;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。 Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。 @@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 -Web bundle 将 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 +随发行版交付的 Web 组合把 `ui-schedule` 行保持禁用;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会将它与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 ## 修改时间 diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index a6033b0b82..1bf88dec7c 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -12,8 +12,8 @@ en: b3b2a6e0ce02bdee zh: 2ddb168de79fd19c /schedule-reminders/create-reminders: - en: 70ae56895847c453 - zh: 19b38057c7462123 + en: c76ee99f59529942 + zh: be1d75c226231dd7 /schedule-reminders/inspect-and-delete-tasks: en: a44fc1aabc2ab957 zh: 31f444d4c7fd5c7a diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index b0058b0cba..abb86b7f8e 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e ## Create reminders -The shipped Web profile mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. +The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Scheduling and time context` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index 60829d82ae..bd10a06fe5 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -17,7 +17,7 @@ ## 创建提醒 -发布的 Web profile 会挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 +发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在 Official 分组中启用“定时与时间上下文”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index da580c762e..e17e125a75 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -214,6 +214,7 @@ export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', '@deepseek-ai/dsh-experimental-auto-review', + '@deepseek-ai/dsh-experimental-schedule-bundle', ] const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index c958afc209..eddc2d76d7 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -21,8 +21,8 @@ en: b7ab01cfe9fed40f zh: 23703ab7727c3ab5 /deepseek-ai-dsh-web-app/use-this-package/configuration: - en: 4f18797d9de48038 - zh: 62387505417e4813 + en: cea7ecccb2a03b79 + zh: 0af72c22e46045d3 /deepseek-ai-dsh-web-app/use-this-package/lan-access-and-trusted-hosts: en: 2522c806db384ddc zh: 7e96f11194af9f9f diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index a798b7f506..4f5b8f6e7c 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -51,7 +51,7 @@ Most users never set these; the command-line flags feed the four settings below | `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands | | `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition disables `schedule`, `ui-schedule`, and `time-context` by default. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition disables `schedule`, `ui-schedule`, and `time-context` by default, and the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` switches those three rows on from the Plugins page. ### LAN access and trusted hosts diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 9ea8e7e868..97010b39e5 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -51,7 +51,7 @@ dsh --profile web --no-open --port 8080 | `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 | | `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合默认禁用 `schedule`、`ui-schedule` 和 `time-context`。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合默认禁用 `schedule`、`ui-schedule` 和 `time-context`,可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 可在插件管理页把这三行打开。 ### LAN 访问与可信主机 diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 9797b69beb..ec785c3dda 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 3594ab6704cbd7b9 - zh: 99a965f43187501b + en: bf8096e6e576ac07 + zh: 1936c0a75dc5cd4b /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 577e9e4ccc..54b0b172b5 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web bundle mounts `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition carries the `ui-schedule` row disabled; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 743d8a5525..4a4fab6b96 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -发布的 Web bundle 将 `ui-schedule` 与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 行保持禁用;在插件管理页(Official 分组)启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可将 `ui-schedule` 与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index cbf433cd29..0aa51cfac3 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -9,8 +9,8 @@ en: 05a169f23a64b7e4 zh: cf57e471b7f13509 /context-request-context-plugins/summary: - en: 4a48e859cbeca00f - zh: 312b8621ba74a3ec + en: c301d0f01494ac06 + zh: 00e51efcde0d1267 /context-request-context-plugins/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 diff --git a/packages/context/README.md b/packages/context/README.md index 6e6d38a5f8..3ddd0c5105 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The context group provides plugins that add model-visible context to each request without defining any tool: workspace instruction files become guidance, `@file` mentions offer path completion, other sessions can be referenced as bounded snapshots, and the model can see the current time and the agent's tmux location. Only `agent-instructions` (`dsh-base`) and `time-context` (`dsh-web-app`) ship by default; the rest are opt-in. Context is durable: injected instructions and references enter session history as user-role messages, so they persist, replay, and compact like other conversation content. This page maps the group; each package README owns the per-package contract. +The context group provides plugins that add model-visible context to each request without defining any tool: workspace instruction files become guidance, `@file` mentions offer path completion, other sessions can be referenced as bounded snapshots, and the model can see the current time and the agent's tmux location. Only `agent-instructions` (`dsh-base`) ships mounted by default; the rest are opt-in. Context is durable: injected instructions and references enter session history as user-role messages, so they persist, replay, and compact like other conversation content. This page maps the group; each package README owns the per-package contract. ## Table of Contents diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 2392f1c644..98b4133036 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -context 组提供不定义任何工具、为每次请求添加模型可见上下文的插件:工作区指令文件成为指引,`@file` 提及提供路径补全,其他会话可以作为有界快照被引用,模型还能看到当前时间与 agent(智能体)的 tmux 位置。默认只包含 `agent-instructions`(`dsh-base`)与 `time-context`(`dsh-web-app`);其余全部需主动启用。上下文是持久的:注入的指令与引用以用户角色消息的形式进入会话历史,因此与其他对话内容一样持久保留、可回放、可压缩。本页概述本组;包级约定由各包 README 负责。 +context 组提供不定义任何工具、为每次请求添加模型可见上下文的插件:工作区指令文件成为指引,`@file` 提及提供路径补全,其他会话可以作为有界快照被引用,模型还能看到当前时间与 agent(智能体)的 tmux 位置。默认只挂载 `agent-instructions`(`dsh-base`);其余全部需主动启用。上下文是持久的:注入的指令与引用以用户角色消息的形式进入会话历史,因此与其他对话内容一样持久保留、可回放、可压缩。本页概述本组;包级约定由各包 README 负责。 ## 目录 diff --git a/packages/context/time-context/README.i18n.yaml b/packages/context/time-context/README.i18n.yaml index 770857eb2c..526a79b8d9 100644 --- a/packages/context/time-context/README.i18n.yaml +++ b/packages/context/time-context/README.i18n.yaml @@ -9,8 +9,8 @@ en: ec051915b3d81cda zh: 3a0ef832e3a5e419 /deepseek-ai-dsh-time-context/summary: - en: e4e308b9fd3fcd56 - zh: 9fb4d40ebe621d6a + en: 8160d6d20a8bc98c + zh: b7010a1edf57079b /deepseek-ai-dsh-time-context/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 7bfb2b1606..01af143af1 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-time-context` gives the model a clock: on eligible steps it appends a durable, source-attributed reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. It helps the model interpret otherwise-unqualified dates and times in the user's browser zone, and tells it to ask when current-turn browser zones are mixed or missing. The shipped Web bundle mounts it with Schedule. Readings default to a 10-minute minimum interval; `refreshIntervalMs: 0` injects at every eligible step. +`dsh-time-context` gives the model a clock: on eligible steps it appends a durable, source-attributed reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. It helps the model interpret otherwise-unqualified dates and times in the user's browser zone, and tells it to ask when current-turn browser zones are mixed or missing. The shipped Web composition carries its row disabled; the optional `@deepseek-ai/dsh-experimental-schedule-bundle` mounts it with Schedule from the Plugins page. Readings default to a 10-minute minimum interval; `refreshIntervalMs: 0` injects at every eligible step. ## Table of Contents diff --git a/packages/context/time-context/README.zh.md b/packages/context/time-context/README.zh.md index 84b18bc96b..95afe6acb0 100644 --- a/packages/context/time-context/README.zh.md +++ b/packages/context/time-context/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-time-context` 给模型一只时钟:在符合条件的步骤上,它追加一条持久、带来源的读数,包含当前时间、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。它帮助模型按用户的浏览器时区解释未明确限定时区的日期与时间;时区来源混杂或缺失时,它告诉模型去询问。发布的 Web bundle 会随 Schedule 一起挂载它。读数默认采用 10 分钟的最小间隔;`refreshIntervalMs: 0` 会在每个符合条件的步骤注入。 +`dsh-time-context` 给模型一只时钟:在符合条件的步骤上,它追加一条持久、带来源的读数,包含当前时间、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。它帮助模型按用户的浏览器时区解释未明确限定时区的日期与时间;时区来源混杂或缺失时,它告诉模型去询问。随发行版交付的 Web 组合把该行保持禁用;可选的 `@deepseek-ai/dsh-experimental-schedule-bundle` 在插件管理页会随 Schedule 一起挂载它。读数默认采用 10 分钟的最小间隔;`refreshIntervalMs: 0` 会在每个符合条件的步骤注入。 ## 目录 diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index c3c5329e44..a979c5d99b 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -9,14 +9,14 @@ en: 18bc48bb66b3e516 zh: 51f2340a5c921f36 /packages-experimental/summary: - en: 9341a408afcd5dfa - zh: 07541055b5f41ff8 + en: 882aada84ecc5413 + zh: daedbca3726e6ac2 /packages-experimental/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /packages-experimental/packages: - en: ad10945dac9dbb51 - zh: 245b454406ce99f8 + en: 74635f0629fe1e84 + zh: 6119c5bbc6b807ac /packages-experimental/related-documentation: en: 31282a28dd828c81 zh: ebb1d3cacdc1bd6d diff --git a/packages/experimental/README.md b/packages/experimental/README.md index a49181f7de..32411baad8 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The experimental group contains prototype capabilities whose contracts can change and carry no support promise. All current packages publish under their `@deepseek-ai/dsh-experimental-*` names, including the opt-in Agent Teams composition, Auto review, Cua Driver providers, browser-use backends, cross-realm Inspector, CPython PTC backend, and browser-worker preview libraries. Released products outside this group must not depend on experimental packages. The dsh installation ships the Agent Teams, voice input, and Auto review packages as optional bundles switched on from the Web sidebar's Plugins page ([decision](../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)); the other packages are libraries or explicit compositions. +The experimental group contains prototype capabilities whose contracts can change and carry no support promise. All current packages publish under their `@deepseek-ai/dsh-experimental-*` names, including the opt-in Agent Teams composition, Auto review, Cua Driver providers, browser-use backends, cross-realm Inspector, CPython PTC backend, and browser-worker preview libraries. Released products outside this group must not depend on experimental packages. The dsh installation ships the Agent Teams, voice input, Auto review, and Schedule packages as optional bundles switched on from the Web sidebar's Plugins page ([decision](../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)); the other packages are libraries or explicit compositions. ## Table of Contents @@ -33,6 +33,7 @@ The experimental group contains prototype capabilities whose contracts can chang | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | | [`auto-review`](auto-review/README.md) | Explicit Web layer for same-model review before each native or PTC inner tool call | — | +| [`schedule-bundle`](schedule-bundle/README.md) | Opt-in Schedule and time-context switch over the shipped Web composition | — | | [`ptc-runtime-python`](ptc-runtime-python/README.md) | CPython subprocess backend for the PTC execution seam | `ctx.ptcRuntime` | | [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.md) | Use an installed Cua Driver through MCP | `ctx.computerUse` | | [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.md) | Embed the Cua Driver native npm runtime | `ctx.computerUse` | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 68b905673f..85c609b043 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -实验组包含约定可能变更且不提供支持承诺的原型能力。所有当前包都以 `@deepseek-ai/dsh-experimental-*` 名称发布,包括显式启用的 Agent Teams 组合、Auto review、Cua Driver 提供方、浏览器操作后端、跨 realm Inspector、CPython PTC 后端与浏览器 worker 预览库。组外已发布产品不得依赖实验性包。dsh 安装将 Agent Teams、语音输入与 Auto review 包作为可选 bundle 一起发布,可从 Web 侧边栏“插件”页启用([决策](../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md));其余包是库或显式组合。 +实验组包含约定可能变更且不提供支持承诺的原型能力。所有当前包都以 `@deepseek-ai/dsh-experimental-*` 名称发布,包括显式启用的 Agent Teams 组合、Auto review、Cua Driver 提供方、浏览器操作后端、跨 realm Inspector、CPython PTC 后端与浏览器 worker 预览库。组外已发布产品不得依赖实验性包。dsh 安装将 Agent Teams、语音输入、Auto review 与定时包作为可选 bundle 一起发布,可从 Web 侧边栏“插件”页启用([决策](../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md));其余包是库或显式组合。 ## 目录 @@ -33,6 +33,7 @@ kind: "package-group" | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | | [`auto-review`](auto-review/README.zh.md) | 显式 Web 层,在每个原生或 PTC inner 工具调用前使用同一模型审查 | — | +| [`schedule-bundle`](schedule-bundle/README.zh.md) | 在出厂 Web 组合上按需开启定时与时间上下文的开关 | — | | [`ptc-runtime-python`](ptc-runtime-python/README.zh.md) | PTC 执行 seam 的 CPython 子进程后端 | `ctx.ptcRuntime` | | [`computer-use-cua-driver-mcp`](computer-use-cua-driver-mcp/README.zh.md) | 通过 MCP 使用已安装的 Cua Driver | `ctx.computerUse` | | [`computer-use-cua-driver-native`](computer-use-cua-driver-native/README.zh.md) | 嵌入 Cua Driver 原生 npm 运行时 | `ctx.computerUse` | diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml new file mode 100644 index 0000000000..a07b7aa5b1 --- /dev/null +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/schedule-bundle/README.md +/: + en: 7504834d27adc717 + zh: 464587026083c661 +/deepseek-ai-dsh-experimental-schedule-bundle: + en: d7b8b6126df4e3c4 + zh: 546d528efd4edbde +/deepseek-ai-dsh-experimental-schedule-bundle/summary: + en: 832752e963df6177 + zh: 30d1d292230a17c1 +/deepseek-ai-dsh-experimental-schedule-bundle/table-of-contents: + en: d152484eb41ac6b4 + zh: 364c80ee00a374ff +/deepseek-ai-dsh-experimental-schedule-bundle/use-this-package: + en: ffaa4b18e10a1df9 + zh: b675fd8195ebb057 +/deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: + en: 9bb32d6e6930f0c2 + zh: d81683d7bb1f6903 +/deepseek-ai-dsh-experimental-schedule-bundle/further-exploration: + en: 99a29dddf9805540 + zh: 13a6df3ee31abfc1 +/deepseek-ai-dsh-experimental-schedule-bundle/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-experimental-schedule-bundle/model-experience/reminder-tools-and-clock-readings: + en: c7fec3a37d945018 + zh: 164346dc63d188dd +/deepseek-ai-dsh-experimental-schedule-bundle/model-experience/reminder-tools-and-clock-readings/what-the-model-sees: + en: fb1906621d02c594 + zh: 394dc933ed63b349 +/deepseek-ai-dsh-experimental-schedule-bundle/model-experience/reminder-tools-and-clock-readings/token-effect: + en: 7c44bfc062010b7b + zh: 15f431db31ad53eb +/deepseek-ai-dsh-experimental-schedule-bundle/model-experience/reminder-tools-and-clock-readings/kv-cache-effect: + en: 0b01d8c40b422f5b + zh: 57fede4db59a62b8 +/deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work: + en: d733f4e0f983de86 + zh: 7ffb3d3fe7f77a7b +/deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work/dev-note: + en: 0225ba087c5266f3 + zh: 4e706f1b2b984e63 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md new file mode 100644 index 0000000000..10f254bc11 --- /dev/null +++ b/packages/experimental/schedule-bundle/README.md @@ -0,0 +1,94 @@ +--- +description: "Enable the shipped Schedule services, reminder catalog, and Automation tasks page from the plugin manager." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-schedule-bundle + +English | [中文](README.zh.md) + +## Summary + +This optional bundle switches on the three Schedule rows that `@deepseek-ai/dsh-web-app` inserts with `disabled: true`: `time-context`, `schedule`, and `ui-schedule`. Its patch appends no row and declares no plugin dependency, so enabling it cannot mount a Host service twice. Shipped profiles leave it switched off. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Open Plugins in the Web sidebar and enable Scheduling and time context, marked by a clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the right sidebar shows the Automation tasks page, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk. + +----- + + +## Understand the implementation + +
+Maintainer details — click to expand + +`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the profile's `dsh.profile.bundles` list, and the profile launcher applies the bundle layers after the Web layer. No runtime invariant companion is published, because this configuration-only package owns no mutable runtime state. + +| File | Role | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | Three `disabled: false` patches over the shipped Web rows | +| [`locale/en.json`](locale/en.json), [`locale/zh.json`](locale/zh.json) | Plugin-manager title and description | +| [`icon.svg`](icon.svg) | Plugin-manager icon | +| [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | + +
+ +----- + + +## Further Exploration + +- [Schedule subsystem](../../../docs/subsystems/schedule.md) — durable tasks, occurrence resolution, and delivery. +- [Schedule service](../../schedule/schedule/README.md) — Host task storage, activation, and the record format. +- [Web bundle](../../bundle/web-app/README.md) — the composition that carries these rows. + +----- + + +## Model Experience + +### Reminder tools and clock readings + +#### What the model sees + +A live root Agent gains `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`. `time-context` appends one durable user message per eligible step carrying the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. + +#### Token effect + +Selecting the bundle adds four Schedule tool schemas to every live root Agent request and one durable clock reading per eligible step; a conversation that never creates a reminder pays both. + +#### KV Cache effect + +The tool schemas change the request prefix once when the bundle mounts; each appended reading is new visible content after that prefix, so existing entries are not invalidated. + +## Known Limitations and Deferred Work + + + +- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. A profile without that bundle carries no such rows, so this bundle changes nothing there. +- Selecting the bundle adds the four Schedule tool schemas to every live root Agent and one clock reading per eligible step. A conversation that never creates a reminder still pays both costs. + +----- + + +### Dev Note + +
+Maintainer details — click to expand + +None. + +
diff --git a/packages/experimental/schedule-bundle/README.zh.md b/packages/experimental/schedule-bundle/README.zh.md new file mode 100644 index 0000000000..747137bc57 --- /dev/null +++ b/packages/experimental/schedule-bundle/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "从插件管理页启用出厂的定时服务、提醒目录与自动化任务页面。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-schedule-bundle + +[English](README.md) | 中文 + +## 概述 + +此可选 Bundle 打开 `@deepseek-ai/dsh-web-app` 以 `disabled: true` 插入的三个定时条目:`time-context`、`schedule` 与 `ui-schedule`。它的补丁不追加条目,也不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。随包配置默认禁用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +打开 Web 侧栏的插件管理页并启用带时钟图标的“定时与时间上下文”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,右侧栏显示自动化任务页面,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。 + +----- + + +## 理解实现 + +
+维护者信息 — 点击展开 + +`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表,profile 启动器在 Web 层之后应用各 Bundle 层。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。 + +| 文件 | 作用 | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | 针对出厂 Web 条目的三个 `disabled: false` 补丁 | +| [`locale/en.json`](locale/en.json)、[`locale/zh.json`](locale/zh.json) | 插件管理页的标题与描述 | +| [`icon.svg`](icon.svg) | 插件管理页图标 | +| [`src/index.ts`](src/index.ts) | 空的模块入口;补丁即运行时内容 | + +
+ +----- + + +## 进一步探索 + +- [定时子系统](../../../docs/subsystems/schedule.zh.md) — 持久任务、发生时刻解析与投递。 +- [定时服务](../../schedule/schedule/README.zh.md) — Host 任务存储、激活与记录格式。 +- [Web Bundle](../../bundle/web-app/README.zh.md) — 携带这些条目的组合。 + +----- + + +## 模型体验 + +### 提醒工具与时钟读数 + +#### 模型看到的内容 + +活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`。`time-context` 在每个符合条件的步骤追加一条持久用户消息,携带采样时刻、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。 + +#### Token 影响 + +启用该 bundle 会给每个活跃根 Agent 请求增加四个定时工具的 schema,并为每个符合条件的步骤增加一条持久时钟读数;即使对话从不创建提醒也会承担这两项开销。 + +#### KV 缓存影响 + +四个工具的 schema 在 bundle 挂载时改变一次请求前缀;每条追加的读数都是该前缀之后的新可见内容,因此不会使已有条目失效。 + +## 已知限制与后续工作 + + + +- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。没有该 Bundle 的 profile 不含这些条目,此 Bundle 在那里不产生变化。 +- 选中此 Bundle 会给每个活跃根智能体增加四个定时工具的 schema,并为每个符合条件的步骤增加一条时钟读数。即使对话从不创建提醒,也会承担这两项开销。 + +----- + + +### 开发备注 + +
+维护者信息 — 点击展开 + +无。 + +
diff --git a/packages/experimental/schedule-bundle/cordis.patch.yml b/packages/experimental/schedule-bundle/cordis.patch.yml new file mode 100644 index 0000000000..3ae14343f2 --- /dev/null +++ b/packages/experimental/schedule-bundle/cordis.patch.yml @@ -0,0 +1,12 @@ +# Experimental Schedule switch over the shipped Web composition. `dsh-web-app` +# inserts the three rows with `disabled: true`; this layer flips them on and +# appends nothing, so enabling it cannot mount a Host service twice. + +- id: time-context + disabled: false + +- id: schedule + disabled: false + +- id: ui-schedule + disabled: false diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg new file mode 100644 index 0000000000..4299a86395 --- /dev/null +++ b/packages/experimental/schedule-bundle/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/experimental/schedule-bundle/locale/en.json b/packages/experimental/schedule-bundle/locale/en.json new file mode 100644 index 0000000000..f748924183 --- /dev/null +++ b/packages/experimental/schedule-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Scheduling and time context", + "description": "Enable durable reminders, the Automation tasks page, and per-step clock context." + } +} diff --git a/packages/experimental/schedule-bundle/locale/zh.json b/packages/experimental/schedule-bundle/locale/zh.json new file mode 100644 index 0000000000..47abb9b169 --- /dev/null +++ b/packages/experimental/schedule-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "定时与时间上下文", + "description": "启用持久提醒、自动化任务页面与逐步时钟上下文。" + } +} diff --git a/packages/experimental/schedule-bundle/package.json b/packages/experimental/schedule-bundle/package.json new file mode 100644 index 0000000000..ba15cc8922 --- /dev/null +++ b/packages/experimental/schedule-bundle/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-experimental-schedule-bundle", + "description": "Optional bundle that switches the shipped Schedule, time-context, and reminder-catalog rows on", + "version": "0.1.7-rc.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/schedule-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "js-yaml": "^4.2.0" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/packages/experimental/schedule-bundle/src/index.ts b/packages/experimental/schedule-bundle/src/index.ts new file mode 100644 index 0000000000..bebbdde0ec --- /dev/null +++ b/packages/experimental/schedule-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** Experimental Schedule composition switch; the patched rows live in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/schedule-bundle/tests/patch.spec.ts b/packages/experimental/schedule-bundle/tests/patch.spec.ts new file mode 100644 index 0000000000..0e61e1c91d --- /dev/null +++ b/packages/experimental/schedule-bundle/tests/patch.spec.ts @@ -0,0 +1,56 @@ +/** The experimental Schedule switch must flip exactly the three rows the Web bundle inserts. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +interface Manifest { + name?: string + icon?: string + private?: boolean + publishConfig?: { access?: string } + exports?: Record + dependencies?: Record + dsh?: { bundle?: { patch?: string } } +} + +interface Patch { + id?: string + name?: string + disabled?: boolean + insert?: unknown[] +} + +describe('experimental Schedule bundle', () => { + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as Manifest + + it('publishes as an experimental bundle with plugin-manager display metadata', () => { + expect(manifest.name).toBe('@deepseek-ai/dsh-experimental-schedule-bundle') + expect(manifest.private).toBeUndefined() + expect(manifest.publishConfig?.access).toBe('public') + expect(manifest.icon).toBe('./icon.svg') + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.exports?.['./locale/*.json']).toBe('./locale/*.json') + expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') + // The patch only overrides rows another layer inserts, so it depends on no plugin package. + expect(manifest.dependencies).toBeUndefined() + }) + + it('flips the three shipped rows on and appends none', () => { + const parsed = yaml.load(readFileSync(resolve(root, './cordis.patch.yml'), 'utf8'), { schema: entryListSchema }) + expect(parsed).toEqual([ + { id: 'time-context', disabled: false }, + { id: 'schedule', disabled: false }, + { id: 'ui-schedule', disabled: false }, + ]) + for (const patch of parsed as Patch[]) { + expect(patch.insert).toBeUndefined() + expect(patch.name).toBeUndefined() + } + }) +}) diff --git a/packages/experimental/schedule-bundle/tsconfig.json b/packages/experimental/schedule-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/schedule-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/experimental/schedule-bundle/tsdown.config.ts b/packages/experimental/schedule-bundle/tsdown.config.ts new file mode 100644 index 0000000000..be29690579 --- /dev/null +++ b/packages/experimental/schedule-bundle/tsdown.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 86176d8c0e..80bd55f6f7 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: 401d9737620b86d9 - zh: 0ebbc36e2fe157f3 + en: db292f4e03cef41b + zh: 064cb553698d548c /deepseek-ai-dsh-schedule/understand-the-implementation: en: 676b7c508f37a1b7 zh: c5e8520b3350f0b0 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index cc39506ee6..b932e52e13 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -23,7 +23,7 @@ Schedule delivers one-shot, fixed-rate, daily, weekly, and cron wall-clock remin ## Use this package -The shipped Web bundle mounts the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Schedule cannot be mounted alone in a headless or SDK-only composition: delivery requires the Host Web Session controller and a Session persistence backend, because a delivery commits only after the Session acknowledges `session/flush`. +The shipped Web composition carries the `schedule` row disabled; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to mount the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Schedule cannot be mounted alone in a headless or SDK-only composition: delivery requires the Host Web Session controller and a Session persistence backend, because a delivery commits only after the Session acknowledges `session/flush`. The Agent receives `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update`. Update replaces the name, instruction, or timing of one reminder in place and keeps its id and saved records; it is not offered for the relative `after` delay. Creation requires a non-empty prompt, a title, and exactly one of six selectors: diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 93f67b6cab..6b4e70c9c1 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -23,7 +23,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 ## 使用此包 -发布的 Web bundle 将此服务与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。Schedule 无法在 headless 或仅 SDK 的组合中单独挂载:投递需要 Host 的 Web Session controller 和 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。 +随发行版交付的 Web 组合把 `schedule` 行保持禁用;在插件管理页(Official 分组)启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可将此服务与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。Schedule 无法在 headless 或仅 SDK 的组合中单独挂载:投递需要 Host 的 Web Session controller 和 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。 Agent 获得 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update`。更新原地替换一条提醒的名称、指令或时间,保留其 id 与已保存记录;相对的 `after` 延迟不支持更新。创建时需要非空提示文本、标题,且必须只提供以下六个选择器之一: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36dc0506d3..49642d4755 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: '@deepseek-ai/dsh-experimental-auto-review': specifier: workspace:* version: link:../../packages/experimental/auto-review + '@deepseek-ai/dsh-experimental-schedule-bundle': + specifier: workspace:* + version: link:../../packages/experimental/schedule-bundle '@deepseek-ai/dsh-experimental-voice-input-bundle': specifier: workspace:* version: link:../../packages/experimental/voice-input-bundle @@ -7769,6 +7772,18 @@ importers: specifier: workspace:* version: link:../../util/values + packages/experimental/schedule-bundle: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:~ + version: link:../../../vendor/include + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 + packages/experimental/speech-to-text: dependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index a54d7c3fb2..003c97074d 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -62,6 +62,7 @@ describe('release families', () => { '@deepseek-ai/dsh-experimental-computer-use-cua-driver-native', '@deepseek-ai/dsh-experimental-inspector', '@deepseek-ai/dsh-experimental-ptc-runtime-python', + '@deepseek-ai/dsh-experimental-schedule-bundle', '@deepseek-ai/dsh-experimental-speech-to-text-sensevoice', '@deepseek-ai/dsh-experimental-speech-to-text', '@deepseek-ai/dsh-experimental-tool-agent-team', diff --git a/tsconfig.base.json b/tsconfig.base.json index e2b7da61e8..f303dfc77a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -319,6 +319,7 @@ "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"], + "@deepseek-ai/dsh-experimental-schedule-bundle": ["./packages/experimental/schedule-bundle/src/index.ts"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], "@deepseek-ai/dsh-util-values": ["./packages/util/values/src"], "@deepseek-ai/dsh-util-code-language": ["./packages/util/code-language/src"], From d0f62c36facdbc4e7fc70336193c16819f88481d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 21:05:54 +0800 Subject: [PATCH 011/102] build(schedule-bundle): add the bundle to the host program references --- tsconfig.host.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.host.json b/tsconfig.host.json index bbdbf3cac5..f73460ef8c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -357,6 +357,7 @@ { "path": "./packages/terminal/tool-terminal" }, { "path": "./packages/ptc-runtime/ptc-runtime" }, { "path": "./packages/experimental/ptc-runtime-python" }, + { "path": "./packages/experimental/schedule-bundle" }, { "path": "./packages/ptc-runtime/ptc-runtime-node" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-deepseek-api-key" }, From 3ce7b1142546cc46a44c1c5b2edd6b73168ec250 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 21:32:28 +0800 Subject: [PATCH 012/102] fix(schedule-bundle): satisfy the omitted-companion README rule and refresh the plugin goldens --- apps/web/tests/expected/plugin-config/official.expected.md | 6 +++++- .../tests/expected/plugin-manager/live-enabled.expected.md | 6 +++++- apps/web/tests/expected/plugin-manager/manager.expected.md | 6 +++++- packages/experimental/schedule-bundle/README.i18n.yaml | 2 +- packages/experimental/schedule-bundle/README.md | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index d408f284f3..247d6ac308 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -3,7 +3,7 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "7" +- text: "8" - list: - listitem: - button "查看 智能体团队": 智能体团队 @@ -13,6 +13,10 @@ - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" + - listitem: + - button "查看 定时与时间上下文": 定时与时间上下文 + - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 + - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 2f7b9bc832..4bd8ed064e 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -3,7 +3,7 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "7" +- text: "8" - list: - listitem: - button "查看 智能体团队": 智能体团队 @@ -13,6 +13,10 @@ - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" + - listitem: + - button "查看 定时与时间上下文": 定时与时间上下文 + - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 + - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 61c517953b..b188be8787 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -3,7 +3,7 @@ - button "刷新" - button "添加插件" - heading "官方" [level=3] -- text: "7" +- text: "8" - list: - listitem: - button "查看 智能体团队": 智能体团队 @@ -13,6 +13,10 @@ - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" + - listitem: + - button "查看 定时与时间上下文": 定时与时间上下文 + - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 + - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml index a07b7aa5b1..98d86454bc 100644 --- a/packages/experimental/schedule-bundle/README.i18n.yaml +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -18,7 +18,7 @@ en: ffaa4b18e10a1df9 zh: b675fd8195ebb057 /deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: - en: 9bb32d6e6930f0c2 + en: 90beaeda691bd0a7 zh: d81683d7bb1f6903 /deepseek-ai-dsh-experimental-schedule-bundle/further-exploration: en: 99a29dddf9805540 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md index 10f254bc11..6e3abd4eea 100644 --- a/packages/experimental/schedule-bundle/README.md +++ b/packages/experimental/schedule-bundle/README.md @@ -35,7 +35,7 @@ Open Plugins in the Web sidebar and enable Scheduling and time context, marked b
Maintainer details — click to expand -`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the profile's `dsh.profile.bundles` list, and the profile launcher applies the bundle layers after the Web layer. No runtime invariant companion is published, because this configuration-only package owns no mutable runtime state. +`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the profile's `dsh.profile.bundles` list, and the profile launcher applies the bundle layers after the Web layer. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state. | File | Role | |---|---| From 9f7ef18c03d619a0f6975dba8005eedcf5a615ab Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 24 Sep 2026 22:58:55 +0800 Subject: [PATCH 013/102] refactor(analytics): centralize live policy and message submission context --- apps/cli/tests/desktop-host.e2e.ts | 2 - apps/desktop-host/src/index.ts | 3 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/account-backend.ts | 18 +++-- apps/desktop/src/client/WelcomePage.tsx | 12 +-- apps/desktop/src/main.ts | 16 ++-- apps/desktop/src/preload-welcome.ts | 5 +- apps/desktop/src/welcome-api.ts | 10 ++- apps/desktop/src/welcome-backend.ts | 7 ++ apps/desktop/src/welcome-window.ts | 22 ++++-- apps/desktop/tests/account-backend.spec.ts | 15 +++- apps/desktop/tests/main-startup.spec.ts | 18 +++-- apps/desktop/tests/welcome-host.spec.ts | 2 + .../tests/welcome-renderer.client.spec.tsx | 7 +- apps/desktop/tests/welcome-startup.spec.ts | 1 + apps/desktop/tests/welcome-window.spec.ts | 26 ++++++- apps/desktop/tsconfig.client.json | 3 + docs/config-catalog.md | 9 ++- docs/config-catalog.zh.md | 9 ++- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/product-telemetry.md | 9 ++- docs/subsystems/product-telemetry.zh.md | 9 ++- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 - packages/boot/app-boot/README.zh.md | 4 - packages/boot/app-boot/src/index.ts | 1 - packages/boot/app-boot/src/launch-flag.ts | 15 ---- .../boot/app-boot/tests/launch-flag.spec.ts | 11 --- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 5 +- .../web-app/tests/product-analytics.spec.ts | 33 ++++++++- .../client/product-analytics/README.i18n.yaml | 12 +-- packages/client/product-analytics/README.md | 12 +-- .../client/product-analytics/README.zh.md | 12 +-- .../client/product-analytics/package.json | 5 +- .../product-analytics/src/client/index.ts | 29 ++++++-- .../client/product-analytics/src/index.ts | 57 ++++++++++----- .../tests/collection.client.spec.ts | 58 ++++++++++++--- .../tests/collection.host.spec.ts | 65 ++++++++++++----- .../product-analytics/tsconfig.host.json | 6 ++ packages/client/ui-chat/src/client/apply.ts | 4 +- .../src/client/chat/TurnTailNodeView.tsx | 2 +- .../ui-chat/src/client/contract/slots.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 14 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../client/contract/composer-submission.ts | 16 ++++ .../src/client/contract/input.ts | 6 +- .../src/client/input/facade.ts | 36 ++++----- .../ui-conversation/src/client/input/hub.ts | 23 +++--- .../src/client/input/machine.ts | 24 +++--- .../src/client/input/submission-analytics.ts | 22 ++++++ .../input-reference-submit.client.spec.ts | 73 ++++++++++++++++--- .../service-orchestration.client.spec.ts | 2 +- .../src/client/manager-store.ts | 8 +- ...analytics.ts => sanitize-install-input.ts} | 2 +- ... => sanitize-install-input.client.spec.ts} | 6 +- .../src/client/DesktopOnboarding.tsx | 29 +++++--- .../src/client/OnboardingConfirmation.tsx | 6 +- .../tests/apply.client.spec.ts | 3 +- .../src/client/slot-catalog.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 ++- pnpm-lock.yaml | 9 ++- 73 files changed, 605 insertions(+), 281 deletions(-) delete mode 100644 packages/boot/app-boot/src/launch-flag.ts delete mode 100644 packages/boot/app-boot/tests/launch-flag.spec.ts create mode 100644 packages/client/ui-conversation/src/client/input/submission-analytics.ts rename packages/client/ui-plugin-manager/src/client/{install-analytics.ts => sanitize-install-input.ts} (90%) rename packages/client/ui-plugin-manager/tests/{install-analytics.client.spec.ts => sanitize-install-input.client.spec.ts} (77%) diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index f177adadec..615270aae0 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -28,9 +28,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } writeFileSync(join(root, 'package.json'), '{"type":"module"}') writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}') - copyFileSync(fileURLToPath(new URL('../../../packages/boot/app-boot/lib/types/launch-flag.js', import.meta.url)), join(modules, 'dsh-app-boot', 'launch-flag.js')) writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), ` - export { resolveLaunchFlag } from './launch-flag.js'; export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({}); diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index bfd01f4b7c..4a47a92ce8 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -2,7 +2,7 @@ import { delimiter, join } from 'node:path' import { inspect } from 'node:util' -import { loadLayeredEnv, loadProfileDirectory, resolveLaunchFlag, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -21,7 +21,6 @@ async function main(): Promise { installOfficeEngineResolution(runtimeDir) const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') const profile = loadProfileDirectory('dsh', projectDir, installAnchor) - process.env.DSH_DESKTOP_PRODUCT_ANALYTICS = resolveLaunchFlag('DSH_PRODUCT_ANALYTICS_ENABLED', process.env.DSH_PRODUCT_ANALYTICS_ENABLED, true) ? '1' : '0' reportSkippedBundles('dsh', profile) const application = runProfile({ environment: loadLayeredEnv('dsh'), diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 9189c4bffe..5eef9404af 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md /deepseek-harness-desktop: - en: 84dbd7387e8791c9 - zh: ec1ce14b79e953f5 + en: 2a9d7d1de31172d4 + zh: 6680e1bf85abb6f1 /deepseek-harness-desktop/closing-the-window-and-quitting: en: fb852f3bd70d8602 zh: 3d2b0a8d580d7f0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3d1d252824..56750b7bbf 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its launch-time switch. Web usage is excluded. +Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f73621d3e9..4288b99f2e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。 +桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 889b4371bc..bb79bbb526 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -67,9 +67,10 @@ export interface DesktopAccountBackend { * @param listener - state recipient. * @param failed - stream failure recipient. * @param expired - live credential-expiry recipient. + * @param policy - optional Desktop collection-policy recipient; disconnected streams publish false. * @returns stream disposer. */ - watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void + watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void, policy?: (enabled: boolean) => void): () => void } /** @@ -86,19 +87,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo state: () => call('getState'), start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }), - watch(listener, failed, expired) { + watch(listener, failed, expired, policy) { let closed = false let socket: WebSocket | undefined let retry: ReturnType | undefined const connect = (): void => { const streamId = randomUUID() const expiryStreamId = randomUUID() + const policyStreamId = randomUUID() void cookies().then((cookie) => { if (closed) return const url = new URL(REMOTE_STREAM_MUX_PATH, origin) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) socket.on('open', () => { + if (policy !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: policyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) }) @@ -106,6 +109,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo try { const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === policyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') policy?.(frame.value) + else policy?.(false) + return + } if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') if (frame.type === 'item') listener(accountView(frame.value)) @@ -113,11 +121,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo } catch { socket?.close() } }) socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + socket.on('close', () => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() - return () => { closed = true; clearTimeout(retry); socket?.close() } + return () => { closed = true; policy?.(false); clearTimeout(retry); socket?.close() } }, } } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index c40d10e999..b367061732 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -70,7 +70,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { }, [api, m.welcomeTitle]) useEffect(() => { - if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('view') + if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {}) visiblePage.current = page if (page === 'key') input.current?.focus() else if (page === 'entry' && focusEntry.current) { @@ -88,7 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) { async function saveKey(event: FormEvent) { event.preventDefault() if (busyRef.current) return - void api.analytics?.('save-key') + void api.analytics?.('api_key_save_click', {}) const value = draft.trim() if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { @@ -202,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) { - - + +

{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}

- +
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index f465553e57..a442d71cf4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -272,8 +272,7 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return await c.reload(SELF) const actions = operations(c) const track = vi.fn() - const analytics = c.ctx.get('productAnalytics')! - vi.spyOn(analytics, 'track').mockImplementation(track) + c.ctx.provide('productAnalytics', { track } as never) actions.track?.('auth_page_view', {}) expect(track).toHaveBeenCalledWith('auth_page_view', {}) const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index c288ffe5d4..1f0ffab903 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -241,11 +241,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number, messageId?: MessageId) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', - 'MessageId', 'MessageImageLoader', 'OpenFileOptions', 'RenderMessageImages', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a632d89bec..08df52a9cb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1705,8 +1705,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'productAnalytics', - summary: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', - description: 'Authenticated event intake; disabled instances neither inspect identity nor construct an exporter.', + summary: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', + description: 'Authenticated event intake; disabled instances do not inspect identity or accept new events.', methods: [ { signature: '@Remote enabled(): boolean', @@ -1714,6 +1714,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'whether this Host currently accepts Desktop analytics.', }, + { + signature: '@Remote({ mode: \'stream\' }) async *watchPolicy(signal: AbortSignal): AsyncIterable', + description: 'Stream the effective policy initially and after live configuration edits.', + parameters: [{ name: 'signal', description: 'subscriber lifetime.' }], + returns: 'current policy values until cancellation or service disposal.', + }, { signature: '@Remote async report(event: ProductEvent): Promise', description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5d804ccb0..498f0979b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2934,9 +2934,6 @@ importers: packages/client/product-analytics: dependencies: - '@deepseek-ai/dsh-credentials': - specifier: workspace:* - version: link:../../credentials/credentials '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol @@ -2947,6 +2944,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-settings': + specifier: workspace:* + version: link:../../settings/settings '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis From 7e5a4d1e4b517ffd97d74d90201daec2306bb372 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 23:56:45 +0800 Subject: [PATCH 014/102] fix(docs): align publication runner and scoped-event heap budgets --- .github/workflows/docs-pages.yml | 2 +- package.json | 4 ++-- scripts/gen-scoped-events.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8bac404b2d..6cd25b059d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -35,7 +35,7 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read pages: read diff --git a/package.json b/package.json index 81a370d4c4..4317f7dc94 100644 --- a/package.json +++ b/package.json @@ -181,8 +181,8 @@ "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", - "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", - "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "gen-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts", + "verify-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts --check", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index 9d070def72..eecf684d8b 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -9,8 +9,8 @@ * `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`; * multiple matches are ambiguous and always fail loud. * - * `tsx scripts/gen-scoped-events.ts` -> write the generated source - * `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale + * `pnpm run gen-scoped-events` -> write the generated source + * `pnpm run verify-scoped-events` -> exit 1 when it is stale */ import { existsSync, readFileSync, writeFileSync } from 'node:fs' From 638842895a18ee14e9b3a3c4ccab91f77a266180 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:00:53 +0800 Subject: [PATCH 015/102] docs(desktop): describe keyboard typecheck ownership --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 039d4701b1..00a0668e87 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0d11af8639977a6d zh: 86b086b12d2210d3 /deepseek-harness-desktop/develop: - en: 9b9b362a9865e763 - zh: c748901168e2db8c + en: 40d2ad4014bf445b + zh: 6a08de06df9ed042 /deepseek-harness-desktop/develop/startup-onboarding: en: 5e2d62207a603d81 zh: 5fb863d4906f4a67 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0f579c249b..f1de0eba4c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -130,7 +130,7 @@ pnpm run start:desktop The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. -The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules. +The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules. ### Startup onboarding diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 62e34fad48..c2f7ffbcfb 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -132,7 +132,7 @@ pnpm run start:desktop Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 -[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。 +[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。 ### 启动引导 From a08472e9828922406ea9b6c6128a86a6e248720f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:01:08 +0800 Subject: [PATCH 016/102] fix(build): fold desktop keyboard tests into client typecheck --- tsconfig.client.json | 9 ++++++++- tsconfig.desktop-keyboard-tests.json | 24 ------------------------ 2 files changed, 8 insertions(+), 25 deletions(-) delete mode 100644 tsconfig.desktop-keyboard-tests.json diff --git a/tsconfig.client.json b/tsconfig.client.json index 0471bfa13b..2e27307ddb 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -14,6 +14,13 @@ "types": ["node"] }, "include": [ + // The native/renderer keyboard fixture imports only these Cordis-free Desktop modules. + "apps/desktop/tests/keyboard.spec.ts", + "apps/desktop/src/keyboard.ts", + "apps/desktop/src/keybindings.ts", + "apps/desktop/src/browser-guests.ts", + "apps/desktop/src/ipc.ts", + "apps/desktop/src/update-overlay.ts", "apps/desktop/tests/**/*.client.spec.tsx", // Source-subpath test imports can arrive through workspace links whose // realpath semantics vary by host. Load package CSS declarations directly. @@ -43,8 +50,8 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ - { "path": "./tsconfig.desktop-keyboard-tests.json" }, { "path": "./apps/desktop/tsconfig.client.json" }, + { "path": "./packages/util/atomic-write" }, { "path": "./packages/experimental/client-ui-voice-input" }, { "path": "./packages/client/ui-sidebar-terminal" }, diff --git a/tsconfig.desktop-keyboard-tests.json b/tsconfig.desktop-keyboard-tests.json deleted file mode 100644 index 28cdd1bf9c..0000000000 --- a/tsconfig.desktop-keyboard-tests.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - // The native/renderer integration fixture imports only Cordis-free Desktop modules. - "extends": "./tsconfig.base.client.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "lib/desktop-keyboard-test-types", - "emitDeclarationOnly": true, - "types": ["node"] - }, - "files": [ - "apps/desktop/tests/keyboard.spec.ts", - "apps/desktop/src/keyboard.ts", - "apps/desktop/src/keybindings.ts", - "apps/desktop/src/browser-guests.ts", - "apps/desktop/src/ipc.ts", - "apps/desktop/src/update-overlay.ts" - ], - "include": [], - "references": [ - { "path": "./packages/client/shortcuts/tsconfig.client.json" }, - { "path": "./packages/client/ui-sidebar-browser/tsconfig.client.json" }, - { "path": "./packages/util/atomic-write" } - ] -} From 197355bb12584f99595cd4ac5f2268f231e9f176 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:08:48 -0700 Subject: [PATCH 017/102] fix(schedule-bundle): name the Plugins row Automation tasks with an alarm clock icon The row now carries the name of the sidebar entry it adds and a plain-language description, and its icon is an alarm clock drawn in the Official group's 36px style. The README and the user guide quote the new title; the README names the sidebar entry instead of a right-sidebar page. --- .../tests/expected/plugin-config/official.expected.md | 6 +++--- .../expected/plugin-manager/live-enabled.expected.md | 6 +++--- .../tests/expected/plugin-manager/manager.expected.md | 6 +++--- docs/user/guide/schedule.i18n.yaml | 4 ++-- docs/user/guide/schedule.md | 2 +- docs/user/guide/schedule.zh.md | 2 +- packages/experimental/schedule-bundle/README.i18n.yaml | 4 ++-- packages/experimental/schedule-bundle/README.md | 2 +- packages/experimental/schedule-bundle/README.zh.md | 2 +- packages/experimental/schedule-bundle/icon.svg | 9 +++++---- packages/experimental/schedule-bundle/locale/en.json | 4 ++-- packages/experimental/schedule-bundle/locale/zh.json | 4 ++-- 12 files changed, 26 insertions(+), 25 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 247d6ac308..dddc056ff2 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 4bd8ed064e..086bfee63c 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index b188be8787..90f137a80e 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -14,9 +14,9 @@ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 - switch "启用 自动授权审查" - listitem: - - button "查看 定时与时间上下文": 定时与时间上下文 - - text: 实验性 启用持久提醒、自动化任务页面与逐步时钟上下文。 - - switch "启用 定时与时间上下文" + - button "查看 自动化任务": 自动化任务 + - text: 实验性 按设定的时间或周期,在会话中自动执行任务。 + - switch "启用 自动化任务" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 1bf88dec7c..1aa4d2c185 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -12,8 +12,8 @@ en: b3b2a6e0ce02bdee zh: 2ddb168de79fd19c /schedule-reminders/create-reminders: - en: c76ee99f59529942 - zh: be1d75c226231dd7 + en: 75d55fa3e4fc01d5 + zh: 2fb76166a9106f5f /schedule-reminders/inspect-and-delete-tasks: en: a44fc1aabc2ab957 zh: 31f444d4c7fd5c7a diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index abb86b7f8e..e248cccdb5 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e ## Create reminders -The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Scheduling and time context` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. +The shipped Web profile carries the `time-context`, `schedule`, and `ui-schedule` rows disabled. Open Plugins and enable `Automation tasks` in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders. Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index bd10a06fe5..4b2a1827f3 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -17,7 +17,7 @@ ## 创建提醒 -发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在 Official 分组中启用“定时与时间上下文”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 +发布的 Web profile 把 `time-context`、`schedule` 与 `ui-schedule` 三行保持禁用。打开插件管理页,在“官方”分组中启用“自动化任务”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。 向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。 diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml index 98d86454bc..f7efa8d5ab 100644 --- a/packages/experimental/schedule-bundle/README.i18n.yaml +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-schedule-bundle/use-this-package: - en: ffaa4b18e10a1df9 - zh: b675fd8195ebb057 + en: a5fcc3d26604ff4c + zh: bc358d33b0b0c1d2 /deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: en: 90beaeda691bd0a7 zh: d81683d7bb1f6903 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md index 6e3abd4eea..6bd93ce427 100644 --- a/packages/experimental/schedule-bundle/README.md +++ b/packages/experimental/schedule-bundle/README.md @@ -25,7 +25,7 @@ This optional bundle switches on the three Schedule rows that `@deepseek-ai/dsh- ## Use this package -Open Plugins in the Web sidebar and enable Scheduling and time context, marked by a clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the right sidebar shows the Automation tasks page, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk. +Open Plugins in the Web sidebar and enable Automation tasks, marked by an alarm clock icon. A live root Agent then receives `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`, the Session header shows its reminder catalog, the sidebar shows the Automation tasks entry, and each eligible step appends one clock reading with the current time, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. Disabling the bundle restores the shipped composition; stored tasks remain on disk. ----- diff --git a/packages/experimental/schedule-bundle/README.zh.md b/packages/experimental/schedule-bundle/README.zh.md index 747137bc57..d72e8434a3 100644 --- a/packages/experimental/schedule-bundle/README.zh.md +++ b/packages/experimental/schedule-bundle/README.zh.md @@ -25,7 +25,7 @@ kind: "package-bundle" ## 使用此包 -打开 Web 侧栏的插件管理页并启用带时钟图标的“定时与时间上下文”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,右侧栏显示自动化任务页面,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。 +打开 Web 侧栏的插件管理页并启用带闹钟图标的“自动化任务”。此后,活跃的根智能体获得 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`,会话头部显示提醒目录,侧栏出现“自动化任务”入口,每个符合条件的步骤追加一条时钟读数,包含当前时间、打开请求所带的浏览器时区,以及距上一条模型可见消息的经过时间。禁用此 Bundle 会恢复随包组合;已存储的任务保留在磁盘上。 ----- diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg index 4299a86395..91a374ce9f 100644 --- a/packages/experimental/schedule-bundle/icon.svg +++ b/packages/experimental/schedule-bundle/icon.svg @@ -1,9 +1,10 @@ - - - + + + + - + diff --git a/packages/experimental/schedule-bundle/locale/en.json b/packages/experimental/schedule-bundle/locale/en.json index f748924183..8766ee2a4c 100644 --- a/packages/experimental/schedule-bundle/locale/en.json +++ b/packages/experimental/schedule-bundle/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Scheduling and time context", - "description": "Enable durable reminders, the Automation tasks page, and per-step clock context." + "title": "Automation tasks", + "description": "Run tasks in your sessions at a set time or on a repeating schedule." } } diff --git a/packages/experimental/schedule-bundle/locale/zh.json b/packages/experimental/schedule-bundle/locale/zh.json index 47abb9b169..14e171d352 100644 --- a/packages/experimental/schedule-bundle/locale/zh.json +++ b/packages/experimental/schedule-bundle/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "定时与时间上下文", - "description": "启用持久提醒、自动化任务页面与逐步时钟上下文。" + "title": "自动化任务", + "description": "按设定的时间或周期,在会话中自动执行任务。" } } From 1145adee07216bdb5d8487046f4d732569f7b3d0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 15:00:26 +0800 Subject: [PATCH 018/102] docs(schedule-bundle): correct the notes, README limits, and composed patch spec Archive the reversed 2026-09-24-web-default-schedule-composition triplet and name the four bundles in the optional-bundles note. Rewrite the opt-in note's Problem to the state at the branch base and state why the bundle switches time-context on with schedule. Record the missing-row warnings, the bundle list order, and the bundle detail page's empty state in the bundle README. Pin the composed Schedule rows in scripts/optional-bundles.spec.ts and move the cli dependency entry into order. --- ...web-default-schedule-composition.i18n.yaml | 6 +++ ...-09-24-web-default-schedule-composition.md | 40 +++++++++++++++++++ ...-24-web-default-schedule-composition.zh.md | 40 +++++++++++++++++++ .agents/notes/archived/manifest.json | 3 ++ ...capabilities-as-optional-bundles.i18n.yaml | 8 ++-- ...mental-capabilities-as-optional-bundles.md | 4 +- ...tal-capabilities-as-optional-bundles.zh.md | 4 +- ...-schedule-opt-in-optional-bundle.i18n.yaml | 12 +++--- ...6-09-24-schedule-opt-in-optional-bundle.md | 6 +-- ...9-24-schedule-opt-in-optional-bundle.zh.md | 6 +-- apps/cli/package.json | 2 +- .../schedule-bundle/README.i18n.yaml | 8 ++-- .../experimental/schedule-bundle/README.md | 6 +-- .../experimental/schedule-bundle/README.zh.md | 6 +-- scripts/optional-bundles.spec.ts | 23 ++++++++++- 15 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml create mode 100644 .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md create mode 100644 .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml new file mode 100644 index 0000000000..b5321c036f --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-09-24-web-default-schedule-composition.md: 3f27bb09ebc00d42c32563e17a1de82c8e525be1 +2026-09-24-web-default-schedule-composition.zh.md: 9e0e1fb77ce89b579e1bc8b0d4d0a1b9f4e9fea5 diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md new file mode 100644 index 0000000000..3f27bb09eb --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md @@ -0,0 +1,40 @@ +# Agent Note: Schedule in the shipped Web composition + +Status: implemented +Archived: 2026-09-25 + +English | [中文](2026-09-24-web-default-schedule-composition.zh.md) + +## Problem + +The Automation tasks page, the Session reminder catalog, and the `schedule_*` tools reached a Web deployment only through `--patch apps/cli/config/examples/schedule/cordis.yml`. `packages/bundle/web-app/cordis.patch.yml` carried the `ui-schedule` client row with `disabled: true`, and the Host rows `time-context` and `schedule` existed in that overlay alone. A person running the shipped `web` profile therefore saw no Automation tasks entry and no reminder tools, while every consumer that wanted them repeated the same three rows: the repository preview image kept its own overlay list, and two Web suites hard-coded the overlay path. + +## Decision + +`packages/bundle/web-app/cordis.patch.yml` inserts `time-context` and `schedule` in its Host row list and leaves `ui-schedule` enabled; `packages/bundle/web-app/package.json` declares both packages, which `verify-cordis-config` requires for a bare row name in a bundle patch. The `web` profile consequently ships the Automation tasks page, the Session-header reminder clock, the idle Session row's clock mark and hover list, the right-Sidebar task tab, and `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on every live root Agent. + +`apps/cli/config/examples/schedule/cordis.yml` is deleted. `applyEntryPatches` appends an `insert` list without de-duplicating ids, so keeping the overlay would mount `time-context` and `schedule` a second time; the two Web suites and the preview packer that named the overlay compose the shipped profile alone. + +`time-context` ships with Schedule because a reminder request states a wall-clock target. The plugin appends one durable user message per eligible step carrying the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. The sampling instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value; the [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns that interpretation boundary and the explicit-zone requirement it feeds. + +The Host service, its storage domain, and the client half are unchanged. This decision moves which composition mounts them, and the [Host-owned scheduled messages decision](2026-09-16-host-schedule-storage.md) continues to own task storage, activation, dispatch, and delivery records. + +## Recorded sessions + +A Web scenario that drives a live step now logs one time-context reading per step, so its committed Session fixture and header sidecars were refreshed. A reading's sampled instant, browser zone, and elapsed duration are volatile, and `normalizeWebSessionVolatiles` in `apps/web/tests/scaffold.ts` replaces them with `{{timeContextTimestamp}}`, `{{clientTimeZone}}`, and `{{elapsed}}`. The turn and step numbers and the preceding-event baseline stay readable, so a fixture still shows what the model received. Refresh writes the current writer generation (`session.v4.jsonl`) beside the retained predecessors, which remain committed replay baselines. + +## Alternatives considered + +**Keep the overlay and change nothing.** The shipped surface would keep contradicting the product documentation that describes the Automation tasks entry, and each consumer would keep its own copy of the three rows. The overlay also cannot satisfy the request that the default `dsh web` process exposes the page without a flag. + +**Ship the Schedule rows without `time-context`.** The model would then have no sampling instant of its own, so an unqualified date or time in a reminder request would depend on a separate clock source; the overlay paired the two rows for that reason. + +**Reach for a separate optional bundle.** `OPTIONAL_BUNDLES` exists for bundles a person switches on from the plugin manager, which is the same opt-in shape as the overlay; the decision here is that a default Web surface owns the page and the tools. + +## Consequences + +- Every Web session's request header carries four additional tool schemas, and every eligible step appends one durable user message. A conversation that never creates a reminder pays that token cost. +- The clock reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message. +- A deployment that wants the previous behavior disables the `time-context`, `schedule`, and `ui-schedule` rows in its own profile patch layer; the capability itself is untouched. +- The `cordis_inspect_query` `listTools` answer for a full preset table now passes the base composition's 12,500-token inline budget, so the spill policy retains that answer's head and tail with a spill path instead of the complete JSON. +- The repository preview image and both Web suites drop their overlay arguments, so one composition change reaches every surface at once. diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md new file mode 100644 index 0000000000..9e0e1fb77c --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 默认 Web 组合中的 Schedule + +Status: implemented +Archived: 2026-09-25 + +[English](2026-09-24-web-default-schedule-composition.md) | 中文 + +## Problem + +自动化任务页面、Session 提醒目录以及 `schedule_*` 工具此前只能通过 `--patch apps/cli/config/examples/schedule/cordis.yml` 进入某个 Web 部署:`packages/bundle/web-app/cordis.patch.yml` 里的 `ui-schedule` 客户端行带 `disabled: true`,而宿主行 `time-context` 与 `schedule` 只存在于那个 overlay 中。因此运行发布版 `web` profile 的人既看不到自动化任务入口,也拿不到提醒工具;同时每个需要它们的消费者都要重复同样的三行:仓库预览镜像自行维护了一份 overlay 列表,两个 Web 测试套件各自硬编码了 overlay 路径。 + +## Decision + +`packages/bundle/web-app/cordis.patch.yml` 在其宿主行列表中插入 `time-context` 与 `schedule`,并让 `ui-schedule` 保持启用;`packages/bundle/web-app/package.json` 声明这两个包,这是 `verify-cordis-config` 对 bundle patch 中裸包名的要求。因此 `web` profile 会交付自动化任务页面、Session 页头的提醒时钟、空闲 Session 行的时钟标记与悬停列表、右侧栏任务页签,并在每个 live 根 Agent 上注册 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`。 + +`apps/cli/config/examples/schedule/cordis.yml` 已删除。`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,保留该 overlay 会把 `time-context` 与 `schedule` 挂载两次;原本引用它的两个 Web 测试套件与预览打包器现在只组合发布版 profile。 + +`time-context` 随 Schedule 一起发布,因为提醒请求本身给出的是墙上时钟目标。该插件在每个符合条件的步骤追加一条持久 user 消息,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。正是采样瞬时让模型能把「明天九点」这类请求落成带偏移量的 `at` 值;该解释边界及其要求的显式时区由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责。 + +宿主服务、其存储 domain 与客户端半边都没有改动。本决策改变的是由哪个组合挂载它们,任务存储、激活、投递与投递记录仍由[宿主定时消息决策](2026-09-16-host-schedule-storage.zh.md)负责。 + +## Recorded sessions + +驱动 live 步骤的 Web 场景现在每步记录一条 time-context 读数,因此其已提交的 Session 夹具与页头 sidecar 都已重刷。一条读数中的采样瞬时、浏览器时区与经过时长是易变值,`apps/web/tests/scaffold.ts` 的 `normalizeWebSessionVolatiles` 会把它们替换为 `{{timeContextTimestamp}}`、`{{clientTimeZone}}` 和 `{{elapsed}}`。turn 与 step 序号以及"前一条事件"的基线保持可读,因此夹具仍能显示模型收到的内容。重刷会在已保留的上一代旁写入当前 writer 代次(`session.v4.jsonl`),旧代次继续作为已提交的回放基线保留。 + +## Alternatives considered + +**保留 overlay,不做任何改动。** 发布版界面会继续与描述该自动化任务入口的产品文档相矛盾,而每个消费者都要各自保留那三行的副本。overlay 也无法满足「默认 `dsh web` 进程不带 flag 就暴露该页面」这一要求。 + +**只发布 Schedule 两行,不带 `time-context`。** 模型将没有自己的采样瞬时,于是提醒请求中未明确限定时区的日期或时间要依赖另一个时钟来源;overlay 当初正是为此把两行配对。 + +**另建一个可选 bundle。** `OPTIONAL_BUNDLES` 面向的是用户在插件管理器里主动开启的 bundle,与 overlay 是同一种 opt-in 形态;本决策要的是默认 Web 界面自己拥有该页面与这些工具。 + +## Consequences + +- 每个 Web 会话的请求头多出四个工具 schema,且每个符合条件的步骤追加一条持久 user 消息。从不创建提醒的对话也要承担这份 token 成本。 +- 时钟读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。 +- 想要恢复旧行为的部署,可在自己的 profile patch 层禁用 `time-context`、`schedule` 与 `ui-schedule` 三行;能力本身没有改动。 +- 完整预设表的 `cordis_inspect_query` `listTools` 答案现在超过基础组合 12,500 token 的内联预算,因此 spill 策略改为保留该答案的首尾并给出 spill 路径,而不是完整 JSON。 +- 仓库预览镜像与两个 Web 测试套件都不再传 overlay 参数,一处组合改动即同时作用于所有界面。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 5ed6850038..b4e085deb2 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -376,6 +376,9 @@ "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e", "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed", "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a", + "architecture/2026-09-24-web-default-schedule-composition.i18n.yaml": "sha256:8c3d5c614924d0e81f361a299427ac697aefc6189c41eb3f4151f5c42e371ca0", + "architecture/2026-09-24-web-default-schedule-composition.md": "sha256:3a139593ade13f95cbb67094c1eec66cb0a2c14bcc154512799803b2a7d22ab4", + "architecture/2026-09-24-web-default-schedule-composition.zh.md": "sha256:59908f7987058742d1226dc8a801d03c265c84d51172249ba51716b91f084f0c", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7", diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index e8573b2e72..87aa04a960 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,11 +9,11 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: 1c5a43b3ce2d3056 - zh: b59f549a12cf67d1 + en: fdd54ede661f7a83 + zh: a41ed1a872bb8ba3 /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: en: a65d84004e48b3ef zh: 17221f08e7a8a89b /agent-note-ship-experimental-capabilities-as-optional-bundles/consequences: - en: d52ed0a248131a37 - zh: 779b8d415281c5da + en: 71482af50ad31d54 + zh: 12d4849104842f0d diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index a2a83645c9..48d3fee0bb 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A ## Decision -`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch. +`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Schedule bundle. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch. An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page. @@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc ## Consequences -The Official group grows from two to three entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. +The Official group grows from two to four entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 62e248e5ae..37df0d630a 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 决策 -`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。 +`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Schedule bundle。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。 可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。 @@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 后果 -官方分组从两项增至三项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 +官方分组从两项增至四项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml index 901bf1470d..a95cf939f3 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml @@ -6,14 +6,14 @@ en: 8c2381fd778a9888 zh: 8877255cb980eea1 /agent-note-schedule-as-an-opt-in-optional-bundle/problem: - en: 26d8e4f92b6cf3c5 - zh: 48dcb89bbb4a7169 + en: 2409ea3a006ea5d9 + zh: dbcda58a4c6b5b9f /agent-note-schedule-as-an-opt-in-optional-bundle/decision: - en: 5edbd3d4f9007745 - zh: e12eb39513bed213 + en: 360867ff77c6b727 + zh: 4eb4b47439c21d5d /agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered: en: d12affa9097a1a50 zh: 4190d195c6a6eb8d /agent-note-schedule-as-an-opt-in-optional-bundle/consequences: - en: 00d9287c82256837 - zh: eb85623524e59b60 + en: 8ea1d9aaf25f1702 + zh: 32e721347ea55475 diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md index b0c4eba715..4ea60cb9cd 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md @@ -6,7 +6,7 @@ English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md) ## Problem -The shipped Web composition mounted `time-context`, `schedule`, and `ui-schedule`, so every Web session carried four Schedule tool schemas and one durable clock message per eligible step whether or not the deployment wanted reminders. The rows had no switch on the product surface: a deployment that wanted them off edited its own profile patch layer, and the `--patch apps/cli/config/examples/schedule/cordis.yml` overlay could only switch them on from a launch flag. +`packages/bundle/web-app/cordis.patch.yml` inserts `time-context`, `schedule`, and `ui-schedule` with `disabled: true`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. The rows have no switch on the product surface; a deployment that wants them edits a profile patch layer or passes a `--patch` overlay, and both are configuration files a person using the shipped Web profile cannot reach. ## Decision @@ -14,7 +14,7 @@ The shipped Web composition mounted `time-context`, `schedule`, and `ui-schedule `@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) carries three id-targeted patches in its `cordis.patch.yml`, one per row, each setting `disabled: false`; it inserts no row. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group. -Enabling the bundle turns on `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition that carries the disabled rows. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row stays disabled; stored task records remain in the Schedule domain. +Enabling the bundle turns on `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle switches `time-context` on with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition that carries the disabled rows. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row stays disabled; stored task records remain in the Schedule domain. ## Alternatives considered @@ -28,5 +28,5 @@ Enabling the bundle turns on `time-context` (a per-step clock reading with the s - A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`. - An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message. -- The bundle changes nothing in a composition that does not carry the three Web rows, and its package declares no plugin dependency, so enabling it cannot mount a Host service twice. +- A composition that does not carry the three Web rows reports one `patch: entry not found` warning per patch and mounts nothing from this bundle, and its package declares no plugin dependency, so enabling it cannot mount a Host service twice. - The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion. diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md index 871bd7b52e..ed63f8e9c9 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -发布的 Web 组合挂载了 `time-context`、`schedule` 与 `ui-schedule`,因此每个 Web 会话都会带上四个 Schedule 工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,无论该部署是否需要提醒。产品界面上没有开关:想要关闭它们的部署只能改自己的 profile patch 层,而 `--patch apps/cli/config/examples/schedule/cordis.yml` overlay 只能通过启动参数打开它们。 +`packages/bundle/web-app/cordis.patch.yml` 以 `disabled: true` 插入 `time-context`、`schedule` 与 `ui-schedule`,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。这些条目在产品界面上没有开关;想要它们的部署要改 profile patch 层或传 `--patch` overlay,而两者都是使用随发行版 Web profile 的人无法触及的配置文件。 ## Decision @@ -14,7 +14,7 @@ Status: implemented `@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中携带三个按 id 定位的 patch,每行一个,各自把 `disabled` 设为 `false`;它不插入任何行。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在 Official 分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。 -启用该 bundle 会打开 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`),以及 `ui-schedule`(Session 提醒目录与自动化任务页面)。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;承载这些禁用行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行保持禁用期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。 +启用该 bundle 会打开 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`),以及 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起打开,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;承载这些禁用行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行保持禁用期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。 ## Alternatives considered @@ -28,5 +28,5 @@ Status: implemented - 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。 - 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。 -- 在不携带这三行 Web 行的组合中,该 bundle 不改变任何东西;其包不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。 +- 在不携带这三行 Web 行的组合中,该 bundle 的每个补丁都会报告一条 `patch: entry not found` 警告且不挂载任何东西;其包不声明插件依赖,因此启用它不会重复挂载同一个 Host 服务。 - 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 7e9fb3286b..3c84cff81a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,8 +38,8 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", - "@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", + "@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml index 98d86454bc..efbd18b82e 100644 --- a/packages/experimental/schedule-bundle/README.i18n.yaml +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -18,8 +18,8 @@ en: ffaa4b18e10a1df9 zh: b675fd8195ebb057 /deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: - en: 90beaeda691bd0a7 - zh: d81683d7bb1f6903 + en: 744024d71c8fca24 + zh: 22f939f9484e1ad7 /deepseek-ai-dsh-experimental-schedule-bundle/further-exploration: en: 99a29dddf9805540 zh: 13a6df3ee31abfc1 @@ -39,8 +39,8 @@ en: 0b01d8c40b422f5b zh: 57fede4db59a62b8 /deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work: - en: d733f4e0f983de86 - zh: 7ffb3d3fe7f77a7b + en: 6f2812ce847b2654 + zh: ac8ef109662f67c0 /deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work/dev-note: en: 0225ba087c5266f3 zh: 4e706f1b2b984e63 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md index 6e3abd4eea..7b8feb04ee 100644 --- a/packages/experimental/schedule-bundle/README.md +++ b/packages/experimental/schedule-bundle/README.md @@ -35,7 +35,7 @@ Open Plugins in the Web sidebar and enable Scheduling and time context, marked b
Maintainer details — click to expand -`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the profile's `dsh.profile.bundles` list, and the profile launcher applies the bundle layers after the Web layer. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state. +`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the tail of the profile's `dsh.profile.bundles` list; bundle layers apply in list order, so the patches reach the rows only because that list places `@deepseek-ai/dsh-web-app` before this package. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state. | File | Role | |---|---| @@ -78,8 +78,8 @@ The tool schemas change the request prefix once when the bundle mounts; each app -- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. A profile without that bundle carries no such rows, so this bundle changes nothing there. -- Selecting the bundle adds the four Schedule tool schemas to every live root Agent and one clock reading per eligible step. A conversation that never creates a reminder still pays both costs. +- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. In a profile without that bundle the three patches match no row: the loader reports one `patch: entry not found` warning per row and skips it, while the Plugins page still shows the switch as on. +- The bundle's detail page has no per-row switches. The Host reports the three overridden ids under `overrides`, and the Web client renders only the rows a bundle inserts, so opening this card shows "This plugin pack contains no components." The switches for the three rows are on the Official-group list row. ----- diff --git a/packages/experimental/schedule-bundle/README.zh.md b/packages/experimental/schedule-bundle/README.zh.md index 747137bc57..4201c46ad5 100644 --- a/packages/experimental/schedule-bundle/README.zh.md +++ b/packages/experimental/schedule-bundle/README.zh.md @@ -35,7 +35,7 @@ kind: "package-bundle"
维护者信息 — 点击展开 -`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表,profile 启动器在 Web 层之后应用各 Bundle 层。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。 +`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表末尾;Bundle 层按列表顺序应用,因此这些补丁能命中条目,只是因为该列表把 `@deepseek-ai/dsh-web-app` 排在此包之前。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。 | 文件 | 作用 | |---|---| @@ -78,8 +78,8 @@ kind: "package-bundle" -- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。没有该 Bundle 的 profile 不含这些条目,此 Bundle 在那里不产生变化。 -- 选中此 Bundle 会给每个活跃根智能体增加四个定时工具的 schema,并为每个符合条件的步骤增加一条时钟读数。即使对话从不创建提醒,也会承担这两项开销。 +- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。在没有该 Bundle 的 profile 中,三个补丁匹配不到任何条目:加载器为每个条目报告一条 `patch: entry not found` 警告并跳过它,而插件管理页仍把该开关显示为已启用。 +- 该 Bundle 的详情页没有逐条目开关。Host 把被覆盖的三个 id 报告在 `overrides` 下,而 Web 客户端只渲染 Bundle 插入的条目,因此打开这张卡片会显示“这个插件包不包含任何组件。”。三个条目的开关在 Official 分组列表行上。 ----- diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index 71671e5b71..9218b4ed98 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -41,13 +41,34 @@ describe('optional bundles', () => { it.each(OPTIONAL_BUNDLES)('%s composes over the Web profile without a skipped patch', (name) => { const { patches } = bundle(name) const warnings: string[] = [] - const ids = new Set(composeEntries([...shipped, patches], message => warnings.push(message)).map(entry => entry.id)) + const composed = composeEntries([...shipped, patches], message => warnings.push(message)) + const ids = new Set(composed.map(entry => entry.id)) expect(warnings).toEqual([]) // Inserted rows carry stable ids at the profile root, so a later profile patch can configure or disable them. for (const row of patches.flatMap(patch => patch.insert ?? [])) { expect(typeof row.id).toBe('string') expect(ids.has(row.id)).toBe(true) } + // An id-targeted patch reaches a row another layer inserted: the composed row keeps that row's package + // name, appears once, and carries the patched values. + for (const patch of patches) { + if (patch.insert !== undefined || typeof patch.id !== 'string') continue + const matches = composed.filter(entry => entry.id === patch.id) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ ...patch }) + expect(typeof matches[0]?.name).toBe('string') + } + }) + + it('composes the Schedule bundle over the three shipped Web rows', () => { + const { patches } = bundle('@deepseek-ai/dsh-experimental-schedule-bundle') + const composed = composeEntries([...shipped, patches]) + expect(composed.filter(entry => entry.id === 'time-context' || entry.id === 'schedule' || entry.id === 'ui-schedule')) + .toMatchObject([ + { id: 'time-context', name: '@deepseek-ai/dsh-time-context', disabled: false }, + { id: 'schedule', name: '@deepseek-ai/dsh-schedule', disabled: false }, + { id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule', disabled: false }, + ]) }) it.each(OPTIONAL_BUNDLES)('%s resolves a title, description, and icon in both shipped languages', (name) => { From 1065f0afd17ca2ad8cc215aeed8d5436a0fcedf9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 15:12:14 +0800 Subject: [PATCH 019/102] fix(schedule-bundle): correct review findings on row semantics and the archived note Cross-link the archived decision the opt-in note supersedes, state the Loader's one entry per id and last-declaration-wins behavior in the overlay and extraction alternatives, scope the missing-row warning to profiles that mount no Plugins page, and move the switch-on-but-ineffective case to the list-order paragraph. Assert one top-level composed row per id and correct the stale assembled-remote comment. --- ...perimental-capabilities-as-optional-bundles.i18n.yaml | 4 ++-- ...9-21-experimental-capabilities-as-optional-bundles.md | 2 +- ...1-experimental-capabilities-as-optional-bundles.zh.md | 2 +- .../2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml | 8 ++++---- .../2026-09-24-schedule-opt-in-optional-bundle.md | 6 ++++-- .../2026-09-24-schedule-opt-in-optional-bundle.zh.md | 6 ++++-- apps/web/tests/assembled-remote.ts | 5 +++-- packages/experimental/schedule-bundle/README.i18n.yaml | 8 ++++---- packages/experimental/schedule-bundle/README.md | 6 +++--- packages/experimental/schedule-bundle/README.zh.md | 6 +++--- scripts/optional-bundles.spec.ts | 9 ++++++--- 11 files changed, 35 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index 87aa04a960..521567dd7e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -15,5 +15,5 @@ en: a65d84004e48b3ef zh: 17221f08e7a8a89b /agent-note-ship-experimental-capabilities-as-optional-bundles/consequences: - en: 71482af50ad31d54 - zh: 12d4849104842f0d + en: e7fb2a93a89ba9b6 + zh: 37ed9f0a3ca44583 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index 48d3fee0bb..b7139f6d9a 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc ## Consequences -The Official group grows from two to four entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. +The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 37df0d630a..af4048bc5e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 后果 -官方分组从两项增至四项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 +官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml index a95cf939f3..4cc0c9b59b 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md /agent-note-schedule-as-an-opt-in-optional-bundle: - en: 8c2381fd778a9888 - zh: 8877255cb980eea1 + en: d234bf064c5990f1 + zh: c54b941429b4658c /agent-note-schedule-as-an-opt-in-optional-bundle/problem: en: 2409ea3a006ea5d9 zh: dbcda58a4c6b5b9f @@ -12,8 +12,8 @@ en: 360867ff77c6b727 zh: 4eb4b47439c21d5d /agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered: - en: d12affa9097a1a50 - zh: 4190d195c6a6eb8d + en: cef562365cc8ab3a + zh: 2d43ba5839b97474 /agent-note-schedule-as-an-opt-in-optional-bundle/consequences: en: 8ea1d9aaf25f1702 zh: 32e721347ea55475 diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md index 4ea60cb9cd..c06bd25549 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md) +> Supersedes [Schedule in the shipped Web composition](../../archived/architecture/2026-09-24-web-default-schedule-composition.md): the three rows now ship disabled, and switching them on is this bundle's job. + ## Problem `packages/bundle/web-app/cordis.patch.yml` inserts `time-context`, `schedule`, and `ui-schedule` with `disabled: true`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. The rows have no switch on the product surface; a deployment that wants them edits a profile patch layer or passes a `--patch` overlay, and both are configuration files a person using the shipped Web profile cannot reach. @@ -20,9 +22,9 @@ Enabling the bundle turns on `time-context` (a per-step clock reading with the s **Ship Schedule on by default.** Every Web session then pays four tool schemas in each request header and one durable user message per eligible step, and a conversation that never creates a reminder pays both costs. The arrangement also left the capability without an off switch on the product surface. -**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. It cannot re-insert the rows either: `applyEntryPatches` appends an `insert` list without de-duplicating ids, so an overlay that inserts `time-context` and `schedule` mounts each Host plugin a second time. +**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. It also cannot set fields on the shipped rows: `applyEntryPatches` appends an `insert` list without de-duplicating ids, and the Loader keeps one entry per id with the last declaration winning, so an overlay that re-declares `time-context` and `schedule` replaces the Web bundle's rows instead of overriding them. -**Extract the three rows out of the Web composition into the bundle.** The bundle would insert the rows instead of overriding them, so any profile that also inserts a row would mount the same Host plugin twice for the same reason. The disabled rows are what an override-style switch targets. +**Extract the three rows out of the Web composition into the bundle.** The bundle would insert the rows instead of overriding them, so a profile that also declares one of those ids would have two declarations for it, of which the Loader keeps only the last. The disabled rows are what an override-style switch targets. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md index ed63f8e9c9..4a92067a46 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文 +> 取代[默认 Web 组合中的 Schedule](../../archived/architecture/2026-09-24-web-default-schedule-composition.md):这三行现在默认禁用,打开它们是此 bundle 的职责。 + ## Problem `packages/bundle/web-app/cordis.patch.yml` 以 `disabled: true` 插入 `time-context`、`schedule` 与 `ui-schedule`,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。这些条目在产品界面上没有开关;想要它们的部署要改 profile patch 层或传 `--patch` overlay,而两者都是使用随发行版 Web profile 的人无法触及的配置文件。 @@ -20,9 +22,9 @@ Status: implemented **默认发布 Schedule。** 那样每个 Web 会话都要在每个请求头中带上四个工具 schema,并在每个符合条件的步骤追加一条持久 user 消息,从不创建提醒的对话也要承担这两项成本。这种安排还让该能力在产品界面上没有关闭开关。 -**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。它也无法重新插入这些行:`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,插入 `time-context` 与 `schedule` 的 overlay 会把每个 Host 插件挂载两次。 +**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。它也无法给出厂条目设值:`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,而 Loader 每个 id 只保留一个条目、最后一个声明生效,因此重新声明 `time-context` 与 `schedule` 的 overlay 会替换 Web Bundle 的条目,而不是覆盖它们。 -**把这三行从 Web 组合中抽出、移入 bundle。** 这样 bundle 会插入这些行而不是覆盖它们,任何同样插入某行的 profile 都会因同样的原因把同一个 Host 插件挂载两次。按需开关针对的正是这些禁用行。 +**把这三行从 Web 组合中抽出、移入 bundle。** 这样 bundle 会插入这些行而不是覆盖它们;某个 profile 若也声明了这些 id,同一个 id 就会有两份声明,而 Loader 只保留最后一份。按需开关针对的正是这些禁用行。 ## Consequences diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 7569106fc6..192feb1142 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -223,8 +223,9 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass return ok(undefined) }) mock.unary('session/list', () => ok({ items: structuredClone(sessions) })) - // The shipped Web composition mounts the Schedule client, whose catalog and - // per-Session list read empty here: these scenarios drive no reminder. + // The shipped bundle layers this graph composes carry the `ui-schedule` row + // disabled, so the Schedule client does not mount and no case here reads the + // catalog or the per-Session list; both stay declared empty. mock.unary('schedule/catalog', () => ok([])) mock.unary('schedule/list', () => ok([])) mock.unary('session/projections', (request: unknown) => { diff --git a/packages/experimental/schedule-bundle/README.i18n.yaml b/packages/experimental/schedule-bundle/README.i18n.yaml index efbd18b82e..c05ec3e55b 100644 --- a/packages/experimental/schedule-bundle/README.i18n.yaml +++ b/packages/experimental/schedule-bundle/README.i18n.yaml @@ -18,8 +18,8 @@ en: ffaa4b18e10a1df9 zh: b675fd8195ebb057 /deepseek-ai-dsh-experimental-schedule-bundle/understand-the-implementation: - en: 744024d71c8fca24 - zh: 22f939f9484e1ad7 + en: b17882af7c7eaf2c + zh: bfc71d0152557af5 /deepseek-ai-dsh-experimental-schedule-bundle/further-exploration: en: 99a29dddf9805540 zh: 13a6df3ee31abfc1 @@ -39,8 +39,8 @@ en: 0b01d8c40b422f5b zh: 57fede4db59a62b8 /deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work: - en: 6f2812ce847b2654 - zh: ac8ef109662f67c0 + en: a4a2674fb3c5a101 + zh: cb4c54ba37730708 /deepseek-ai-dsh-experimental-schedule-bundle/known-limitations-and-deferred-work/dev-note: en: 0225ba087c5266f3 zh: 4e706f1b2b984e63 diff --git a/packages/experimental/schedule-bundle/README.md b/packages/experimental/schedule-bundle/README.md index 7b8feb04ee..02c0e1740f 100644 --- a/packages/experimental/schedule-bundle/README.md +++ b/packages/experimental/schedule-bundle/README.md @@ -35,7 +35,7 @@ Open Plugins in the Web sidebar and enable Scheduling and time context, marked b
Maintainer details — click to expand -`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the tail of the profile's `dsh.profile.bundles` list; bundle layers apply in list order, so the patches reach the rows only because that list places `@deepseek-ai/dsh-web-app` before this package. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state. +`cordis.patch.yml` carries three id-targeted patches, each setting `disabled: false` on a row the Web bundle already inserts. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names this package and `apps/cli` depends on it, so every installation ships it switched off and the plugin manager offers it in the Official group. Selecting it appends the bundle to the tail of the profile's `dsh.profile.bundles` list; bundle layers apply in list order, so the patches reach the rows only because that list places `@deepseek-ai/dsh-web-app` before this package. A hand-ordered list with this package first leaves the rows disabled while the Plugins page still shows the switch as on. No runtime invariant companion is published because this configuration-only package owns no mutable runtime state. | File | Role | |---|---| @@ -78,8 +78,8 @@ The tool schemas change the request prefix once when the bundle mounts; each app -- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. In a profile without that bundle the three patches match no row: the loader reports one `patch: entry not found` warning per row and skips it, while the Plugins page still shows the switch as on. -- The bundle's detail page has no per-row switches. The Host reports the three overridden ids under `overrides`, and the Web client renders only the rows a bundle inserts, so opening this card shows "This plugin pack contains no components." The switches for the three rows are on the Official-group list row. +- The switch targets rows `@deepseek-ai/dsh-web-app` inserts. In a profile without that bundle the three patches match no row: the loader reports one `patch: entry not found` warning per row and mounts nothing from this bundle; such a profile mounts no Plugins page either. +- The bundle's detail page has no per-row switches. The Host reports the three overridden ids under `overrides`, and the Web client renders only the rows a bundle inserts, so opening this card shows "This plugin pack contains no components." The switch for the three rows is on the Official-group list row. ----- diff --git a/packages/experimental/schedule-bundle/README.zh.md b/packages/experimental/schedule-bundle/README.zh.md index 4201c46ad5..f28a2466ce 100644 --- a/packages/experimental/schedule-bundle/README.zh.md +++ b/packages/experimental/schedule-bundle/README.zh.md @@ -35,7 +35,7 @@ kind: "package-bundle"
维护者信息 — 点击展开 -`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表末尾;Bundle 层按列表顺序应用,因此这些补丁能命中条目,只是因为该列表把 `@deepseek-ai/dsh-web-app` 排在此包之前。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。 +`cordis.patch.yml` 包含三个按 id 定位的补丁,各自把 Web Bundle 已插入条目的 `disabled` 设为 `false`。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出此包,`apps/cli` 依赖它,因此每次安装都随包携带且默认禁用,插件管理页在 Official 分组中提供它。选中后会把该 Bundle 追加到 profile 的 `dsh.profile.bundles` 列表末尾;Bundle 层按列表顺序应用,因此这些补丁能命中条目,只是因为该列表把 `@deepseek-ai/dsh-web-app` 排在此包之前。手写顺序把此包排在最前时,这些条目保持禁用,而插件管理页仍把该开关显示为已启用。此纯配置包不拥有可变的运行时状态,因此不发布不变量伴随模块。 | 文件 | 作用 | |---|---| @@ -78,8 +78,8 @@ kind: "package-bundle" -- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。在没有该 Bundle 的 profile 中,三个补丁匹配不到任何条目:加载器为每个条目报告一条 `patch: entry not found` 警告并跳过它,而插件管理页仍把该开关显示为已启用。 -- 该 Bundle 的详情页没有逐条目开关。Host 把被覆盖的三个 id 报告在 `overrides` 下,而 Web 客户端只渲染 Bundle 插入的条目,因此打开这张卡片会显示“这个插件包不包含任何组件。”。三个条目的开关在 Official 分组列表行上。 +- 此开关针对 `@deepseek-ai/dsh-web-app` 插入的条目。在没有该 Bundle 的 profile 中,三个补丁匹配不到任何条目:加载器为每个条目报告一条 `patch: entry not found` 警告,且不从该 Bundle 挂载任何东西;这样的 profile 也不挂载插件管理页。 +- 该 Bundle 的详情页没有逐条目开关。Host 把被覆盖的三个 id 报告在 `overrides` 下,而 Web 客户端只渲染 Bundle 插入的条目,因此打开这张卡片会显示“这个插件包不包含任何组件。”。控制这三个条目的开关在 Official 分组列表行上。 ----- diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index 9218b4ed98..aa7c1b9dd4 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -49,13 +49,16 @@ describe('optional bundles', () => { expect(typeof row.id).toBe('string') expect(ids.has(row.id)).toBe(true) } - // An id-targeted patch reaches a row another layer inserted: the composed row keeps that row's package - // name, appears once, and carries the patched values. + // One top-level row per id: a duplicate declaration leaves the Loader with the last one, silently + // replacing the layer that declared the id first. + const topLevelIds = composed.flatMap(entry => typeof entry.id === 'string' ? [entry.id] : []) + expect(topLevelIds).toHaveLength(new Set(topLevelIds).size) + // An id-targeted patch reaches a row another layer inserted: the id resolves to exactly one top-level + // row that keeps its declared package name. for (const patch of patches) { if (patch.insert !== undefined || typeof patch.id !== 'string') continue const matches = composed.filter(entry => entry.id === patch.id) expect(matches).toHaveLength(1) - expect(matches[0]).toMatchObject({ ...patch }) expect(typeof matches[0]?.name).toBe('string') } }) From 92690a5a3bd96bc36c281eb3d7d347ee971d3e61 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:14:40 -0700 Subject: [PATCH 020/102] fix(ui-settings-plugin-inventory): align and simplify plugin cards Cards in one grid row now share a height, with title rows and descriptions starting at the same offsets. The entry id shows only when it differs from the title, as a code chip in the card's bottom-left corner; tags mark only states other than plainly enabled; descriptions clamp to two lines until the card expands; the preset group starts open; loading shows skeleton cards. Group headers share one height, the subtitle separator is evenly spaced, the title weight drops to 500, 12.5px text moves to 12px, and a container query on the tab switches the grid to one column. --- .../README.i18n.yaml | 16 +-- .../ui-settings-plugin-inventory/README.md | 10 +- .../ui-settings-plugin-inventory/README.zh.md | 10 +- .../PluginInventorySettingsTab.module.css | 135 +++++++++++++----- .../src/client/PluginInventorySettingsTab.tsx | 57 +++++--- .../src/client/locales.ts | 2 +- .../tests/components.client.spec.tsx | 83 ++++++----- 7 files changed, 204 insertions(+), 109 deletions(-) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 6b7ff6a2bf..b69016100b 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -9,8 +9,8 @@ en: 463eecf002ec0f54 zh: b8dc6003ddccd14a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/summary: - en: 56f5349b6e502525 - zh: 691a0fe90b739e9d + en: f52353cf99816eb8 + zh: e172c1751289882f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: e615aaa891b5055f - zh: 38769e763c8e54ca + en: 6ccd8a53e3205f24 + zh: 1d0ad59bfb7786e4 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/retrying-a-failed-read: - en: 2859449aaae5ab59 - zh: 06380059ae429026 + en: 673c7574e14078be + zh: b093e897f0341973 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation: en: cab883d26642c932 zh: 54e7321bd781b400 @@ -33,8 +33,8 @@ en: e4792880c5916ce0 zh: 4c8171a446dd153f /deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation/rendering: - en: b9aa850e5f101b68 - zh: 8a29025f19f6ce37 + en: 779146e3d3732803 + zh: 0dfafa328dfeda75 /deepseek-ai-dsh-client-ui-settings-plugin-inventory/further-exploration: en: ae3819f0c5da95bd zh: 40972ab96d5c95e2 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 7353f14d11..d6127c9ed9 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. +The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, open by default, then the collapsed global inventory; a search opens both. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. ## Table of Contents @@ -31,9 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s ### Reading a card -Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. +Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. -The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. The chip's hover text, search, and expanded details retain the complete id, and so does the accessible name unless the id equals the title. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher @@ -41,7 +41,7 @@ The switcher is the same selector-pill-plus-menu control the General settings ro ### Retrying a failed read -A failed read renders the shared error marker inside the tab; loading and current-page synchronization use the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. +A failed read renders the shared error marker inside the tab. Loading shows skeleton cards in the card grid, and current-page synchronization uses the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details. The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page. @@ -61,7 +61,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. +Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and, after removal of a leading composition `include:` marker, supplies the collapsed id chip when the result differs from the title, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 9078f8074a..a084d93919 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 +**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;预设分组默认展开,全局分组默认收起,搜索期间两组都展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 ## 目录 @@ -31,9 +31,9 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 +每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 -稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。代码标签的悬停提示、搜索与展开详情保留完整 id;除非 id 与标题相同,无障碍名称也保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 @@ -41,7 +41,7 @@ kind: "package-reference" ### 重试失败的读取 -读取失败会在标签页内渲染共享 error 标记;加载与当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 +读取失败会在标签页内渲染共享 error 标记。加载时在卡片网格中显示骨架卡片,当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。 插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。 @@ -61,7 +61,7 @@ kind: "package-reference" ### 渲染 -行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 +行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中;去掉开头的组合 `include:` 标记后若与标题不同,则作为收起卡片的 id 代码标签,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 87b29d442f..1d732ae93d 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -1,4 +1,6 @@ +/* The tab's own width, not the window's, decides the card columns below. */ .section { + container: plugin-inventory / inline-size; display: flex; flex-direction: column; gap: 14px; @@ -7,7 +9,6 @@ color: var(--dsw-alias-label-primary); } -.catalogHeading h3, .status, .failure p { margin: 0; @@ -85,37 +86,21 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent); } -.catalogHeading { - display: flex; - align-items: baseline; - gap: 7px; - padding: 0 2px; -} - -.catalogHeading h3 { - font-size: 13px; - line-height: 20px; - font-weight: 600; -} - -.catalogHeading span { - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); - font-variant-numeric: tabular-nums; -} - .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; gap: 10px; margin: 0; padding: 0; list-style: none; } +/* Cards stretch to their grid row's height. The button fills a stretched card: + the title row and description stay at the top, in line with the other card's, + and the spare height sits above the entry id chip or at the bottom. */ .card { + display: flex; + flex-direction: column; min-width: 0; overflow: hidden; border: 0.5px solid var(--dsw-alias-settings-card-stroke); @@ -127,11 +112,21 @@ border-color: var(--dsw-alias-border-l3); } +/* The other card in an expanded card's row keeps its own height instead of + stretching alongside the details. With two columns each row starts at an odd + position; in one column the matched card sits alone in its row, unaffected. */ +.card:nth-child(odd)[data-open='true'] + .card, +.card:nth-child(odd):has(+ .card[data-open='true']) { + align-self: start; +} + .cardContent { box-sizing: border-box; display: flex; + flex: 1 1 auto; flex-direction: column; align-items: stretch; + gap: 2px; width: 100%; min-height: 52px; border: 0; @@ -173,11 +168,35 @@ min-width: 0; font-size: 14px; line-height: 20px; - font-weight: 600; + font-weight: 500; } -/* The Loader entry id keeps one size in the card subtitle and the row it expands into; - the collapsed subtitle stays on the quieter label tier, while the expanded row carries +/* Two lines at most while collapsed; the expanded card shows the full text. */ +.cardDescription { + display: -webkit-box; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.card[data-open='true'] .cardDescription { + display: block; +} + +/* The entry id is metadata, not prose: a code chip in the card's bottom-left + corner whose left edge lines up with the title. */ +.cardMeta { + display: flex; + margin-top: auto; + padding-top: 6px; +} + +/* The Loader entry id keeps one size in the card chip and the row it expands into; + the collapsed chip stays on the quieter label tier, while the expanded row carries the card's primary value. */ .cardIdentity, .entryValue { @@ -188,15 +207,19 @@ .cardIdentity { display: block; - width: 100%; - color: var(--dsw-alias-label-tertiary); + box-sizing: border-box; + max-width: 100%; + border-radius: var(--dsw-radius-xs); + padding: 1px 6px; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); } .cardTrailing { display: inline-flex; flex: none; align-items: center; - gap: 7px; + gap: 8px; color: var(--dsw-alias-label-tertiary); } @@ -213,11 +236,13 @@ gap: 10px; } +/* Every group header takes the preset switcher's 36px height, so each subtitle + sits the same distance below its title. */ .groupTitleRow { display: flex; align-items: center; gap: 8px; - min-height: 32px; + min-height: 36px; } .group + .group { @@ -266,7 +291,7 @@ .groupSub { display: flex; flex-wrap: wrap; - gap: 4px 8px; + row-gap: 4px; margin: -6px 0 0 20px; color: var(--dsw-alias-label-tertiary); font-size: 12px; @@ -274,6 +299,13 @@ font-variant-numeric: tabular-nums; } +/* A middle dot with equal space on both sides separates the subtitle's parts. */ +.groupSub > * + *::before { + content: '·'; + padding: 0 6px; + color: var(--dsw-alias-label-tertiary); +} + .failedCount { color: var(--dsw-alias-state-error-primary); } @@ -330,7 +362,7 @@ padding: 8px 10px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); color: var(--dsw-alias-state-error-primary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; overflow-wrap: anywhere; white-space: pre-line; @@ -343,7 +375,7 @@ gap: 4px 8px; margin: 0; color: var(--dsw-alias-label-tertiary); - font-size: 12.5px; + font-size: 12px; line-height: 18px; } @@ -353,10 +385,35 @@ background: transparent; color: var(--dsw-alias-state-business-primary); font: inherit; - font-size: 12.5px; + font-size: 12px; cursor: pointer; } +/* Loading placeholder in the cards grid, sized like a card with one description + line; its bars pulse through the shared skeleton token as the input-trigger + menu's pending rows do. */ +.skeletonCard { + display: flex; + flex-direction: column; + gap: 8px; + border: 0.5px solid var(--dsw-alias-settings-card-stroke); + border-radius: var(--dsw-radius-xl); + padding: 15px 14px; + background: var(--dsw-alias-settings-card-fill); +} + +.skeletonBar { + width: 40%; + height: 14px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); +} + +.skeletonBar + .skeletonBar { + width: 80%; + height: 12px; +} + .enabledIn { display: flex; @@ -430,9 +487,19 @@ .chevron { transition: transform 140ms var(--ds-ease-in-out); } + + .skeletonBar { + animation: plugin-inventory-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; + } } -@media (max-width: 680px) { +@keyframes plugin-inventory-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@container plugin-inventory (max-width: 520px) { .cards { grid-template-columns: minmax(0, 1fr); } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index c5064e5441..a86672f79f 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -58,6 +58,9 @@ const PHASE_KEYS = { unloading: 'unloading', } satisfies Record, PluginInventoryLocaleKey> +/** Placeholder cards the loading skeleton lays out in the cards grid. */ +const SKELETON_CARDS = [0, 1, 2, 3] as const + /** Localized accessible label for one root Fiber phase. */ function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) @@ -77,6 +80,11 @@ function entrySubtitle(entryId: string): string { return entryId.replace(/^include:/, '') } +/** Accessible card name: the title, the entry id unless it repeats the title, then the enablement state. */ +function cardLabel(title: string, entryId: string | null, state: string): string { + return [title, ...entryId === null || entryId === title ? [] : [entryId], state].join(', ') +} + /** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */ function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) { const title = row.meta?.title @@ -127,6 +135,7 @@ function PluginCard({ const open = expanded === rowKey const detailId = `plugin-details-${encodeURIComponent(rowKey)}` const descriptionId = useId() + const subtitle = entryId === null ? null : entrySubtitle(entryId) return (