diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml index 5de2a3a586..9de126a4e4 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml @@ -9,11 +9,11 @@ en: 8578b584bda3abdc zh: 85dcf44aab2cfd51 /agent-note-desktop-welcome-window-material/decision: - en: df31ede930b7c3c0 - zh: c7fc4272bc6b2d85 + en: df7f9dc0c48769ab + zh: 5e667c736dd58a1c /agent-note-desktop-welcome-window-material/alternatives-considered: - en: 6e095dceffe75fa8 - zh: 679a6026afd735a7 + en: cf42e200b1d0f00a + zh: de96b5647f5ffb23 /agent-note-desktop-welcome-window-material/consequences: - en: 8ab73e6c67f4ce88 - zh: 6b1edad02f90ba5b + en: fd2533d63d9a1e4e + zh: 5cb602962608f5de diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md index 4ee5f6d293..e3961481f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md @@ -12,7 +12,7 @@ The desktop welcome design blurs the desktop behind its entire window. Chromium The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its independently bundled React renderer supplies the entry, account sign-in states and API-key form. It shares the `StateDot` loading component with the Web UI without booting the Web plugin graph. The Desktop build emits local JavaScript and CSS under `lib/welcome`, included in packaged applications; the document keeps its network-denying content security policy. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics. -Cold startup opens the entry when neither an account credential nor a model API key is stored. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. Startup reads account presence and API-key configuration independently. Account sign-in enters the workspace; sign-out reopens the welcome window only when no model API key remains configured. +Cold startup opens the entry when neither an account credential nor a model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a credential-entry completion flag. The next process launch checks credentials again. The Desktop preload marker suppresses the automatic Web credential step and welcome notice; settings and explicit API-key editing remain available. The [Desktop onboarding decision](../feature/2026-09-16-desktop-onboarding.md) owns the separate device-local introduction after account login. Other native shells can disable only the credential step through the Models Host plugin's `credentialOnboarding` page-injection value. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The [account provider](2026-09-14-deepseek-account-login.md) supplies login and sign-out state while preserving independently stored API keys. The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins. @@ -24,7 +24,7 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th **A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect. -**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. +**Persisting a credential-entry completion flag.** A native credential skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. The account introduction's separate completion flag does not bypass this authentication decision. **Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner. @@ -34,4 +34,4 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th ## Consequences -Native blur strength and font fallback vary by system. macOS uses menu vibrancy with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. Native materials own the blur strength. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out preserves independently configured API keys. Onboarding presentation produces no Session events. +Native blur strength and font fallback vary by system. The main window uses the shared sidebar vibrancy; full-window onboarding retains a transparent renderer without an additional tint. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and built-Host acceptance covers credential persistence across restarts. Account sign-out preserves independent API keys. Onboarding presentation produces no Session events. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md index 1689af8481..17504368e8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md @@ -12,7 +12,7 @@ Status: implemented [Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。独立打包的 React 渲染器提供入口、账号登录状态和 API Key 表单。它与 Web UI 共用 `StateDot` 加载组件,不启动 Web 插件图。Desktop 构建将本地 JavaScript 和 CSS 输出到 `lib/welcome`,并纳入应用安装包;文档继续使用禁止网络访问的内容安全策略。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。 -冷启动在既无已存账号凭证、也无已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。启动分别读取账号凭证是否存在和 API Key 配置状态。账号登录后进入工作区;退登后仅在没有已配置的模型 API Key 时重新打开欢迎窗口。 +冷启动在账号凭证与模型 API Key 均未配置时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或凭证入口完成标记。下次进程启动时会重新检查凭证。Desktop preload 标记抑制自动 Web 凭证步骤和欢迎须知;设置与显式 API Key 编辑仍然可用。[Desktop 引导决策](../feature/2026-09-16-desktop-onboarding.zh.md)负责账号登录后独立的设备本地介绍流程。其他原生壳可以通过 Models Host 插件注入页面的 `credentialOnboarding` 值仅禁用凭证步骤。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。[账号提供方](2026-09-14-deepseek-account-login.zh.md)提供登录与退出登录状态,同时保留独立存储的 API Key。 生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。 @@ -24,7 +24,7 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在 **仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。 -**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。 +**持久化凭证入口完成标记。** 跳过原生凭证入口只对当前进程有效;保留标记会在用户仍无凭证时抑制下次冷启动选择。账号介绍流程的独立完成标记不会绕过此认证判断。 **同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。 @@ -34,4 +34,4 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在 ## 后果 -原生模糊强度和字体回退因系统而异。macOS 使用 menu vibrancy,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。模糊强度由原生材质控制。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登保留独立配置的 API Key。引导展示不会生成 Session 事件。 +原生模糊强度和字体回退因系统而异。主窗口沿用共享的 sidebar vibrancy;全窗引导保持渲染器透明,不额外叠加底色。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收覆盖凭证跨重启持久化。账号退出登录保留独立的 API Key。引导展示不会生成 Session 事件。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml index 6580682ec7..4bef95dc24 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml @@ -3,20 +3,20 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md /agent-note-deepseek-account-browser-login: - en: 666b3732ccc1c2fb - zh: 37dffad1bb4e26ae + en: 8328647bcdf8a666 + zh: 445a07c7ec4bcca4 /agent-note-deepseek-account-browser-login/problem: en: 67c00444d9dcf06d zh: ef294910952ba052 /agent-note-deepseek-account-browser-login/decision: - en: db9297d04d25d0f6 - zh: 20938f92c1563146 + en: 3b6c781b5374a8af + zh: 2a3fb7c63dfdaf83 /agent-note-deepseek-account-browser-login/alternatives-considered: - en: b7694861ea5282b6 - zh: a5ff57bdcc9fcf60 + en: 3c2dd2c948ffb51b + zh: d019197df0ee3dcf /agent-note-deepseek-account-browser-login/consequences: - en: 348fcdb33c193cf2 - zh: ad82da41941d3afb + en: a6eff4a0350e9018 + zh: 2e3200ca089023ab /agent-note-deepseek-account-browser-login/verification: en: 881edcb454b941a6 zh: 7dc5a9b7a066f623 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md index 165cd2d0a2..70ed73f265 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-14-deepseek-account-login.zh.md) -Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default. +Sign-in captures the initiating UI's client metadata per attempt so browser authorization follows the Desktop or Settings language without depending on the Platform default. ## Problem @@ -20,11 +20,11 @@ Authorization registers a temporary /oauth/callback route on the Host webServer. Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses. -The Desktop profile supplies the native platform for Host API headers. Account and update-policy clients share one mapping to avoid different client identities for the same installation. This identity does not depend on a login attempt, since stored grants also issue requests after restart. Embedded pages use the same platform identity and retain their other separately configured headers. +Platform client identity belongs to the calling UI rather than to the Host. Every account operation carries the caller's client version, active language, and current UTC offset, and the provider derives the five Platform client headers for that call from them plus the composed native platform. Client identity headers override same-named deployment headers, so one Host shared by several callers reports the UI that made each request instead of the last one it saw, and the update-policy client keeps the same header meanings for the installed shell. PlatformSession deliberately omits caller identity; the embedding client composes its own five headers and limits them to the configured origin. ## Alternatives considered -Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected. +Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. Remembering one client identity in the Host would mislabel every other caller that shares it or reaches it through a forward. These alternatives are rejected. ## Consequences @@ -34,7 +34,7 @@ API keys and account grants remain separately stored. Account tokens have no pro The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs. -Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session. +Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and clears browser authentication; [account-scoped page storage](2026-09-22-platform-browser-storage.md) survives. Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs. @@ -56,6 +56,8 @@ The embedded Platform document stays hidden during loading because native child Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap. +Bonus notices use Platform-provided text and order identities. Reads occur when an account becomes active — signed-in startup and successful sign-in — and once per Settings entry or return from the top-up view, with no periodic polling. The card is painted as soon as the server offers it, including underneath the open Settings panel, so display is never deferred. The client acknowledges an order after a presented frame or the user closes it; nothing is written to browser storage, so a later sign-in re-displays an order the server still offers. Failed acknowledgments retry with capped exponential backoff for the rest of the signed-in session. Platform acknowledgments suppress delivery on other devices, but concurrent displays before acknowledgment are possible because the API does not reserve delivery. Fetch success alone never records a display. + The Host binds private Platform sessions to the account provider lifetime. Removal or watch termination clears Electron’s session; replacement subscribes to the new provider, and disposed reads cannot publish old credentials. The client distinguishes plugin disposal from terminal account-stream failure. RemoteStream aborts its signal after either outcome, so the plugin owns a separate disposal flag to preserve failure feedback while suppressing reports after unload. diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md index 5e09c0a17c..5b0df92064 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-14-deepseek-account-login.md) | 中文 -登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。 +登录申请保存发起界面的客户端元数据,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。 ## 问题 @@ -20,11 +20,11 @@ Status: implemented 平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。 -Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新策略客户端共用一份映射,避免同一安装实例发送不同的客户端标识。此标识不依赖登录尝试,因为重启后保存的授权仍会发起请求。内嵌页面使用同一平台标识,并保留独立配置的其他请求头。 +Platform 客户端身份属于发起调用的界面,而不属于 Host。每个账号操作都携带调用方的客户端版本、当前语言和当前 UTC 偏移,provider 据此与组合出的原生平台推导该次调用的五个 Platform 客户端请求头。客户端身份请求头覆盖部署配置中的同名请求头,因此一个被多方共享的 Host 报告的是每次请求的发起界面,而不是它上一次见到的调用方;更新策略客户端对已安装的壳沿用同一组请求头语义。PlatformSession 有意不携带调用方身份;内嵌客户端自行组装同样的五个请求头,并仅在配置来源发送。 ## 替代方案 -把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。 +把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。在 Host 中记住一份客户端身份,会让共享该 Host 或经转发接入的其他调用方被标错。这些方案均被拒绝。 ## 影响 @@ -34,7 +34,7 @@ API Key 与账号记录独立保存。账号 token 没有主动刷新流程; 账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。 -Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。 +Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档并清理浏览器认证;[账号范围内的页面存储](2026-09-22-platform-browser-storage.zh.md)会保留。 支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。 @@ -56,6 +56,8 @@ exchange 的 user 数据在凭证提交后供首次资料读取使用,展示 独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。 +赠金通知使用 Platform 提供的文案和订单标识。账号进入登录态(已登录启动、登录成功)时读取一次,每次进入设置页或从充值视图返回时再读取一次,不进行定时轮询。服务端下发的通知立即绘制,包括在打开的设置面板之下,因此展示从不延后。客户端在通知卡完成一帧绘制后或用户关闭卡片时确认订单。不写入任何浏览器存储,因此之后再次登录会重新展示服务端仍在下发的订单。确认失败会在本次登录会话内按带上限的指数退避重试。Platform 的确认记录阻止其他设备继续获取通知,但接口不预留展示资格,因此确认前仍可能并发展示。仅获取成功不会记录为已展示。 + Host 将私有 Platform 会话绑定到账号提供者的生命周期。提供者移除或订阅结束时清空 Electron 会话;替换后订阅新的提供者,已释放的读取不得发布旧凭据。 客户端区分插件卸载和账户状态流的终止错误。RemoteStream 在两种情况下都会中止其信号,因此插件独立记录卸载状态,保留错误反馈并抑制卸载后的报告。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml new file mode 100644 index 0000000000..945359e6e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-16-host-schedule-storage.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-16-host-schedule-storage.md +/agent-note-host-owned-scheduled-messages: + en: 11854b220af809ea + zh: 0f0575de37107ed8 +/agent-note-host-owned-scheduled-messages/problem: + en: 2c774d05ae75784f + zh: 619ba7f281b13233 +/agent-note-host-owned-scheduled-messages/decision: + en: 4a38d9bd933241b0 + zh: b246c74f68e92a25 +/agent-note-host-owned-scheduled-messages/alternatives-considered: + en: 21d7ec9a9ff3cbf2 + zh: 93d03b8b3012aa7e +/agent-note-host-owned-scheduled-messages/consequences: + en: 5b8d0a26024f795e + zh: c4d6f36d54783c47 diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md new file mode 100644 index 0000000000..f063dba6e4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md @@ -0,0 +1,65 @@ +# Agent Note: Host-owned scheduled messages + +Status: implemented + +English | [中文](2026-09-16-host-schedule-storage.zh.md) + +## Problem + +Scheduled messages must remain discoverable after restart without loading every Session. A timer attached to a live Agent cannot dispatch a task whose Session remains cold. Users also need to inspect pending and delivered tasks without activating their conversation; removing a task after delivery loses that management record. + +## Decision + +The existing Schedule package owns one Host storage domain containing each task's original Session id, reminder record, `active` or `inactive` status, latest delivery receipt, and saved delivery history. The domain is authoritative for retained tasks. The Host scheduler restores timers only for active tasks; a due task uses the Session controller's existing resolution operation and queues an ordinary follow-up in the original Session. Querying and deleting tasks do not resolve an Agent. Stored records lacking status normalize to `active` without scanning or recreating historical Sessions. + +The task domain declares the whole-unit layout. Tasks are authoritative, so the JSON backend rejects an unreadable file, malformed document, unsupported version, or invalid task as a complete load failure instead of interpreting it as deletion. Failed recovery leaves `schedule.json` unchanged, while an initially absent file opens as an empty domain; repairing the file preserves task identity. `Service.init` waits for both storage validation and runtime setup, management methods wait for the same initialization result, and an unloading owner releases late-acquired storage without starting dispatch. Dispatch admission is separate from initialization: an ancestor can close Agent admission after a task mutation commits but before runtime cleanup starts. That refusal is logged without automatic retry and does not turn the persisted mutation into a failed management result. Invalid storage and failed cleanup registration still reject startup. + +This supersedes the Session-log authority, live-only activation, and read-only catalog decisions in the [Session-local reminders note](../feature/2026-08-05-durable-web-schedule.md). Its explicit absolute-time validation, fixed-rate arithmetic, and distinction between queue admission and model completion remain applicable. Historical `schedule/change` creation, fold, and invariant types remain restricted to After, At, and Every through `LegacyScheduleRecord`; a separate Host decoder accepts the current `ScheduleRecord`, including Daily, Weekly, and Cron. The Host record decoder requires a stored `title` that is non-empty after trimming, already trimmed, and at most 120 characters, and the strict task schema rejects the whole domain open instead of dropping such a task. The historical change decoder tolerates an absent `title` so an already-written Session log stays readable, and it applies the same three checks when the member is present. The storage domain remains version 1. Historical events do not populate that domain, and existing `at` tasks are not converted into daily or weekly rules. Loading a Session with active legacy reminders warns that they must be recreated. + +A plugin-sourced `followup()` synchronously appends the message to the Session inbox. Successful Session flush acknowledges durable delivery. One task-record put stores `lastDelivery`, appends a delivery record with the occurrence target, acknowledgment time, actual message id, and instruction snapshot, and updates the one-shot's `inactive` status or the recurring task's next target and status. Task state and its saved history therefore share one commit. Inactive tasks remain stored and never schedule another delivery; explicit deletion stops future delivery and removes the row together with its saved records. These records acknowledge inbox delivery, not user receipt or model execution completion. + +New tasks initialize an empty `deliveryHistory`. A task without that optional field exposes its existing latest receipt, if any, and marks earlier records unavailable. Reading never rewrites the old task or invents the lost history or instruction snapshot. Its next acknowledged delivery preserves that available receipt and appends the new one. Strict decoding rejects duplicate message ids and disagreement between the latest receipt and the last history entry. History follows append order, not wall-clock sorting, so clock rollback does not reverse acknowledged deliveries. + +A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages. + +Daily is a fourth mutually exclusive creation selector, Weekly a fifth, and Cron a sixth, none a conversion of `every_seconds` or `at`. Weekly selects every local date whose ISO weekday belongs to its stored set; the decoder rejects an unnormalized stored set, and the selector rejects an empty, repeated, out-of-range, or non-integer one. Their first targets are strictly future. Missing local times and dates are skipped; overlaps select the earlier instant once per date. The next target is after the delivery decision and on a local date later than the selected occurrence's date. Only UTC targets are restricted to years 0001–9999; local dates may cross into year 0 or 10000 at the edges. After Session restoration, the runtime rechecks due members against the current clock so a rollback does not admit future tasks. + +The maintained `@js-temporal/polyfill` 0.5.1 resolves local dates and overlap/gap behavior using the Host's IANA data; Schedule does not own offset-sampling logic or add another package layer. Each daily or weekly record stores normalized `HH:mm:ss.SSS` time, its zone, its weekday set where applicable, and a committed next UTC instant. Decoding and restart do not recompute that instant. Valid zone aliases remain readable across canonical-name changes, while subsequent targets use current Host time-zone data. + +Model `schedule_list`, Remote `list`, and the Session header's right-aligned reminder clock show only active tasks. The independent Automation tasks page opens from the sidebar and reads active and inactive Host tasks through the read-only Remote `catalog` method, without activating Sessions or reading their history. That clock is absent while the Session has no active reminder, opens a single task's detail directly when exactly one remains, and otherwise lists them so one can be chosen; both paths open one right-Sidebar task tab that renders the same detail component the Automation tasks page uses, so neither path selects the main panel. The tab names its task and original Session through its navigation parameters and carries that binding into every mutation, so opening or deleting from it never activates a Session. The page has one All, Enabled, and Inactive status filter row, retained inactive details, latest delivery metadata, and an original-Session control that sits in the detail tab strip beside the task actions. Its header's New action starts a new Session, where the reminder is created by asking the model; the page offers no creation form. Navigation waits for Session and archive metadata, explains unavailable targets, and never restores an archived Session implicitly. Task retention remains independent of original-Session availability. Every, Daily, Weekly, and Cron are recurring rules; the page groups tasks only by status. Daily and Weekly rule labels retain the saved local time, exact rule zone, and stored weekday set rather than substituting the browser zone used for next-target display. Explicit confirmed deletion reuses `delete` with the entry's original Session binding, requiring that the supplied binding match the stored task. This argument check is not independent caller authorization: model tools capture the current Agent's Session, while the global user interface selects the task's original binding. Cross-Session model modification policy remains a product decision, not a consequence of the storage layout. A payload-free change notification invalidates queries, and reconnect queries recover missed notifications. Deletion prevents subsequent dispatch but does not retract a queued message. + +The second detail level separates Rules from Delivery records. Opening records requests `history` with the task's original Session binding and an explicit page limit of 1–100. An exclusive message-id cursor reads older receipts without changing position when new deliveries append. Catalogs retain only latest-receipt metadata rather than duplicating full histories in every task-list response. Missing tasks and unknown cursors are distinct failures, not empty successful history. The records view loads lazily, rejects stale responses, and keeps original-Session navigation separate from inspection. + +Name, instruction, and timing updates share the dispatch and deletion FIFO. Comparing the complete observed record inside that queue prevents a stale draft from overwriting a saved rule or a target advanced by delivery. This avoids adding a persisted revision counter, at the cost of rejecting drafts after normal delivery changes the target. One task put replaces the rule, name, or instruction, preserving identity, original Session binding, status, latest receipt, and saved history. A request may omit the timing change, the name, or the instruction; an omitted field keeps its stored value, and a name or instruction change alone leaves the committed target untouched. Normalized no-ops leave the committed target and stored bytes unchanged. Changed one-shots retain their identity as At records; changed fixed intervals use a new accepted-save anchor; changed wall-clock rules re-resolve their first future target from the same accepted-save sample, and an equivalent normalized rule stays a no-op. A timing change may select a different recurrence kind, and the new rule computes its target exactly as creation would from that accepted-save sample. Deletion removes the row together with its saved records ([consequences of that reversal](2026-09-22-schedule-hard-delete-and-bounded-history.md)), so the runtime never schedules the task again and `history` no longer resolves for its original binding. Inactive tasks are not reopened. + +The page uses the existing Host task domain and scheduler, not a mock engine or a Session projection. The name, the instruction, and the run-time rows edit one local draft for the shown task, and one Save commits that draft as a single update carrying the complete expected record; Cancel restores the stored values, a failed save retains the draft, an unsaved draft survives an authoritative refresh, and a committed save and a failed catalog refresh remain separate outcomes. Pause and execution-history operations remain unsupported. Lifecycle status and latest delivery acknowledgment do not describe model results. + +The client presentation derives from existing persisted data instead of widening a persisted format. The `schedule_create` transcript card reads the created task out of the call's model-visible result JSON, which already serializes the canonical task value, rather than declaring `presentationMeta`: a persisted metadata channel would add a durable field for a card that Session-log replay reconstructs from the result alone, and previously recorded Sessions render the card without a format change. The Sidebar Session-row clock mark occupies the root-scoped `sidebar.session.row.leading` seat rather than requesting a per-Session provider, because a Session-scoped seat would bind every listed Session and thereby activate and retain it; the same root-scoped owner share feeds `sidebar.session.row.hover`, and both read the shared Host catalog snapshot `schedule/catalog()` without reading a Session log. + +Session inbox persistence and the task put occur in separate persistence systems. Inbox persistence precedes the task put to avoid marking delivery before its message exists. A crash or task-write failure after inbox persistence can repeat a message; storing status, target, and receipt together does not make the two stores atomic. Dispatch failures retain the task and produce a diagnostic; there is no independent retry timer. This feature does not create or replace a Goal. + +## Alternatives considered + +**Load every Session at startup.** Session activation is unnecessary for discovering tasks and would couple timer restoration to Agent initialization and conversation size. + +**Introduce separate rule, run, scheduler, and delivery packages.** Durable schedules and their saved inbox receipts support task inspection without independent execution records or model completion tracking. + +**Write history separately from the task.** The domain exposes independent per-record writes, not a multi-record transaction. Keeping history in the task row makes status, next target, latest receipt, and appended history commit together. A separate history store requires an explicit transactional or recovery design before adopting it. + +**Advance before enqueue or promise exactly-once delivery.** Advance-first can lose a message on enqueue failure. Exactly-once delivery needs a shared transaction or a durable idempotency protocol that the two stores do not provide. + +**Import every historical Session automatically.** A startup scan would reintroduce the cold-history dependency. Explicit recreation keeps the persisted authority unambiguous; historical decoding preserves access to existing logs. + +**Represent daily reminders as 86,400-second intervals.** Fixed elapsed time cannot preserve a local wall-clock rule when offsets change. A distinct Daily record retains the rule needed to resolve each later date. Weekly extends the same decision with an explicit weekday set rather than a 604,800-second interval, so a later offset change still resolves each selected local date. + +**Sample offsets manually or recalculate saved targets during decoding.** Temporal supplies maintained calendar and disambiguation behavior without a Schedule-owned offset search. A committed target remains due even when newer time-zone data would resolve the rule differently; only later targets use that data. + +## Consequences + +**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses. + +Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the opt-in overlay remains responsible for enabling Schedule. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported. + +Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim. + +Name, instruction, and timing coverage includes all supported rule kinds, complete-record conflicts, delivery/save ordering, cancellation, normalized no-ops, unchanged-history persistence, pending controls, draft retention, and collision-free task identity. The [built CLI process test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) drives public Remote requests and reloads actual task-file bytes in a fresh Host. Its task and receipt seeds establish retained-data inputs, not real creation, delivery, or recorded-model evidence; those require ordinary Session operations and captured model requests. diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md new file mode 100644 index 0000000000..433c1acb0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Host 拥有的定时消息 + +Status: implemented + +[English](2026-09-16-host-schedule-storage.md) | 中文 + +## Problem + +定时消息必须能在重启后被发现,无须加载每个 Session。挂在 live Agent 上的 timer 无法投递目标 Session 仍为 cold 的任务。用户还需要在不激活对话的情况下查看待投递和已投递任务;投递后删除任务会丢失这条管理记录。 + +## Decision + +现有 Schedule 包拥有一个 Host storage domain,保存每个任务的原始 Session id、提醒记录、`active` 或 `inactive` 状态,最近一次投递回执,以及已保存的投递历史。该 domain 是保留任务的权威来源。Host scheduler 仅为活动任务恢复 timer;到期任务使用 Session controller 现有的解析操作,在原 Session 中排入普通 follow-up。查询和删除任务不会解析 Agent。已存储但缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。 + +任务 domain 声明整 unit 布局。任务是权威数据,因此 JSON 后端会把文件不可读、文档损坏、版本不受支持或任务非法作为整批加载失败,而不是解释为删除。恢复失败不会改写 `schedule.json`,初始不存在的文件则作为空 domain 打开;修复该文件后会保留任务身份。`Service.init` 等待存储校验和运行时初始化,管理方法等待同一初始化结果;拥有方卸载时释放迟到的存储资源,而不开始投递。投递调度准入与初始化分开:任务变更提交后、运行时清理开始前,祖先可能关闭 Agent 准入。此类拒绝会记录日志而不自动重试,不会将已持久化的变更变成失败的管理结果。存储非法和清理注册失败仍拒绝启动。 + +此决策替代[会话内提醒记录](../feature/2026-08-05-durable-web-schedule.zh.md)中的 Session 日志权威来源、仅 live 时激活和只读目录决策。其中显式绝对时间校验、固定速率计算,以及区分排队接纳与模型完成的决策仍然适用。历史 `schedule/change` 的创建、fold 和 invariant 类型通过 `LegacyScheduleRecord` 保持为 After、At 和 Every;独立的宿主解码器接受包含 Daily、Weekly 与 Cron 的当前 `ScheduleRecord`。宿主记录解码器要求已存储的 `title` 去除首尾空白后非空、本身已去除首尾空白且不超过 120 个字符,严格的 task schema 会拒绝整个 domain 的打开,而不是丢弃这样的任务。历史变更解码器容忍缺失的 `title`,以便已写入的 Session 日志仍可读取;该成员存在时,同样按这三项检查校验。storage domain 保持版本 1。历史事件不会填充该 domain,已有 `at` 任务也不会转换为每日或每周规则。加载含有活动旧提醒的 Session 时,会警告需要重新创建提醒。 + +插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后确认持久投递。一次任务记录 put 存储 `lastDelivery`,追加包含发生目标、确认时间、真实消息 id 和指令快照的发送记录,并更新一次性任务的 `inactive` 状态或重复任务的下一目标和状态。因此,任务状态与其保存的历史共用一次提交。未运行任务仍保留在存储中,且不会再次安排投递;显式删除会阻止后续投递,并把该行连同其已保存记录一并移除。这些记录确认收件箱投递,不表示用户收到消息或模型执行完成。 + +新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。 + +重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。 + +Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。 + +受维护的 `@js-temporal/polyfill` 0.5.1 使用宿主 IANA 数据解析本地日期及重叠或缺口;Schedule 不拥有偏移量采样逻辑,也不增加包层级。每日或每周记录保存规范化后的 `HH:mm:ss.SSS` 时间、时区、适用的星期集合及已提交的下一 UTC 时点。解码和重启不会重新计算该时点。规范名称变化后,有效时区别名仍可读取;后续目标使用宿主当前的时区数据。 + +模型 `schedule_list`、Remote `list` 和 Session 顶部右侧的提醒时钟仅显示活动任务。独立的自动化任务页面从侧边栏打开,通过只读 Remote `catalog` 方法读取活动和未运行 Host 任务,不激活 Session 或读取其历史。该时钟在该 Session 没有活动提醒时不显示;恰好有一个时直接打开该任务详情,否则列出提醒供选择。两条路径都打开同一个右侧边栏任务页签,该页签渲染自动化任务页面所用的同一详情组件,因此都不会切换主面板。页签通过导航参数指明任务及其原 Session,并把该绑定带入每个修改操作,因此从页签打开或删除都不会激活 Session。页面提供一行“全部/已开启/未运行”状态筛选,保留未运行任务详情、最近一次投递元数据和固定在位于详情页签条内、与任务操作并排的原 Session 入口。页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面不提供创建表单。导航等待 Session 与归档元数据,解释不可用目标,且不会隐式恢复已归档 Session。任务保留与原 Session 可用性相互独立。Every、Daily、Weekly 和 Cron 均为重复规则;页面只按状态分组。Daily 与 Weekly 规则标签保留保存的本地时间、精确规则时区和所存星期集合,不替换为下一目标显示所用的浏览器时区。显式确认删除复用 `delete`,传入条目的原始 Session 绑定,要求传入绑定与已存任务一致。这项参数校验不是独立的调用者鉴权:模型工具捕获当前 Agent 的 Session,全局用户界面则选择任务的原始绑定。跨会话模型修改政策仍是产品决策,不由存储布局直接决定。无载荷的变更通知使查询失效,重新连接时重新查询以恢复漏掉的通知。删除阻止后续投递,但不撤回已排队消息。 + +第二层详情将“规则”与“发送记录”分开。打开发送记录时,`history` 请求携带任务的原始 Session 绑定,以及 1–100 的显式分页数量。排他的消息 id 游标用于读取更早回执,新投递追加后不会改变其定位。目录仅保留最近一次回执元数据,不在每次任务列表响应中复制完整历史。任务不存在和游标未知属于独立失败,不是查询成功的空历史。记录视图按需加载,拒绝晚到的过期响应,并将原 Session 导航与查看记录分开。 + +名称、指令和时间更新与投递、删除共用 FIFO。在该队列内比较完整的已观察记录,可防止旧草稿覆盖已保存规则或投递已推进的目标。这样无须增加持久版本计数器,代价是正常投递改变目标后也会拒绝旧草稿。一次任务 put 替换规则、名称或指令,保留身份、原始 Session 绑定、状态、最近一次回执和已保存历史。请求可以省略时间变更、名称或指令;省略的字段保留已存储值,仅修改名称或指令不会改变已提交目标。归一化后等价的修改保留已提交目标与存储字节。修改后的一次性任务以 At 记录保留身份;固定间隔改变后以接纳保存时刻重新起算;钟表规则改变后以同一接纳保存时刻重新解析首个未来目标,而归一化后等价的规则不产生变更。时间变更可以选择不同的重复类型,新规则按创建时相同的方式、以同一接纳保存时刻计算目标。删除会移除该行及其已保存记录([该反转的后果](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)),因此运行时不会再调度该任务,`history` 也不再按其原始绑定解析。未运行任务不会被重新启用。 + +页面使用现有 Host 任务 domain 和调度器,不使用 mock 引擎或 Session projection。名称、指令和运行时间行编辑当前任务的同一份本地草稿,一次保存把该草稿作为携带完整预期记录的单个更新提交;取消恢复已存储值,保存失败保留草稿,未保存的草稿会保留到权威刷新之后,保存已提交与目录刷新失败仍是不同结果。暂停和执行历史操作仍不受支持。生命周期状态与最近一次投递确认不描述模型结果。 + +客户端的呈现从已有持久化数据派生,而不扩展持久化格式。`schedule_create` transcript(文本记录)卡片从该调用对模型可见的结果 JSON 中读出创建的任务——该 JSON 已经序列化规范任务值——而不是声明 `presentationMeta`:持久化 metadata 通道会为一张仅凭结果即可在 Session 日志回放中重建的卡片增加持久字段,而早于该卡片录制的 Session 无需格式变更即可渲染它。侧栏 Session 行的时钟标记占用 root 作用域的 `sidebar.session.row.leading` slot,而不是请求逐 Session provider,因为 Session 作用域的 slot 会绑定列表中每个 Session,从而激活并保留它;同一个 root 作用域属主约定也供给 `sidebar.session.row.hover`,两者都读取共享的 Host 目录快照 `schedule/catalog()`,不读取 Session 日志。 + +Session 收件箱持久化和任务 put 发生在两个持久化系统中。收件箱持久化先于任务 put,以免消息尚不存在时就标记已投递。收件箱持久化后崩溃或任务写入失败可能重复发送消息;状态、目标和回执一起存储,并不使两个存储具有原子性。投递失败保留任务并输出诊断;没有独立的重试 timer。此功能不创建或替换 Goal。 + +## Alternatives considered + +**启动时加载每个 Session。** 发现任务不需要激活 Session,这会让 timer 恢复依赖 Agent 初始化和对话大小。 + +**引入独立的 rule、run、scheduler 和 delivery 包。** 持久 schedule 与已保存的收件箱回执足以支持任务查看,无须独立执行记录或模型完成跟踪。 + +**将历史与任务分开写入。** domain 提供独立的逐记录写入,不提供多记录事务。将历史保存在任务行内,可使状态、下一目标、最近一次回执和追加历史一起提交。采用独立历史存储之前,需要明确的事务或恢复设计。 + +**入队前推进,或承诺恰好投递一次。** 先推进可能在入队失败时丢失消息。恰好投递一次需要共享事务或持久幂等协议,这两个存储并不提供。 + +**自动导入全部历史 Session。** 启动扫描会重新引入对 cold 历史的依赖。显式重建使持久化权威来源保持明确;历史解码保留了现有日志的访问能力。 + +**以 86,400 秒间隔表示每日提醒。** 固定经过时长无法在偏移量变化时保留本地钟表规则。独立的 Daily 记录保留了解析每个后续日期所需的规则。Weekly 以显式星期集合而非 604,800 秒间隔延续同一决策,因此偏移量变化后仍能解析每个所选本地日期。 + +**手动采样偏移量或在解码时重新计算已保存目标。** Temporal 提供受维护的日历与消歧行为,无须由 Schedule 实现偏移量搜索。即使较新的时区数据对规则给出不同解析,已提交目标仍然到期;仅后续目标使用该数据。 + +## Consequences + +**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。 + +任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Schedule 仍由显式启用的 overlay 加载。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。 + +必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。 + +名称、指令和时间编辑覆盖范围包括所有支持的规则类型、完整记录冲突、投递与保存的顺序、取消、规范化后的无变更、历史持久化不变、等待期间的控件、草稿保留,以及无碰撞的任务标识。[已构建 CLI 进程测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) 驱动公开 Remote 请求,并在新 Host 中重新加载实际任务文件字节。任务和回执种子只是已保留数据的输入,不是真实创建、投递或模型录制证据;这些证据需要普通 Session 操作与捕获的模型请求。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml index 70fd453cb1..a110a82729 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.i18n.yaml @@ -9,11 +9,11 @@ en: 4b02ed98a500205c zh: e1a075f6a8251059 /agent-note-developer-messages-for-incremental-session-changes/decision: - en: 5fecb076fa475b6d - zh: b59c76222ec00123 + en: 986e4806b1d7da88 + zh: dedecc5248fdd5f3 /agent-note-developer-messages-for-incremental-session-changes/alternatives-considered: en: a9ec42fb650d6cd8 zh: c748181c603e675f /agent-note-developer-messages-for-incremental-session-changes/consequences: - en: 28e92dab15059d0c - zh: 31b4f1955d82c02c + en: e98265bb86e38a2d + zh: a9749feb7d90c22a diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md index 0545dfa434..35697e569d 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md +++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md @@ -20,7 +20,7 @@ Dynamic tool changes need durable, ordered records before providers can preserve The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations. -The producer requirement has one scoped exception: finalized Session V4 reserves the persisted representation needed by issue #4146. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together. +Session V4 reserves the persisted representation needed by issue #4146. Production emission, provider projection, UI presentation, and compaction integration are described in the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md). ## Alternatives considered @@ -32,4 +32,4 @@ The producer requirement has one scoped exception: finalized Session V4 reserves ## Consequences -Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records. +Session V4 persists the tool-change representation for issue #4146. Emission, route projection, DeepSeek serialization, and Chat and Trajectory presentation are owned by the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md). diff --git a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md index 81c53115aa..73e27ad123 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md @@ -20,7 +20,7 @@ Status: implemented V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。 -产生方要求有一个限定例外:已定稿 Session V4 为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。 +Session V4 预留 issue #4146 所需的持久表示。生产发出、提供方投影、UI 展示和压缩集成见[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)。 ## Alternatives considered @@ -32,4 +32,4 @@ V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并 ## Consequences -Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。 +Session V4 持久化 issue #4146 的工具变更表示。发出、路由投影、DeepSeek 序列化及 Chat 和 Trajectory 展示由[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)负责。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml new file mode 100644 index 0000000000..9ed3a48e3b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-20-device-local-shortcut-preferences.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-20-device-local-shortcut-preferences.md +/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation: + en: 0f033ac1e9acec35 + zh: 06f3bf59003446ea +/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/problem: + en: d1d3ef4f6e66e5a7 + zh: 5691f2b106617a86 +/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/decision: + en: 21f8da8d396c3f55 + zh: 46de3df8141bcb0a +/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/alternatives-considered: + en: 202dcaddc6c9c400 + zh: 3f8e3576c2324636 +/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/consequences: + en: 9dcfdef13faa374f + zh: 1951198555fcc9ea diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md new file mode 100644 index 0000000000..3da3d3a29c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md @@ -0,0 +1,27 @@ +# Agent Note: Keep shortcut preferences device-local and commit before activation + +Status: implemented + +English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md) + +## Problem + +Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret. + +## Decision + +[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive. + +Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data. + +## Alternatives considered + +**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences. + +**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls. + +**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions. + +## Consequences + +Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness. diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md new file mode 100644 index 0000000000..3c5420e870 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 快捷键偏好按设备保存,提交后才激活 + +Status: implemented + +[English](2026-09-20-device-local-shortcut-preferences.md) | 中文 + +## 问题 + +快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。 + +## 决策 + +[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。 + +未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。 + +## 考虑过的替代方案 + +**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。 + +**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。 + +**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。 + +## 影响 + +不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml new file mode 100644 index 0000000000..2663220c51 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-20-dynamic-tool-updates.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-20-dynamic-tool-updates.md +/agent-note-active-tool-headers-and-stateful-request-projection: + en: c40dd7568bba391f + zh: 295ce33341e0aab7 +/agent-note-active-tool-headers-and-stateful-request-projection/problem: + en: 97fe58a13135b4a1 + zh: 7490373a8d941da5 +/agent-note-active-tool-headers-and-stateful-request-projection/decision: + en: 0c40a9283650c643 + zh: 91a18d752d09a9ce +/agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: + en: b78785f2a603eba8 + zh: b8c268228da7e2e7 +/agent-note-active-tool-headers-and-stateful-request-projection/consequences: + en: 93c32b795b71a1cc + zh: d42b5c563341c898 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md new file mode 100644 index 0000000000..1b505eec4b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -0,0 +1,37 @@ +# Agent Note: Active tool headers and stateful request projection + +Status: implemented + +English | [中文](2026-09-20-dynamic-tool-updates.zh.md) + +## Problem + +Plugin and MCP tools change during conversations. Providers differ in whether they activate deferred definitions or remove tools through history. Storing retained provider declarations as active tools makes comparisons repeat removals and miss restorations. + +## Decision + +`request/header.tools` records assembled active tools independently of model capability. The loop compares names against the preceding header and emits one `developer/message` with additions and removals under the `tool-registry` source. Additions reference the new header through `headerSeq`. Definition changes remain header changes; unchanged names emit no update. Existing header equality and request-series rules remain in force. + +`Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. + +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes. + +DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. + +## Alternatives considered + +**Store provider declarations in headers.** Retained removed definitions are not active tools. Mixing those meanings makes durable updates capability-dependent and breaks change detection. + +**Reconstruct definitions in adapters from messages alone.** Addition messages contain names, not schemas or event-level header references. Session owns that historical relationship; adapters translate projected requests. + +**Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. + +**Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. + +## Consequences + +Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still consolidate on tool changes; explicit series starts and surface replacements still consolidate on every route. No persisted fields or Session format revision are added. + +The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. + +The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md new file mode 100644 index 0000000000..6a8fb256c5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 有效工具请求头与有状态请求投影 + +Status: implemented + +[English](2026-09-20-dynamic-tool-updates.md) | 中文 + +## Problem + +插件和 MCP 工具会在对话期间变化。不同提供方对通过历史激活延迟定义或移除工具的支持不同。把为提供方保留的声明当作有效工具存储,会使比较重复记录移除并遗漏恢复。 + +## Decision + +`request/header.tools` 记录组装得到的有效工具,不依赖模型能力。循环将名称与前一请求头比较,以 `tool-registry` 来源发出一条包含添加和移除的 `developer/message`。添加通过 `headerSeq` 引用新请求头。定义变化仍体现为请求头变化;名称未变时不发出更新。现有请求头相等判断和请求序列规则保持有效。 + +`Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 + +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。 + +DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 + +## Alternatives considered + +**在请求头存储提供方声明。** 为提供方保留的已移除定义不是有效工具。混合两种含义会使持久更新依赖能力,并破坏变更检测。 + +**仅凭消息在适配器重建定义。** 添加消息包含名称,不包含模式或事件级请求头引用。Session 拥有该历史关系;适配器转换投影后的请求。 + +**每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 + +**在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 + +## Consequences + +工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时归并提示词;所有路由仍在显式新序列和 surface 替换时归并。不增加持久字段或 Session 格式版本。 + +手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 + +[真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml new file mode 100644 index 0000000000..55a0c25bdd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-21-desktop-page-close-shortcuts.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-21-desktop-page-close-shortcuts.md +/agent-note-desktop-close-shortcuts-follow-page-focus: + en: e6ce0bc9592af8ba + zh: b43fa4c48c634e27 +/agent-note-desktop-close-shortcuts-follow-page-focus/problem: + en: feb9d491ad4a9016 + zh: 291f0e6c913b0b19 +/agent-note-desktop-close-shortcuts-follow-page-focus/decision: + en: c587d88575b6ce7e + zh: 2287cf4d6d2e7587 +/agent-note-desktop-close-shortcuts-follow-page-focus/alternatives-considered: + en: 10c7bb2fc87bdd16 + zh: 796ecde8bcf09d34 +/agent-note-desktop-close-shortcuts-follow-page-focus/consequences: + en: aea659e749840caa + zh: e75d757165055a55 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md new file mode 100644 index 0000000000..dc97680644 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md @@ -0,0 +1,27 @@ +# Agent Note: Desktop close shortcuts follow page focus + +Status: implemented + +English | [中文](2026-09-21-desktop-page-close-shortcuts.zh.md) + +## Problem + +The [shortcut PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) gives the close command two targets: the focused right-sidebar page, or the Desktop window when no page can close. Electron's native close role bypasses the page owner. On Windows, closing the last window also quits the Desktop instance and stops its Host tasks, so this fallback is a lifecycle choice rather than a menu-label change. + +## Decision + +The sidebar page owner resolves close against live focus and captures the target's Session, pane, tab occurrence, and navigation revision. It closes that page through its resource cleanup handler, or collapses the sole docked guide. Stale sidebar targets do not fall back to another page or the window. Without a closeable page, Desktop requests native window closure; Web has no window fallback. + +The macOS File menu routes Close Page or Window through the same owner and displays the accepted single-key binding. Native closure requires the current configuration revision, a focused and enabled product window, and inactive shortcut recording. The existing window lifecycle applies without a shortcut-specific confirmation: macOS keeps the application alive after its last window closes; Windows and Linux quit and stop the Host. + +This decision owns File-menu and close behavior in place of the close-role choice in [the standard macOS menu note](../bug-fix/2026-09-16-desktop-window-menus.md). That note retains the rationale for native Window and application hide commands. [Shortcut preference persistence](2026-09-20-device-local-shortcut-preferences.md) owns accepted bindings and revision publication. + +## Alternatives considered + +**Use Electron's native close role.** It closes the window without asking the focused page owner and cannot preserve page cleanup or the accepted custom binding's routing. + +**Disable the Windows window fallback.** This avoids a task-stopping close shortcut but omits the PRD's explicit Desktop fallback. The product keeps native window-close semantics; users who need the application to remain running can minimize it. + +## Consequences + +Close behaves consistently across the shortcut and macOS File menu. On Windows and Linux, pressing it outside a closeable sidebar page can stop active tasks by quitting the instance. The command does not add a background Host lifetime or a task-aware shutdown confirmation. Desktop keyboard tests cover revision and focus guards; sidebar focus tests cover captured page identity and stale targets; startup tests cover the platform menu declarations. diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md new file mode 100644 index 0000000000..6ef60c084f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Desktop 关闭快捷键跟随页面焦点 + +Status: implemented + +[English](2026-09-21-desktop-page-close-shortcuts.md) | 中文 + +## 问题 + +[快捷键 PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) 为关闭命令规定了两类目标:获得焦点的右侧栏页面,或没有可关闭页面时的 Desktop 窗口。Electron 原生 close role 绕过页面 owner。Windows 关闭最后一个窗口还会退出 Desktop 实例并停止其 Host 任务,因此该回退属于生命周期选择,不只是菜单标签变更。 + +## 决策 + +侧栏页面 owner 根据实时焦点解析关闭操作,并捕获目标的 Session、窗格、tab occurrence 和导航 revision。它通过资源清理处理器关闭该页面,或收起唯一的停靠引导页。过期侧栏目标不会回退到其他页面或窗口。没有可关闭页面时,Desktop 请求原生窗口关闭;Web 没有窗口回退。 + +macOS File 菜单通过同一个 owner 路由“关闭页面或窗口”,并显示已接受的单键绑定。原生关闭要求配置 revision 仍为当前值、产品窗口已聚焦且启用,并且没有在录制快捷键。现有窗口生命周期直接生效,不增加快捷键专用确认:macOS 最后一个窗口关闭后保留应用;Windows 和 Linux 退出并停止 Host。 + +本决策接管[标准 macOS 菜单记录](../bug-fix/2026-09-16-desktop-window-menus.zh.md)中 close role 选择所涉及的 File 菜单和关闭行为。该记录保留原生 Window 与应用隐藏命令的理由。[快捷键偏好持久化](2026-09-20-device-local-shortcut-preferences.zh.md)负责已接受绑定及 revision 发布。 + +## 考虑过的替代方案 + +**使用 Electron 原生 close role。** 它不询问获得焦点的页面 owner 就关闭窗口,无法保留页面清理和已接受自定义绑定的路由。 + +**禁用 Windows 窗口回退。** 这能避免关闭快捷键停止任务,但会遗漏 PRD 明确要求的 Desktop 回退。产品保留原生关窗语义;需要应用继续运行时,用户可以最小化窗口。 + +## 影响 + +快捷键与 macOS File 菜单的关闭行为一致。在 Windows 和 Linux 上,焦点不在可关闭侧栏页面时按下该快捷键,可能因退出实例而停止活动任务。该命令不增加后台 Host 生命周期或感知任务状态的退出确认。Desktop 键盘测试覆盖 revision 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.i18n.yaml new file mode 100644 index 0000000000..d962ba868a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-platform-browser-storage.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-22-platform-browser-storage.md +/agent-note-account-scoped-platform-browser-storage: + en: ade088e230a39dd2 + zh: 437a9be5fe2ef704 +/agent-note-account-scoped-platform-browser-storage/problem: + en: 84cfcf61f58fab58 + zh: 14844d5e3d3638fe +/agent-note-account-scoped-platform-browser-storage/decision: + en: 7c8ec7ea318f3689 + zh: 2c45ac28670dfda1 +/agent-note-account-scoped-platform-browser-storage/alternatives-considered: + en: 101fa0ff0b01b096 + zh: 6c6b0a4136531d6b +/agent-note-account-scoped-platform-browser-storage/consequences: + en: 0aa0a16abba90b43 + zh: 6fcb59f3ecea2e54 diff --git a/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md new file mode 100644 index 0000000000..31fd984af9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md @@ -0,0 +1,23 @@ +# Agent Note: Account-scoped Platform browser storage + +Status: implemented + +English | [中文](2026-09-22-platform-browser-storage.zh.md) + +## Problem + +Embedded Platform notices store dismissal in localStorage. Disposable browser partitions lose that preference whenever the view closes. + +## Decision + +Desktop partitions browser storage by a SHA-256 hash of the Platform origin and stable account ID. Persistent partitions retain localStorage page preferences across view and application lifetimes, including sign-out and later sign-in to the same account. Raw account IDs and tokens do not appear in partition names. While the Host has no account ID from a successful profile read, the view uses a disposable partition, so preparing a Platform session never waits for a profile request. An ID that arrives for the credential already open leaves the mounted disposable document in place; the next open uses the account-scoped partition. + +A persistent partition keeps only localStorage; opening one clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads, while a disposable partition clears all of its storage. Closing a view destroys its document, detaches the credential-bearing request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install awaits it before the installer takes over the exit; shutdown reports a failure instead of blocking the quit. A failed cleanup rejects that open and keeps its account from opening again until a later cleanup succeeds, while other accounts keep opening. Host-only credentials still enter the trusted page through the existing preload and are not written to browser storage by the bridge. + +## Alternatives considered + +One shared partition mixes account preferences. Token-derived names discard preferences when credentials change. A notice-specific native API would duplicate Platform preference ownership and require coordinated frontend changes. Clearing storage only when a view closes leaves authentication from an unclean exit readable by the next document. + +## Consequences + +Preferences remain local to this Desktop browser-data directory and are not synchronized across devices. Platform scripts remain trusted to handle the credential they receive; persistent site storage is not a credential vault. Storage for a signed-out account stays on disk until that account signs in again or the browser-data directory is removed. The real Electron regression checks notice dismissal across view recreation, account switches, and process restart, and that cookies, Cache Storage, and IndexedDB are cleared, a page beforeunload handler does not hold the close, a disposable session neither inherits nor retains preferences, and cookie residue from a previous process is cleared. diff --git a/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md new file mode 100644 index 0000000000..ebaea7b642 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 按账号隔离的 Platform 浏览器存储 + +Status: implemented + +[English](2026-09-22-platform-browser-storage.md) | 中文 + +## Problem + +内嵌 Platform 通知将关闭状态写入 localStorage。一次性浏览器分区会在每次关闭视图后丢失该偏好。 + +## Decision + +Desktop 使用 Platform 来源和稳定账号 ID 的 SHA-256 哈希划分浏览器存储。持久分区跨视图和应用生命周期保留 localStorage 页面偏好,包括退出登录后再次登录同一账号。分区名不包含原始账号 ID 或 token。在 Host 尚未从成功的资料读取获得账号 ID 期间,视图使用一次性分区,因此准备 Platform 会话不会等待资料请求。同一凭证下账号 ID 迟到时,已挂载的一次性文档保持不变;下次打开使用账号分区。 + +持久分区只保留 localStorage;打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态,而一次性分区会清理其全部存储。关闭视图会销毁文档、移除携带凭证的请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待;退出流程上报清理失败,而不阻塞退出。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号再次打开,其他账号不受影响。仅限 Host 的凭证仍通过现有 preload 传入受信任页面,bridge 不会将其写入浏览器存储。 + +## Alternatives considered + +共享分区会混用账号偏好。以 token 命名会在凭证变化时丢失偏好。专用于通知的原生 API 会重复承担 Platform 偏好管理职责,并要求前端同步修改。只在关闭视图时清理,会让异常退出遗留的认证被下一个文档读取。 + +## Consequences + +偏好仅保存在此 Desktop 浏览器数据目录中,不跨设备同步。Platform 脚本仍须可信地处理收到的凭证;持久站点存储不是凭证保险库。已退出账号的存储会保留在磁盘上,直到该账号再次登录或浏览器数据目录被删除。真实 Electron 回归测试验证视图重建、账号切换和进程重启后的通知关闭状态,并断言 Cookie、Cache Storage 和 IndexedDB 已清理、页面 beforeunload 处理函数不会阻止关闭、一次性会话既不继承也不保留偏好,以及上一进程遗留的 Cookie 已清除。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml new file mode 100644 index 0000000000..575b3ca146 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-schedule-hard-delete-and-bounded-history.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-22-schedule-hard-delete-and-bounded-history.md +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history: + en: 17a73230ad22ec0b + zh: 5bcf27d7c0e86764 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/problem: + en: 98e82a0add324882 + zh: c10e85a3f2a522f8 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/decision: + en: 56328745115714b5 + zh: f30d39f59aa06a13 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/alternatives-considered: + en: 0c4eb01054be1141 + zh: 95980f8097fa36fd +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/consequences: + en: 05121f4b77b8ee12 + zh: 1751c9b1a752ce6b diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md new file mode 100644 index 0000000000..90127d6887 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md @@ -0,0 +1,33 @@ +# Agent Note: Schedule deletes task rows and bounds delivery history + +Status: implemented + +English | [中文](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md) + +## Problem + +The Schedule domain is one whole-unit JSON document that the backend rewrites on every mutation, so every retained byte in a task row grows every write. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip. + +## Decision + +Deletion is hard deletion. `ScheduleService.delete` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for the same `(sessionId, id)`; the row's saved delivery records are removed with it. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records. + +Delivery history is bounded by plugin `Config`: `deliveryHistoryDays` (default 30, 1–3650) and `deliveryHistoryRecords` (default 200, 1–10000). `appendDelivery` applies both bounds on the only write path. It keeps the records whose `deliveredAt` is at or after the appended receipt's `deliveredAt` minus the configured day window, keeps the newest `deliveryHistoryRecords` of those, and always retains the appended latest receipt; a pruned window sets `earlierRecordsUnavailable` and `earlierRecordsPruned`, which later appends preserve. The latter flag is optional in stored rows and confirms actual removal; legacy unavailability alone does not imply pruning. The history response includes the current retention limits, and the Client shows the cleanup notice only for confirmed pruning after loading the final nonempty page. + +The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: one authoritative version-1 task domain, timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. Its status vocabulary is `active` or `inactive`; `inactive` is the sole non-active name, and the client filter row reads All, Enabled, and Inactive. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here. + +## Alternatives considered + +**Keep the retained `deleted` row.** The flag existed so `history` could answer after deletion. Nothing read those records after deletion, the row kept every prompt snapshot in the storage document, and the state had to be excluded by hand from `list`, `catalog`, and the timer. Removing the row makes deletion mean one thing. + +**Bound only by record count.** A count cap alone keeps arbitrarily old receipts when deliveries are sparse, and it drops a burst of recent ones when they are not. The day window bounds staleness while the cap bounds the document. + +**Measure the window from the task instead of from each receipt.** Anchoring the window to the task's creation or committed target would discard a newly written receipt of an old task. Measuring back from the appended receipt's `deliveredAt` keeps the window anchored to the data being kept. + +**Keep history unbounded and let operators clean up.** The domain publishes one whole-unit document per write, so unbounded history makes the storage document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference. + +## Consequences + +Deletion now offers no recovery: a deleted task's saved deliveries cannot be read back, and a task that still needs its full history must be read before deletion. Configured retention also discards receipts the task once had; the retained flag records that a window was pruned rather than pretending the history is complete. + +In exchange, the persisted task set has one deletion semantics, the storage document's size is bounded by configuration instead of by delivery counts, and `inactive` is the only non-active lifecycle name. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history. diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md new file mode 100644 index 0000000000..dfc8ab4ff3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Schedule 删除任务行并限制投递历史 + +Status: implemented + +[English](2026-09-22-schedule-hard-delete-and-bounded-history.md) | 中文 + +## Problem + +Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档,因此任务行中保留的每个字节都会随每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。 + +## Decision + +删除是硬删除。`ScheduleService.delete` 通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。 + +投递历史受插件 `Config` 限制:`deliveryHistoryDays`(默认 30,1–3650)与 `deliveryHistoryRecords`(默认 200,1–10000)。`appendDelivery` 在唯一的写入路径上同时应用这两个上限。它保留 `deliveredAt` 不早于「本次追加回执的 `deliveredAt` 减去配置的天数窗口」的记录,在其中保留最新的 `deliveryHistoryRecords` 条,并始终保留本次追加的最近一次回执;发生裁剪时设置 `earlierRecordsUnavailable` 和 `earlierRecordsPruned`,后续追加会保留这两个标志。后者在存储行中可选,用于确认实际删除;仅有旧格式的不可用标志不表示发生过裁剪。历史响应携带当前保留上限,客户端仅在加载完最后一页且历史非空、确认裁剪时显示清理提示。 + +[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:唯一的权威版本 1 任务 domain、只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。其状态词汇为 `active` 或 `inactive`;`inactive` 是唯一的非活动名称,客户端筛选行为全部、活动和未运行。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。 + +## Alternatives considered + +**保留带 `deleted` 标志的任务行。** 该标志的存在是为了让 `history` 在删除后仍能回答。删除后没有任何代码读取这些记录,而该行把每个提示文本快照都留在存储文档中,还必须手工把它排除在 `list`、`catalog` 和定时器之外。移除该行让删除只有一种含义。 + +**只按条数设上限。** 仅有条数上限时,投递稀疏的任务会保留任意久远的回执,投递密集时又会丢弃近期的一批回执。天数窗口限制陈旧程度,条数上限限制文档大小。 + +**以任务而非每条回执为窗口起点。** 把窗口锚定在任务的创建时间或已提交目标上,会丢弃旧任务刚写入的回执。从本次追加回执的 `deliveredAt` 向前计算,使窗口锚定在所要保留的数据上。 + +**保留无上限的历史,由运维清理。** 该 domain 每次写入都发布整个 unit 文档,因此无上限的历史会让存储文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。 + +## Consequences + +删除不再提供恢复能力:已删除任务的已保存投递无法再读回,仍需要完整历史的必须在删除前读取。配置的保留策略也会丢弃任务曾经拥有的回执;保留的标志记录窗口发生过裁剪,而不是假装历史完整。 + +换取的是:持久任务集合只有一种删除语义,存储文档大小由配置而非投递次数决定,且 `inactive` 是唯一的非活动生命周期名称。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml new file mode 100644 index 0000000000..5bd54753a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-23-schedule-local-menu.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-23-schedule-local-menu.md +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client: + en: a9ed8f9cf0bdfd66 + zh: ff11822efe6b48b7 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/problem: + en: 076e6ca799bf2045 + zh: d3f87030015cdf3c +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/decision: + en: c35ab9fa2549866d + zh: 01855f87b884dc93 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/alternatives-considered: + en: 3648e55bb76a4381 + zh: 83c98e0789a6df9c +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/consequences: + en: f6380c38e35bce37 + zh: 1379ff1763d0eb10 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/testing: + en: f1de601175678a9a + zh: 58fddec29a462b98 diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md new file mode 100644 index 0000000000..fd3266bbc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md @@ -0,0 +1,43 @@ +# Agent Note: Own the Schedule detail menus inside the Schedule client + +Status: implemented + +English | [中文](2026-09-23-schedule-local-menu.zh.md) + +## Problem + +The Automation tasks detail needs a menu whose list carries a pinned control: the Time zone row's search field must stay above the scrolling zone rows. The shared `Menu` in `@deepseek-ai/dsh-client-ui-primitives` renders rows, separators, labels, a selection mark, and a keyboard walk, but it has no header slot. The Delivery records tab shows a saved record's message id behind an info glyph, and the reader must be able to read and copy that id. + +Serving either need by extending `ui-primitives` puts one feature's behavior into the package every client plugin consumes. The same branch had already removed unrelated `ui-layout` and `ui-sidebar-right` edits after review asked why a Schedule change touched them, and a shared primitive extended for a single caller still costs every future reader of that primitive. + +## Decision + +`packages/client/ui-schedule` owns its menu. `src/client/TaskMenu.tsx` with `TaskMenu.module.css` holds a schedule-local copy that renders rows, separators, labels, disabled and destructive rows, a trailing `selectedId` mark, the pointer and keyboard walk, portaled placement, the pinned `header` slot, and the `data-menu-field` focus handover. It imports the shared `useAnchoredPosition` and `useDismissOnOutsidePointer` hooks, which are the placement and dismissal behavior any anchored panel needs, and it imports no runtime value from `ui-primitives`. The four detail menus use it. + +The Delivery records tab names and copies a record's message id through an info button. The button's accessible name states the action and the record (`Copy message ID: `), the shared `Tooltip` states the id on hover and on keyboard focus, and activation copies it through the shared `writeClipboard` helper and reports `Copied` in a status region beside the glyph. `Tooltip` therefore keeps its master shape: the mode that held one bubble open under the pointer is gone, and the Delivery records view does not use the `HoverCard` that opens on the pointer alone. + +`packages/client/ui-primitives` returns to master except the clock glyph's stroke. The client bundle purity gate in `packages/client/tsdown.client.ts` is the neighboring rule for the other direction of this boundary: a client plugin may import another plugin's package only as types or as a module-table request, so shared runtime vocabulary travels through an inline-safe layer or a cordis service rather than a package edge. + +## Alternatives considered + +**Add a `header` prop to the shared `Menu`.** This is what the branch first did: an optional slot plus the `data-menu-field` focus handover, with tests in the shared package. It gives the smallest diff and keeps one menu implementation, and it was rejected because the only caller is the Schedule detail: `ui-primitives` would carry API that exists for one feature, and every client plugin's bundle would read a header contract none of them use. + +**Add an `interactive` mode to the shared `Tooltip`.** Shipped first and then removed: a grace period between anchor and bubble, pointer hold, click toggling, and selectable text, with six tests. It lost because the shared tooltip already opens on focus and the shared clipboard helper already performs the copy, so the mode added a second way to hold one bubble open for a single caller. + +**Name and copy the message id through `HoverCard`.** Shipped second and then replaced: the card holds while the pointer rests on it, selects its text, and copies on activation. It lost because `HoverCard` opens on `onPointerEnter` alone, so the already-focusable glyph showed a keyboard reader nothing and offered no copy path. + +**Render the zone search field outside the menu list.** A search input above the trigger, or a separate row in the rule card, needs no primitive change. It was rejected because the field would scroll away from the list it filters or sit in a different surface than the rows it filters. + +**Build the zone picker on the schedule-local `PickerPopover`.** The Date and Time pickers already use that panel, so the zone list could reuse it. It was rejected because `PickerPopover` positions and dismisses a panel but renders no list semantics: the keyboard walk, the row roles, and the selected-row mark would have to be rebuilt inside the Schedule client anyway, which is the copy this note describes with more surface than the menu it replaces. + +## Consequences + +The Schedule client carries a second menu implementation: 346 lines of component, 189 lines of CSS, and 403 lines of tests, in exchange for `ui-primitives` returning to master except the clock glyph's stroke. The copy is trimmed to what the detail uses, and the absent capabilities stay absent: submenus, component-rendered rows, a pinned footer, multiple selection ids, the fill selection mode, dense and compact spacing, mount-time autofocus, a caller-supplied anchor rect, pointer-leave closing, and a side parameter. A later Schedule menu that needs one of them extends the copy or revisits this decision. + +Copying is an activation, not a text selection: the tooltip shows the id but does not accept the pointer, so the desktop gesture that copies the id is a click on the glyph. That is the capability the removed interactive mode had and this shape gives up. Shared behavior stays shared: placement, dismissal, focus handover, the duplication gate's exclusion markers naming both owners, and per-file coverage all apply to the copy. The copied region carries `jscpd:ignore-start` and `jscpd:ignore-end` with the two owners named, following the [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md); the schedule-specific header, handover, and Tab semantics sit outside that region. + +The copy places its list with `useAnchoredPosition`, which clamps every edge by the same 12px margin, while the shared menu's top edge used the frame-top clearance helper. A list that must clamp upward against the top of a macOS window can therefore sit about 12px lower than the shared menu did in the same situation; the stylesheet still reserves the clearance for the list's own maximum height, so the common case is unchanged. + +## Testing + +`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` pins the pointer and keyboard walk, the `header` field's Home/End ownership, the `data-menu-field` handover after placement, the selected-row mark, disabled rows, portal placement, outside dismissal, and focus return; the three menu-header tests that lived in `ui-primitives` moved there with the capability. `delivery-history.client.spec.tsx` pins the tooltip that keyboard focus raises, the button's accessible name, the clipboard write, and the copy confirmation that clears. The coverage gate reports 100% per file for `packages/client/ui-schedule/src`, and the duplication gate reports no clones across `packages/client/ui-schedule` and `packages/client/ui-primitives`. diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md new file mode 100644 index 0000000000..924f14f5ad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Schedule 详情菜单由 Schedule 客户端自己拥有 + +Status: implemented + +[English](2026-09-23-schedule-local-menu.md) | 中文 + +## Problem + +Automation tasks 详情需要一个列表顶部钉住控件的菜单:Time zone 行的搜索框必须停在滚动时区行之上。`@deepseek-ai/dsh-client-ui-primitives` 里的共享 `Menu` 会渲染行、分隔线、标题行、选中标记和键盘走位,但没有 header 槽。Delivery records 页把一条已保存记录的 message id 放在 info 图标背后,读者要能读到并复制这个 id。 + +用扩展 `ui-primitives` 来满足其中任一需求,都会把单个功能的行为放进每个 client 插件都消费的包里。同一条分支此前已经因为 review 质询“Schedule 的改动为什么碰它们”而撤掉了无关的 `ui-layout` 与 `ui-sidebar-right` 编辑;而一个只为单一调用方扩展的共享原语,对之后每一个读它的人都是成本。 + +## Decision + +`packages/client/ui-schedule` 自己拥有菜单。`src/client/TaskMenu.tsx` 与 `TaskMenu.module.css` 保存一份 schedule 本地副本,渲染行、分隔线、标题行、disabled 与 dangerous 行、`selectedId` 尾随标记、指针与键盘走位、portal 定位、钉住的 `header` 槽,以及 `data-menu-field` 焦点交接。它导入共享的 `useAnchoredPosition` 与 `useDismissOnOutsidePointer` hook——任何锚定面板都需要的定位与关闭行为——不从 `ui-primitives` 导入任何运行时值。详情的四个菜单都改用它。 + +Delivery records 页用一个 info 按钮给出并复制记录的消息 id。该按钮的可访问名称同时说明动作与记录(`Copy message ID: `),共享 `Tooltip` 在悬停与键盘聚焦时给出该 id,激活时通过共享的 `writeClipboard` 复制,并在图标旁用 status 区域报告 `Copied`。因此 `Tooltip` 保持 master 形态:那个让气泡在指针下保持展开的模式已不存在,Delivery records 页也不使用只在指针下打开的 `HoverCard`。 + +`packages/client/ui-primitives` 除时钟图标描边外回到 master。`packages/client/tsdown.client.ts` 的 client bundle purity gate 是这条边界另一方向上的相邻规则:client 插件只能以类型或 module-table request 的形式导入另一个插件的包,因此共享运行时词汇要么走 inline-safe 层,要么走 cordis service,而不是包与包之间的边。 + +## Alternatives considered + +**给共享 `Menu` 加 `header` prop。** 这是分支最初的做法:一个可选槽加 `data-menu-field` 焦点交接,并在共享包里带测试。它的 diff 最小、只保留一份菜单实现;被否决是因为唯一的调用方是 Schedule 详情:`ui-primitives` 会背上只为单个功能存在的 API,而每个 client 插件的 bundle 都要读一份没人使用的 header 契约。 + +**给共享 `Tooltip` 加 `interactive` 模式。** 先落地后撤除:锚点与气泡之间的宽限期、指针停留、点击切换、可选中的文字,外加六个测试。被否决是因为共享 tooltip 本来就支持聚焦展开、共享剪贴板助手本来就负责复制,这个模式只是为单一调用方多出了一种保持气泡展开的方式。 + +**用 `HoverCard` 给出并复制消息 id。** 第二个落地又被替换的版本:指针停在卡片上时保持展开、文字可选中、激活即复制。被否决是因为 `HoverCard` 只在 `onPointerEnter` 打开,于是那个本来就可聚焦的图标对键盘读者什么都不显示,也没有复制入口。 + +**把时区搜索框渲染在菜单列表之外。** 放在触发器上方的输入框,或规则卡里的独立一行,都不需要改原语。被否决是因为该输入框会与它所过滤的列表分离滚动,或落在与它过滤的行不同的界面层上。 + +**用 schedule 本地的 `PickerPopover` 实现时区选择器。** Date 与 Time picker 已经用它。被否决是因为 `PickerPopover` 只负责定位与关闭面板,不渲染列表语义:键盘走位、行 role、选中行标记都要在 Schedule 客户端里重建,那正是本 note 描述的副本,而且比它所替代的菜单要维护更多东西。 + +## Consequences + +Schedule 客户端多出一份菜单实现:346 行组件、189 行 CSS、403 行测试,换来 `ui-primitives` 除时钟图标描边外回到 master。副本按详情实际使用裁剪,缺失的能力保持缺失:submenu、组件渲染行、钉住的 footer、多个 selectedId、fill 选中模式、dense 与 compact 间距、挂载时 autofocus、调用方提供锚点矩形、指针离开即关闭,以及 side 参数。日后某个 Schedule 菜单需要其中一项时,要么扩展副本,要么重新审视这个决定。 + +复制是一次激活,而不是选中文本:tooltip 给出 id 但不接受指针,所以桌面端复制该 id 的手势是点击图标;这正是被撤掉的交互模式所具备、而当前形态放弃的能力。共享行为仍是共享的:定位、关闭、焦点交接、点名两个 owner 的 duplication gate 排除标记,以及 per-file 覆盖率都适用于这份副本。被拷贝的区域带 `jscpd:ignore-start` 与 `jscpd:ignore-end` 并点名两个 owner,遵循 [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md);schedule 专有的 header、交接与 Tab 语义不在该区域内。 + +副本用 `useAnchoredPosition` 定位列表,它对四条边都用同一个 12px margin,而共享菜单的顶边用的是 frame-top clearance helper。因此在 macOS 窗口顶部需要向上钳制的列表,可能比共享菜单原先的位置低约 12px;样式表仍为该列表自身的最大高度保留 clearance,常见情形不变。 + +## Testing + +`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` 钉住指针与键盘走位、`header` 输入框对 Home/End 的独占、落位后的 `data-menu-field` 交接、选中行标记、disabled 行、portal 定位、外部关闭与焦点回返;原先位于 `ui-primitives` 的三个菜单 header 测试随该能力一起迁入。`delivery-history.client.spec.tsx` 钉住键盘聚焦展开的工具提示、按钮的可访问名称、剪贴板写入,以及会自行清除的复制确认。覆盖率门禁对 `packages/client/ui-schedule/src` 报告 per-file 100%,duplication 门禁在 `packages/client/ui-schedule` 与 `packages/client/ui-primitives` 上报告 0 克隆。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index c17e77792b..eb76836a1c 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -9,14 +9,14 @@ en: 75c201a0a687b59d zh: 34593038f673d06f /agent-note-desktop-standard-macos-window-menus/decision: - en: ea0310537d0251d9 - zh: daf3f8a745bb2872 + en: 1797b5695e873e5a + zh: 3376fda4fc36aaae /agent-note-desktop-standard-macos-window-menus/alternatives-considered: - en: e2d3ed82f6247910 - zh: 7e449d21f0601f65 + en: 957382dbfb6cac48 + zh: 2db98b7dbf3e01c0 /agent-note-desktop-standard-macos-window-menus/consequences: - en: 54527f9422525fb8 - zh: 285fd6ffda5ff4fd + en: 1fa44c82ca450bcd + zh: b618f5ec0a90ed13 /agent-note-desktop-standard-macos-window-menus/testing: en: 0c05c0044532f891 zh: cff254b44b3c7a2d diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 8a5343404e..34eff83707 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -10,9 +10,9 @@ The Desktop shell replaces Electron's default application menu with a custom tem ## Decision -On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. +On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior. -Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. +Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. ## Alternatives considered @@ -22,13 +22,13 @@ Electron supplies English defaults for the File, Window, and Edit roles. Explici **Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it. -**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path. +**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application. The menu-restoration task did not request this quit path; the page-close decision explicitly specifies the Desktop fallback. **Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays. ## Consequences -macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale. +macOS retains native Window and application hide commands through four menu roles. Window and Edit retain Electron’s default English labels; application commands use the Desktop locale. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index d6ee641246..c3cb3e5d89 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -10,9 +10,9 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 ## 决策 -macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 +macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。 -Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 +Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 ## 考虑过的替代方案 @@ -22,13 +22,13 @@ Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签 **在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。 -**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。 +**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用。恢复菜单的任务没有要求这条退出路径;页面关闭决策明确规定了 Desktop 回退。 **让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。 ## 影响 -macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 +macOS 通过四个菜单 role 保留原生 Window 和应用隐藏命令。Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 ## 测试 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml new file mode 100644 index 0000000000..dc30220e34 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-18-schedule-task-tab-restore.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/bug-fix/2026-09-18-schedule-task-tab-restore.md +/agent-note-task-tab-recovery-after-a-reload: + en: d7edcc16307d350a + zh: 63301b138b2a203b +/agent-note-task-tab-recovery-after-a-reload/problem: + en: 244f309423fff24e + zh: ddd2ab66a3f4fd5b +/agent-note-task-tab-recovery-after-a-reload/decision: + en: 16e1bcb4a0860e91 + zh: 587715df43d5c96f +/agent-note-task-tab-recovery-after-a-reload/alternatives-considered: + en: 2fb8e2e0688d22ac + zh: a584b183723aace7 +/agent-note-task-tab-recovery-after-a-reload/consequences: + en: 20b7e4c551df0bc0 + zh: c17426fb981f0b06 +/agent-note-task-tab-recovery-after-a-reload/testing: + en: 3581ad83c52ee3b0 + zh: fbffd886a46b76c6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md new file mode 100644 index 0000000000..3809dfadce --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md @@ -0,0 +1,45 @@ +# Agent Note: Task tab recovery after a reload + +Status: implemented + +English | [中文](2026-09-18-schedule-task-tab-restore.zh.md) + +## Problem + +The right Sidebar persists a Session's layout as tab records and not the navigation parameters an opener passed, per the [tab type and navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md). A `scheduleTask` tab restored by a reload therefore had no task identity: its body could show only its loading feedback and its chip only the constant label, until the user opened the task again from the Session entry. Recovering content belongs to the tab type's provider, as the [layout persistence decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) established for terminals, and the Sidebar exposes no parameter store for a page type to reuse. + +## Decision + +The task tab page keeps a durable binding to the task it last showed and resolves a restored record from it. + +The binding key is the Sidebar Session plus the layout record's own id. `dsh.sidebar-right.v1.` persists the tab records and the counter that mints their ids, so a record keeps its id across a reload and no record minted later in that layout reuses it. The record's `contentId` is not a usable key: every tab of one page kind is recorded under the same `sidebar://` address, and page deduplication is per pane, so two panes may each hold a task tab. Each entry also stores the `kind` and `contentId` of the record it was written for, and a read drops and ignores an entry whose pair differs from the record reading it. + +One localStorage document per Session, keyed `dsh.schedule.task-tab.v1.`, holds one entry per tab id, mapping that page to the Session and id of its last task. No task content is stored. A write keeps only the entries whose tab the Session's committed layout still holds, read through `ctx.sidebarRight.tabsIn(sessionId)`; a Session whose layout is not adopted leaves its entries alone. An entry that a read succeeding after the tab appeared cannot resolve is removed, which leaves the tab with the unbound-tab notice, the same state as one that never had a binding. + +The body writes the binding in an effect whenever a navigation carries this type's parameters, and reads it only while the record has no navigation parameters; parameters of another kind leave the record in its missing-task state. A recovered identity then follows the existing rules: the detail whenever the retained records hold the task, the catalog feedback until a read requested after the tab's baseline settles without the task, and the query-failure state with its retry when the read fails. A binding such a read cannot resolve shows the unbound-tab notice instead of the loading feedback, and never the missing-task notice, because that notice states that an opener named a task. + +The body measures the answer from the catalog read request ordinal it observed for its current navigation, and asks for a read whenever the retained records leave it without a task and no read requested after that ordinal has succeeded, reporting the catalog feedback while that read is pending. The Sidebar navigates a tab again without remounting its body, so the navigation's revision moves the baseline; a read requested before the baseline never states that the tab's task is gone. + +## Alternatives considered + +**Key the binding by `contentId`.** Rejected: every tab of one page kind is recorded under the same address, and page deduplication is per pane, so two task tabs in two panes would share one binding and restore the same task. + +**Persist navigation parameters in the Sidebar layout.** Rejected: it moves content parameters into the layout snapshot that the [navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) keeps transient, and every future page type would inherit the format. + +**Declare the kind `multiple: true` so every open mints a unique contentId.** Rejected: it changes what opening a task does — a second task opens a second tab instead of showing in the existing one — to obtain a key, and it cannot recover records restored before the change. + +**Resolve the task from the catalog.** Rejected: the catalog holds no per-tab association, and the Session entry the tab was opened from is gone after a reload. + +**Reuse the client store's localStorage persistence.** Rejected: `createSnapshotStore`'s `persist` option saves one whole store value under one name and rehydrates it when that store is created, so it needs a store per Session; the binding also has to reject reused ids and prune closed tabs on write, which a keyed document does in one place with no subscription for a body to re-render through. + +**Store the task title beside the binding to confirm the recovery.** Rejected: a title is task content, it changes with an edit, and the settled catalog read already decides whether the bound id resolves. + +## Consequences + +A reloaded Session restores every task tab's detail and chip without reopening the task. The binding is window-local browser state: another browser, a cleared origin, or a tab the layout kit duplicated has none, and such a record states that the tab has no task instead of claiming the task is gone. A discarded layout snapshot can mint a tab id that a binding still names; the kind and contentId guard rejects an entry of another kind, and an entry of the same kind is overwritten by the next navigation of that tab or removed once its task stops resolving. + +Entries of Sessions whose layouts are never adopted again remain until their next write; write-time pruning bounds every adopted Session, and no entry holds content. + +## Testing + +Unit tests cover writing on navigation, reading only without parameters, recovery into the detail and the chip, degradation of an unresolvable binding, committed-tab pruning, reused-id rejection, malformed stored documents, and storage that is absent or rejects writes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md new file mode 100644 index 0000000000..84a1a1247b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 刷新后任务标签页的恢复 + +Status: implemented + +[English](2026-09-18-schedule-task-tab-restore.md) | 中文 + +## Problem + +右 Sidebar 按 [标签类型与导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 只把 Session 布局持久化为标签记录,不持久化打开方传入的导航参数。因此刷新恢复出的 `scheduleTask` 标签没有任务身份:正文只能显示加载反馈,chip 只能显示固定文案,直到用户从 Session 入口重新打开该任务。按 [布局持久化决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md) 为终端确立的划分,恢复内容属于标签类型自身 provider 的职责,而 Sidebar 没有可供页面类型复用的参数存储。 + +## Decision + +任务标签页保存到它最后显示任务的持久关联,并据此恢复刷新出的记录。 + +关联键是 Sidebar Session 加布局记录自身的 id。`dsh.sidebar-right.v1.` 持久化标签记录以及铸造其 id 的计数器,因此记录刷新后保持 id,该布局之后铸造的记录也不会复用它。记录的 `contentId` 不能作为键:同一种页面类型的所有标签都记录在同一个 `sidebar://` 地址下,而页面去重按 pane 进行,两个 pane 可以各有一个任务标签。每条记录还保存写入时该记录的 `kind` 和 `contentId`;读取时若这一对与读取它的记录不一致,就删除该条并视为无关联。 + +每个 Session 一个 localStorage 文档,键为 `dsh.schedule.task-tab.v1.`,按 tab id 保存一条记录,把该页面映射到其最后任务的 Session 和 id。不保存任何任务内容。写入时只保留 Session 已提交布局仍然持有的标签对应的记录,该布局通过 `ctx.sidebarRight.tabsIn(sessionId)` 读取;布局未被采用的 Session 保持其记录不变。标签页出现之后成功的一次读取无法解析的记录会被删除,使该标签显示未关联页签提示,与从未有关联的标签相同。 + +只要一次导航携带本类型的参数,正文就在 effect 中写入该关联;只有记录完全没有导航参数时才读取它,其他类型的参数让记录停留在任务缺失状态。恢复出的身份随后遵循既有的渲染规则:保留的记录包含该任务时即显示详情,判据之后请求的读取仍未给出该任务时显示目录反馈,读取失败时显示带重试的查询失败状态。这样的读取仍无法解析的关联显示未关联页签提示,而不是加载反馈,也绝不产生任务缺失提示,因为该提示的含义是打开方指名了一个任务。 + +正文以它为当前导航观察到的目录读取请求序号作为判据,并在保留的记录使标签页没有任务、且该序号之后没有读取成功时补发一次读取,该读取未落地期间显示目录反馈。Sidebar 会在不重新挂载正文的情况下对同一标签再次导航,因此以导航的 revision 移动判据;早于判据发出的读取永远不能断言该标签的任务已消失。 + +## Alternatives considered + +**用 `contentId` 作为关联键。** 否决:同一种页面类型的所有标签都记录在同一地址下,且页面去重按 pane 进行,两个 pane 的任务标签会共用一个关联并恢复出同一个任务。 + +**把导航参数持久化进 Sidebar 布局。** 否决:这会把内容参数放进 [导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 保持为临时的布局快照,且之后每种页面类型都会继承该格式。 + +**把 kind 声明为 `multiple: true`,让每次打开都铸造唯一 contentId。** 否决:为了取得一个键而改变打开任务的行为——打开第二个任务会新开一个标签,而不是在已有标签中显示;而且它无法恢复改动之前已存在的记录。 + +**从目录解析任务。** 否决:目录不保存按标签的关联,而打开该标签的 Session 入口在刷新后已不存在。 + +**复用 client store 的 localStorage 持久化。** 否决:`createSnapshotStore` 的 `persist` 选项把一个 store 的整体值保存在一个键下,并在该 store 创建时回填,因此需要按 Session 各建一个 store;而该关联还必须拒绝复用的 id 并在写入时清理已关闭标签,这些由一份按键文档在一处完成,也不需要正文为其订阅重渲染。 + +**在关联旁保存任务标题以确认恢复。** 否决:标题属于任务内容,会随编辑变化,而已定论的目录读取本身就能判断绑定的 id 是否可解析。 + +## Consequences + +刷新后的 Session 无需重新打开任务即可恢复每个任务标签的详情和 chip。该关联是窗口内的浏览器状态:其他浏览器、被清除的站点数据,或被布局工具包复制出的标签都没有它,这类记录会说明该页签未关联任务,而不会声称任务已不存在。被丢弃的布局快照可能铸造出关联仍然指名的 tab id;kind 与 contentId 守卫会拒绝其他类型的记录,而同类型记录会因该标签的下一次导航被覆盖,或在任务不再可解析时被删除。 + +布局不再被采用的 Session 的记录会保留到其下次写入;写入时的清理使每个已采用的 Session 有界,且任何记录都不含内容。 + +## Testing + +单元测试覆盖:导航时写入、仅在无参数时读取、详情与 chip 的恢复、无法解析关联的降级、已提交标签的清理、复用 id 的拒绝、损坏的存储文档,以及存储缺失或拒绝写入的情况。 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index 95f9c571b7..74f90930fc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml @@ -2,36 +2,30 @@ # 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/feature/2026-08-05-durable-web-schedule.md -/agent-note-durable-session-local-reminders: - en: c40fc2bc6dd1b469 - zh: c3932a1c8c60579b -/agent-note-durable-session-local-reminders/problem: - en: 75d2d50c48475d22 - zh: 293c507e9e516f29 -/agent-note-durable-session-local-reminders/decision: - en: 7fd21223d6306a39 - zh: b792a415d9dccba0 -/agent-note-durable-session-local-reminders/decision/session-log-authority-and-tools: - en: 23a928cba17217d6 - zh: 7995b144db6e4c70 -/agent-note-durable-session-local-reminders/decision/explicit-absolute-time-boundary: - en: 2e1fe906d42245aa - zh: bd21d4a2dd7d8afc -/agent-note-durable-session-local-reminders/decision/bounded-fixed-rate-semantics: - en: 2ebada22d530b18a - zh: 90e7dd128c8ba54a -/agent-note-durable-session-local-reminders/decision/live-delivery-lifecycle: - en: e69281749cf18500 - zh: 9ce38e39e486f2d6 -/agent-note-durable-session-local-reminders/decision/read-only-web-catalog: - en: 6ea0fcb2512d1434 - zh: ef9e430200677c9f -/agent-note-durable-session-local-reminders/alternatives-considered: - en: d86b5e8b0380c22a - zh: e6b6fce158608b08 -/agent-note-durable-session-local-reminders/verification: - en: 716fce79e742ecfa - zh: 6abb1480f1b9e2d9 -/agent-note-durable-session-local-reminders/consequences: - en: 7abf5e7d73b32153 - zh: c178fb3f1f28dee4 +/agent-note-durable-scheduled-reminders: + en: 7d312bc635f68bef + zh: f675a214bfe26dac +/agent-note-durable-scheduled-reminders/problem: + en: 85e09d88c1383aeb + zh: 2d90fcbf2fe5ad3c +/agent-note-durable-scheduled-reminders/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-durable-scheduled-reminders/decision/explicit-absolute-time-boundary: + en: 16cc539a319f99b4 + zh: ae06872529f2227d +/agent-note-durable-scheduled-reminders/decision/bounded-fixed-rate-semantics: + en: 4a12a91087629614 + zh: 4f23eee242eeadd3 +/agent-note-durable-scheduled-reminders/decision/queue-admission-is-not-completion: + en: 1d88821105254303 + zh: 08982a1534d8fd18 +/agent-note-durable-scheduled-reminders/alternatives-considered: + en: f6d44a87797410ca + zh: 383ac9e712492c93 +/agent-note-durable-scheduled-reminders/verification: + en: 113a6d381ff1b145 + zh: 59f9693741adbbab +/agent-note-durable-scheduled-reminders/consequences: + en: 2121b1640094e601 + zh: 9420468b80ff1155 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index b09ab752c9..f918a6f44f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -1,93 +1,49 @@ -# Agent Note: Durable Session-local reminders +# Agent Note: Durable scheduled reminders Status: implemented English | [中文](2026-08-05-durable-web-schedule.zh.md) +The [Host-owned scheduled messages decision](../architecture/2026-09-16-host-schedule-storage.md) owns task storage, timer activation, dispatch, delivery records, and the task catalog. This note owns the two timing decisions that survive it — the explicit absolute-time boundary and bounded fixed-rate arithmetic — and the queue-admission boundary they share. + ## Problem -A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. A process-local timer or inbox item cannot provide that durability, while a global scheduler or private database introduces a second identity, persistence, and lifecycle system. - -Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, absolute calendar input, and teardown make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and avoid spreading Schedule-specific presentation or time-zone state across unrelated components. +A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. Busy Agents, long waits, wall-clock changes, cold Sessions, and persistence failures make a simple timeout insufficient. Absolute calendar input and repeated wake-ups raise two timing questions that storage and activation choices do not answer: what an absolute time means when no recurring rule stores a zone, and how a fixed interval catches up after downtime without replaying every missed occurrence. ## Decision -The [Schedule guide](../../../../docs/user/guide/schedule.md) uses an overlay that explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-schedule`, and enables the Web bundle's otherwise-disabled `ui-schedule` row. The default Web startup graph remains inactive for Schedule. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate the runtime. - -The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md)). - -| Scenario | Durable fact | Live behavior | User-visible result | -| --- | --- | --- | --- | -| Create and manage | `schedule/change` create/delete in the original Session | Agent-scoped tools checkpoint before reads and after mutations | Stable id, UTC target, state, and `session-local` disclosure | -| Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn | -| Several Every records are overdue | Each active record retains its earliest unaccepted anchor-aligned target | One decision selects each record's latest occurrence and advances it past now | One ordinary follow-up containing one occurrence per record | -| Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted | -| Fork | Parent events remain in the inherited prefix | Child fold starts at the exact `inheritedEventCount` | Parent work does not become active in the child | - -### Session-log authority and tools - -The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after the `inheritedEventCount` passed into projection initialization. - -When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`; the shared [projection state decision](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md) owns its initialization and restore contract. Corrupt durable input fails the existing read path rather than yielding a partial array. The browser-safe record vocabulary is exposed through the type-only `@deepseek-ai/dsh-schedule/client` subpath. - -The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. `every_seconds` is a safe integer of at least 300 whose `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record stays aligned to its creation-plus-interval sequence. One-shot dispatch stores only the id; Every dispatch stores `id + acceptedAt`. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`. - -An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed. - -Every successful management preflight asks the live owner to recompute. A later list can therefore confirm a retained create after a previous post-append rejection and arm it without a private persistence-retry timer. - ### Explicit absolute-time boundary Natural-language interpretation and Schedule parsing are deliberately separate ([time-zone simplification](../simplification/2026-08-09-explicit-schedule-time-zone.md)). Each browser prompt carries its Host-validated IANA zone only on that durable user message. Time-context tells the model to assume that zone for otherwise-unqualified dates and times. Schedule neither imports that plugin nor stores a Session zone: the model must turn its interpretation into an offset-bearing RFC 3339 value or a local object with explicit `time_zone`. -Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A successful create stores only canonical UTC `scheduledAt`, not the original offset, local fields, or zone. +Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A structured local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A one-time `at` or `after` record stores only the canonical UTC `scheduledAt`, not the input offset, local fields, or zone. A `daily`, `weekly`, or `cron` record instead stores its normalized local rule with an explicit `timeZone` ([timing selectors](../../../../packages/schedule/schedule/README.md)). ### Bounded fixed-rate semantics -Every is a fixed-duration interval, not a calendar rule. The first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence. +Every is a fixed-duration interval, not a calendar rule. Its first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence. Every, Daily, Weekly, and Cron records overdue for one Session share one message, and each record contributes only its latest occurrence ([storage and dispatch](../architecture/2026-09-16-host-schedule-storage.md)). -All distinct overdue Every records participate in one batch, each with one latest occurrence and one shared `acceptedAt`. There is no cross-record cooldown, gate, quota, or retained batch timestamp. A five-minute minimum bounds wake and model-request frequency. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record. +A five-minute minimum bounds wake and model-request frequency. There is no cross-record cooldown, gate, quota, or retained batch timestamp. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record. -Calendar and Cron expressions are deliberately absent ([bounded recurrence simplification](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md)); supporting them would add a time-zone-sensitive calendar language, evaluator dependency, validation surface, and tzdata replay policy unrelated to fixed-rate reminders. +### Queue admission is not completion -### Live delivery lifecycle - -The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. Due one-shots have priority and are admitted one at a time; otherwise every overdue Every record enters one batch in target and creation order. If a turn or maintenance task owns the Agent, `runMaintenance()` rejects the claim; the records stay active and one `whenIdle()` wait triggers another attempt. A rejected preflight or contained framing/enqueue failure also leaves them active without starting a private retry timer. - -The accepted path clears pending persistence and claims the true idle phase. It refolds the exact Session suffix, samples the decision clock, constructs fixed reminder framing with JSON-escaped values, synchronously queues one `followup()`, and appends dispatch before releasing maintenance. A one-shot appends an id-only terminal dispatch. A fixed-rate batch appends one `id + acceptedAt` transition per participating record. Waking input remains parked until release, so the message cannot be claimed before dispatch enters the log; afterward the owner checkpoints dispatch. - -Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise. - -### Read-only Web catalog - -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction. +Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. A crash after follow-up admission but before the durable receipt can repeat the reminder after recovery; the design makes no exactly-once promise. ## Alternatives considered -**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups. - -**Store reminders in a private database or global scheduler.** This could run cold Sessions but requires a second identity map, startup scan, ownership lease, crash protocol, and notification policy. +**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than durable task state and conversation follow-ups. **Persist a Session time zone and infer local `at`.** This spreads one interpretive default through Session core, Host create/fork, persistence formats, clients, and mismatch recovery. Request-local model guidance plus an explicit tool boundary deletes that coupling. -**Keep an independent durable Web receipt.** Dispatch is an internal queue fact, not the user's reminder. Rendering the ordinary assistant answer avoids a second delivery meaning and removes Schedule code from Host and client layers. +**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this design. -**Add a general recurring-rule engine.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving the retained product behavior. - -**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this Session-local scope. - -**Adopt existing roots or register global tools.** Late adoption makes plugin load order activate unseen timers and exposes tools outside the supported root composition. +**Add a general recurring-rule engine for fixed intervals.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving fixed-rate behavior. The cron selector is a separate wall-clock rule with its own Host-owned dialect and evaluation ([cron input](../../../../docs/subsystems/schedule.md#cron-wall-clock-input)). ## Verification -Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update. +Package tests pin fixed-rate arithmetic, creation anchors, latest-only catch-up, batch selection, IANA validation, daylight-saving gaps and overlaps, and time bounds at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. ## Consequences -- Reminder state survives restart through ordinary Session persistence without a new database or public service. -- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work. - Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context. -- Users see normal conversation output; dispatch never overstates model success or acknowledgement. -- Opt-in Web users can inspect the complete active set and recognize cache-known active Sessions in ordinary or search rows without creating a second durable state, runtime signal, or delivery meaning. -- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. -- Fixed-rate recurrence is bounded by a five-minute minimum, latest-only catch-up, and one batched occurrence per overdue record; calendar recurrence remains outside this product boundary. +- A fixed interval stays aligned to its creation anchor, presents one occurrence per overdue record, and cannot accumulate a replay backlog. +- Delivery never overstates model success or acknowledgment, and a crash between queue admission and the durable receipt can repeat a message. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 8d84be3e35..dec8916db7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -1,93 +1,49 @@ -# Agent Note: 持久、仅限 Session 内的提醒 +# Agent Note: 持久定时提醒 Status: implemented [English](2026-08-05-durable-web-schedule.md) | 中文 +[Host 拥有的定时消息决策](../architecture/2026-09-16-host-schedule-storage.zh.md)拥有任务存储、timer 激活、投递、发送记录和任务目录。本记录拥有在该决策之下仍然成立的两个时间决策——显式绝对时间边界与有界固定速率计算——以及二者共有的队列准入边界。 + ## 问题 -在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。进程本地 timer 或 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。 - -繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session、fork、持久化失败、绝对日历输入和资源释放,使简单 timeout 无法满足要求。设计必须区分持久记录与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并避免把 Schedule 专属的呈现或时区状态扩散到无关组件。 +在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session 和持久化失败使简单 timeout 无法满足要求。绝对日历输入与重复唤醒带来两个计时问题,而存储与激活方式的选择并不能回答它们:当重复规则不保存时区时,绝对时间意味着什么;固定间隔在停机后如何追赶,而不重放每一个错过的发生时点。 ## 决策 -[Schedule 指南](../../../../docs/user/guide/schedule.zh.md)使用显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-schedule`,并启用 Web bundle 中默认 disabled 的 `ui-schedule` row 的 overlay。默认 Web 启动图不会激活 Schedule。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活 runtime。 - -用户可见边界是 `session-local`:原 Session 只有在 live 时才会准时运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。到期工作会等待 Agent 完全 idle,再通过 `followup()` 进入普通的下一轮队列;它绝不会中途引导当前轮次,也没有独立 Web 回执([对话式交付](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md))。 - -| 场景 | 持久事实 | live 行为 | 用户可见结果 | -| --- | --- | --- | --- | -| 创建与管理 | 原 Session 中的 `schedule/change` create/delete | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、状态与 `session-local` 说明 | -| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 | -| 多条 Every 记录逾期 | 每条活动记录都保留最早一个尚未接受且与锚点对齐的目标 | 一次决策选择每条记录的最新发生时点,并将其推进到当前时刻之后 | 一个普通 follow-up,其中每条记录各有一个发生时点 | -| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 | -| fork | 父 event 留在继承前缀 | child fold 从精确 `inheritedEventCount` 开始 | 父工作不会在 child 中变为活动状态 | - -### Session 日志权威与工具 - -版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠传入 projection 初始化的 `inheritedEventCount` 位置及其后的 event。 - -`ctx.sessionProjections` 存在时,Schedule 会注册一个复用同一 transition 的严格单元,并发布完整的活动 `ScheduleRecord[]`;共享的 [projection state 决策](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)拥有其初始化与 restore 约定。损坏的持久输入会使既有读取路径失败,而不会产生部分数组。浏览器安全的记录词汇通过纯类型子路径 `@deepseek-ai/dsh-schedule/client` 暴露。 - -当前规则 union 接受非空提示词和恰好一个 selector。`after_seconds` 是正的安全整数 delay,其记录为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的值,也可以是带显式时区的结构化 `{ date, time, time_zone }`;其记录为 `{ id, kind: 'at', prompt, scheduledAt }`。`every_seconds` 是不小于 300 的安全整数,其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` 记录始终与从创建时刻加一个间隔开始的序列对齐。一次性 dispatch 只存储 id;Every dispatch 存储 `id + acceptedAt`。工具值派生 `scheduled` 或 `overdue`,并包含 `deliveryMode: 'session-local'`。 - -一个 Agent-scoped FIFO 会将管理事务与 live owner 的到期事务从 preflight 到 post-append barrier 全程串行化。每项工具读取都会先等待 `ctx.sessions.flush(session)`。create 会尽可能在进入 FIFO 前拒绝输入形状错误,随后执行 preflight、分配 id、追加记录并再次 checkpoint。delete 会在进入 FIFO 前验证 id,在判断其是否活动前执行 preflight,并且只在追加后再次 checkpoint。list 与 not-found delete 绝不会根据未经确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。 - -每次成功的管理 preflight 也会要求 live owner 重新计算。因此,如果先前的 post-append 被拒绝,后续 list 可以确认保留的 create 并将其 arm,而无需私有的 persistence 重试 timer。 - ### 显式绝对时间边界 自然语言解释与 Schedule 解析被有意分开([时区简化](../simplification/2026-08-09-explicit-schedule-time-zone.zh.md))。每条浏览器提示词只在其对应的持久 user message 上携带由 Host 校验过的 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该时区。Schedule 既不导入该插件,也不存储 Session 时区:模型必须把其解释结果转换为带偏移量的 RFC 3339 值,或带显式 `time_zone` 的本地对象。 -Schedule 会校验精确的日历形状、偏移量、时区名称,以及一个严格位于未来、年份为四位数的时点。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,不会存储原始偏移量、本地字段或时区。 +Schedule 会校验精确的日历形状、偏移量、时区名称,以及严格位于未来、年份为四位数的时点。结构化本地时间落在夏令时缺口内会被拒绝;遇到重叠时选择第一次出现的较早时点。一次性 `at` 或 `after` 记录只存储规范化的 UTC `scheduledAt`,不存储输入的偏移量、本地字段或时区。`daily`、`weekly` 或 `cron` 记录则存储规范化后的本地规则及其显式 `timeZone`([时间选择器](../../../../packages/schedule/schedule/README.zh.md))。 ### 有界固定速率语义 -Every 是固定时长间隔,而不是日历规则。第一个目标是创建时刻加上一个间隔。作出到期决策时,整数除法会选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录会直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。 +Every 是固定时长间隔,而不是日历规则。其首个目标是创建时刻加上该间隔。作出到期决策时,整数除法选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。同一 Session 内到期的 Every、Daily、Weekly 和 Cron 记录共用一条消息,每条记录只贡献其最新一次发生时点([存储与投递](../architecture/2026-09-16-host-schedule-storage.zh.md))。 -所有不同的逾期 Every 记录都会参与同一个批次,每条记录各自提供一个最新发生时点,并共享同一个 `acceptedAt`。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。至少 5 分钟的限制约束了唤醒与模型请求频率。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。 +至少五分钟的下限约束唤醒与模型请求频率。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。 -日历表达式与 Cron 表达式被有意排除([有界周期性简化](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md));支持这些表达式需要增加时区敏感的日历语言、求值器依赖、校验范围和 tzdata 回放策略,而这些都与固定速率提醒无关。 +### 队列准入不是完成 -### Live 交付生命周期 - -Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都会重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。已到期的一次性提醒优先,每次准入一条;否则,所有逾期 Every 记录会按目标时间和创建顺序进入同一个批次。如果 Agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;这些记录保持活动,并由一次 `whenIdle()` wait 触发另一次尝试。被拒绝的 preflight 或被收容的 framing/入队失败同样会使其保持活动,但不会启动私有重试 timer。 - -获得准入的路径会刷新所有 pending persistence 并认领真正的 idle phase。它会重新折叠确切的 Session 后缀、采样 decision clock、用经过 JSON 转义的值构造固定提醒 framing、同步排入一个 `followup()`,并在释放 maintenance 前追加 dispatch。一次性提醒会追加只含 id 的终结 dispatch。固定速率批次会为每条参与记录追加一个 `id + acceptedAt` 转换。触发唤醒的 input 会保持 parked,直到 maintenance 释放,因此在 dispatch 进入日志前,消息不会被认领;随后 owner 会为 dispatch 执行 checkpoint。 - -dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。 - -### 只读 Web 目录 - -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。 +dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。follow-up 获得准入后、持久回执前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。 ## 已考虑的替代方案 -**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。 - -**把提醒存入私有数据库或全局 scheduler。** 这样可以运行 cold Session,却需要第二套身份映射、启动扫描、ownership lease、崩溃协议和通知策略。 +**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是持久任务状态和对话 follow-up。 **持久化 Session 时区并推断本地 `at`。** 这会让一个解释默认值扩散到 Session core、Host create/fork、持久化格式、client 和不匹配恢复中。请求本地的模型指导与显式工具边界消除了这种耦合。 -**保留独立的持久 Web 回执。** dispatch 是内部队列事实,而不是用户的提醒。渲染普通 assistant 回答既避免了第二种交付含义,也从 Host 与 client 层移除了 Schedule 代码。 +**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了本设计范围。 -**增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于保留的产品行为。 - -**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了此 Session-local 范围。 - -**接管既有根或注册全局工具。** 晚接管会让插件加载顺序激活不可见的 timer,并把工具暴露到受支持的根组合之外。 +**为固定间隔增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于固定速率行为。cron 选择器是独立的钟表规则,拥有自己的 Host 所有方言与求值([cron 输入](../../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input))。 ## 验证 -包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。 +包测试以逐文件 100% coverage 固定固定速率计算、创建锚点、只追赶最新一次、批次选择、IANA 校验、夏令时缺口与重叠以及时间边界。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。 ## 后果 -- 提醒状态通过普通 Session persistence 跨重启存活,无需新数据库或公开 service。 -- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。 - 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。 -- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。 -- 显式启用 Schedule 的 Web 用户可以查看完整活动集合,并在普通行或搜索结果中辨认 cache 已知的活动 Session,而不会引入第二份持久状态、runtime 信号或第二种交付含义。 -- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。 -- 固定速率周期性受到至少 5 分钟、只追赶最新一次,以及每条逾期记录只在一个批次中贡献一个发生时点的约束;日历周期性仍在此产品边界之外。 +- 固定间隔始终与其创建锚点对齐,每条逾期记录只呈现一个发生时点,且不会积累回放任务。 +- 投递绝不会夸大模型成功或 acknowledgement;队列准入与持久回执之间发生崩溃可能使消息重复。 diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml index 52afcd886f..b8b63582e2 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml @@ -6,8 +6,8 @@ en: 7b69ac848815886a zh: db7a9f4000fe5359 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/problem: - en: 03d0dad3061794af - zh: 4dbef64cd45c4064 + en: 87965b88be0e831f + zh: d96767a2cef52269 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision: en: da6d3f0677f0343b zh: 8b5dcf7e1108edc9 @@ -15,8 +15,8 @@ en: 2f23fa55e1ce652c zh: 335695fc654ab1db /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/the-decision-rule: - en: 3734096cc35b0c86 - zh: 7b6f4ceaae602c87 + en: 554d01427ebebee1 + zh: 664c9bf0a9337a04 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting: en: 00a91b4fb592e1e4 zh: dac2e4f0a3ee9ecf @@ -24,11 +24,11 @@ en: c21a3e7a773860e5 zh: 566669e245ccdbaf /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered: - en: bb8f951694de2aea - zh: d53960bf0ec052c7 + en: f3dec242b2550529 + zh: 0397fa07cc1806bb /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences: en: beda064bcd194a2a zh: 8a3bb81d5edcd60a /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/testing: - en: 4b09d253309f2d4d - zh: f897ffad3a1b2fbe + en: 3f9750d67404ac92 + zh: 80cb9aced02e2950 diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md index aaaf44d638..644592d2a1 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md @@ -8,7 +8,7 @@ English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md) Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable. -A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm. +A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix; [dynamic tool updates](../architecture/2026-09-20-dynamic-tool-updates.md) can defer additions, while changed retained definitions still invalidate reuse. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm. The harness has the representation for this because the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is an operation on `system/message` surface nodes, and the choice between "replace the latest system node" and "append a new node" is a per-route decision. @@ -33,7 +33,7 @@ The loop records the mode in the session: `RequestContext.systemPromptUpdate` jo | `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged | | any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages | -`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt. +`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt. The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary. @@ -69,7 +69,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat **Use the preceding request context for admission.** It describes the previous call, not the adapter bound after request middleware. It can select the wrong prompt representation on the first call, after resume, or after a route or capability change. Resolving before prompt and user commits also keeps cancellation from admitting unsent content. Rejected. -**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching the tools case. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected. +**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching a tool change on an incapable route. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected. **Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected. @@ -89,7 +89,7 @@ Lifecycle verification requires no event for an unchanged prompt and an appended - `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` covers capable-to-incapable routing with changed or unchanged text, incapable-to-capable routing, resumed-route admission, cancellation in request middleware or preparation, and a concurrent selection change while the prepared route stays bound. Retry-compaction cases shadow the latest prompt with or without an earlier surviving update and verify reuse of the admitted assembly, one user admission, and no extra series header on an unchanged retry. Clear cases on capable and incapable routes remove three active prompt versions, keep repeated requests and seeded resume empty without extra prompt events, and restore only the new text; log reconstruction and the pi converter retain no old instructions. Focused coverage of `src/agent.ts` and `src/runtime-context.ts` reaches 100% for statements, branches, functions, and lines. - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on a continuing series, the re-baseline at a series start with or without surviving later nodes and with changed or unchanged effective text, the empty-prompt clearing of all active versions, and the replace-only behaviour without the capability. -- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline under a `change` header that starts a series. +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline on a route without `toolUpdate`, under a `change` header that starts a series. - `packages/llm/llm/tests/service.spec.ts`, `packages/llm/llm-deepseek/tests/adapter.spec.ts`, and `packages/test-support/llm-replay/tests/llm-replay.spec.ts` pin the declared mode on resolved model info and the load-time rejection of any other value. - `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins newest/middle prompt removal, exact heuristic totals, surface ordering after head rewrites, extra source-event citations, dormant empties and fallback clears, immutable transitions, compact retained checkpoints, late registration, replay, and version invalidation. - `packages/client/ui-conversation`, `ui-chat`, and `ui-trajectory` client specs pin the update card, the same-step header dedupe, the absent system change after an update, and the synthetic trajectory header. diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md index 73003b04de..b734bc74f7 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md @@ -8,7 +8,7 @@ Status: implemented 每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。 -一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。 +一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀;[动态工具更新](../architecture/2026-09-20-dynamic-tool-updates.zh.md)可以延迟新增声明,而保留定义的变化仍使复用失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。 因为[系统提示词是 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md),harness 拥有实现这一点的表示:提示词变更是对 `system/message` surface 节点的操作,而「替换最新的系统节点」与「追加新节点」之间的选择是逐路由的决定。 @@ -33,7 +33,7 @@ Status: implemented | `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 | | 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 | -`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。 +`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。 首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。 @@ -69,7 +69,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释 **用先前的请求上下文决定准入。** 它描述上一次调用,而非请求中间件之后绑定的适配器。在首次调用、恢复之后、路由或能力变更之后,它可能选错提示词表示。在提交提示词与用户消息之前解析,还能防止取消时接纳未发送的内容。被否决。 -**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与 tools 的情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。 +**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与不具备能力的路由上的工具变更情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。 **仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。 @@ -89,7 +89,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释 - `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` 覆盖文本变化或未变时从具备能力切换到不具备能力的路由、反向路由切换、恢复时的路由准入、请求中间件或准备阶段取消,以及已准备路由保持绑定时并发选择发生变化。重试压缩用例覆盖遮蔽最新提示词后有或没有更早更新存活的情况,并验证复用已接纳的组装结果、用户消息仅接纳一次,以及未变的后续重试不会多记序列 header。具备和不具备能力路由的清除用例会移除三个生效提示词版本,验证重复请求与带 seed 的恢复保持为空且不多记提示词事件,并仅恢复新文本;日志重建与 pi 转换器都不保留旧指令。`src/agent.ts` 与 `src/runtime-context.ts` 的聚焦覆盖率在语句、分支、函数和行四项均达到 100%。 - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住序列延续时的追加、序列开始时无论是否存在后续存活节点、有效文本是否变化都执行的重新基线化、空提示词对所有生效版本的清除,以及不具备能力时只做替换的行为。 -- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。 +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在不支持 `toolUpdate` 的路由上、开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。 - `packages/llm/llm/tests/service.spec.ts`、`packages/llm/llm-deepseek/tests/adapter.spec.ts` 与 `packages/test-support/llm-replay/tests/llm-replay.spec.ts` 钉住已解析模型信息上声明的模式,以及加载时对任何其他值的拒绝。 - `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住最新与中间提示词移除、精确启发式总量、头部改写后的 surface 顺序、额外来源引用、休眠空节点与回退清空、不可变转换、紧凑保留检查点、延迟注册、重放和版本失效。 - `packages/client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 的客户端测试钉住更新卡片、同一步骤 header 的去重、更新之后不存在系统变更,以及合成的轨迹 header。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml index 0e6f21dae4..fd0a14a09f 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -9,8 +9,8 @@ en: f1aeaa7df97a744f zh: 039b2cccd9a45198 /agent-note-desktop-mandatory-update-client-and-blocking-window/decision: - en: fc0fa9c713fb74e8 - zh: 783f643486eeccaa + en: 7d8ece211be36774 + zh: d25c3ebac6420187 /agent-note-desktop-mandatory-update-client-and-blocking-window/alternatives-considered: en: 136afb04a37db4a1 zh: 2b049e0d9fd8aa27 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md index ff55f30836..838403a2b1 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -10,9 +10,11 @@ A local business server does not deliver remote minimum-version policy. The Desk ## Decision +Native update overlays own their parent-visibility subscription until closure. On macOS, hiding a parent hides its child windows without closing them; showing the parent does not restore those children. The overlay waits for both document readiness and a visible parent, then restores itself on subsequent parent show events. Keeping this in the shared overlay preserves ordinary and mandatory presentation without coupling update policy to onboarding or clearing an active block. + The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy. -A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. +A coordinator owns one in-flight request, a deadline, interval jitter, and bounded failure backoff. The request identifies the installed client with the same five Platform client headers as account requests — platform, client version, locale, UTC offset in seconds, and an intentionally empty bundle id — and adds architecture, update channel, and bundled runtime version. Platform, architecture, update channel, and bundled runtime version are fixed at packaging, while the locale and UTC offset are sampled from the shell for each request and the version is the shared client build version rather than the Electron bundle version. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md index 6c17a9fbd0..f3c862fdcc 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -10,9 +10,11 @@ Status: implemented ## 决策 +原生更新浮层在关闭前持有父窗口可见性监听。在 macOS 上,隐藏父窗口会隐藏但不会关闭其子窗口;重新显示父窗口不会恢复这些子窗口。浮层等待文档就绪且父窗口可见,并在后续父窗口显示事件中恢复自身。由公共浮层处理这一行为,既保持普通更新和强更的展示,也不将更新策略与 onboarding 耦合或清除有效阻塞。 + 壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。 -协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 +协调器持有单一在途请求、截止时间、间隔抖动和有上限的失败退避。请求用与账号请求相同的五个 Platform 客户端请求头标识已安装客户端——平台、客户端版本、语言、以秒为单位的 UTC 偏移,以及有意保持为空的 bundle id——并额外携带架构、更新通道和内置运行时版本。平台、架构、更新通道和内置运行时版本在打包时固定;语言与 UTC 偏移在每次请求时从壳采样,版本取自共享的客户端构建版本而非 Electron 打包版本。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml new file mode 100644 index 0000000000..636682a60e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-16-desktop-onboarding.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/feature/2026-09-16-desktop-onboarding.md +/agent-note-device-local-desktop-onboarding: + en: 84a5519665d72899 + zh: beaadffa0e139459 +/agent-note-device-local-desktop-onboarding/problem: + en: 50c7c883a2115b7e + zh: 60533d618a3b2da7 +/agent-note-device-local-desktop-onboarding/decision: + en: e228d2a308f8ad8a + zh: c5eee26119226f32 +/agent-note-device-local-desktop-onboarding/alternatives-considered: + en: 82debfeba02ecfdb + zh: c15d8fcc012058e3 +/agent-note-device-local-desktop-onboarding/consequences: + en: 65d1dbaf6b39d57a + zh: fa5b562c059b3203 diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md new file mode 100644 index 0000000000..e8b1b99279 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md @@ -0,0 +1,33 @@ +# Agent Note: Device-local Desktop onboarding + +Status: implemented + +English | [中文](2026-09-16-desktop-onboarding.zh.md) + +## Problem + +Account users need an introduction to credit and display choices after native authentication. The native credential entry already has a separate startup responsibility; repeating browser onboarding or treating missing account data as a new user would couple presentation to authentication failures. + +## Decision + +The [account UI](../../../../packages/client/ui-settings-account/README.md#desktop-onboarding) mounts one shell overlay only when the Desktop preload marker exists, an account credential is stored, and the local introduction is unfinished. Host settings own versioned progress and completion for the installation, without a Platform completion flag or per-account history. Restart resumes the saved step; signing out hides the introduction without erasing it. A signed-out installation with configured model credentials records API-key completion after applying standard process, detailed usage, and enabled developer tools. Account sign-in discards a failed API-key completion write so retry cannot bypass the required welcome page. + +Welcome requires an explicit start. The credit page always appears, including for positive balances. Only a ready balance with no positive wallet triggers the additional continue-without-credit confirmation; failed or pending queries never imply zero. Returning from native recharge retains the credit page while the account owner refreshes details. Closing the application during recharge retains the credit step. + +Purpose and process selections persist as drafts. Office-only completion selects compact presentation. Development or both purposes require a process choice. Skipping any supported step selects standard process with compact usage and developer tools off. Completion applies Chat and shared developer-tool preferences before saving the done marker so a refused preference write cannot silently finish onboarding. Compact, standard, and detailed persist their corresponding `ui-chat.transcriptView` values. + +The existing [native welcome decision](../architecture/2026-09-08-desktop-welcome-window-material.md) owns credential entry, IPC restrictions, and window material. Desktop suppresses automatic Web credential onboarding while retaining explicit API-key editing. Onboarding creates no workspace, demonstration task, or Session event. Static Figma illustrations use transparent, palette-compressed 3× PNG assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration. The account package owns the full-window overlay and its control styling; shared primitives retain generic behavior. Brand headings load a bundled, licensed Montserrat WOFF2 font through the Web entry. Choices retain focus during writes, reduced-motion preferences disable animation, and the application remains inert until the exit overlay unmounts. The confirmation dialog blurs its source page instead of sampling another backdrop in the translucent window. + +## Alternatives considered + +**Cloud or per-account completion.** The current release requires device-local progress and has no account completion API. Reusing login state as completion would conflate successful authentication with finished setup. + +**Skipping credit for positive balances or waiting for recharge confirmation.** The introduction explains billing independently of wallet state. Waiting for a balance query would turn return navigation into a network-dependent action without establishing whether a payment completed. + +**Duplicating preference controls.** Onboarding writes work details, usage, and developer tools through their existing owners. + +## Consequences + +An existing API-key installation without an onboarding completion marker also receives these defaults on its first upgraded launch, replacing any prior Chat display, usage, and developer-tools preferences. Installations already marked complete retain their preferences. + +One installation's completion applies across account changes, and another installation runs its own introduction. Preference and completion writes are ordered but are not one transaction; retry can reapply the same preference after a failed completion write. Completed installations do not reapply preferences on subsequent launches. The [controller tests](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts) and [UI tests](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx) own state and interaction coverage; production authorization and payment behavior remain separate integration validation. diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md new file mode 100644 index 0000000000..8a23ff191a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 设备本地 Desktop 引导 + +Status: implemented + +[English](2026-09-16-desktop-onboarding.md) | 中文 + +## 问题 + +账号用户在完成原生认证后,需要了解额度并选择展示方式。原生凭证入口已经承担独立的启动职责;重复运行浏览器引导,或将缺失的账号数据当成新用户,会把展示流程与认证故障耦合。 + +## 决策 + +[账号 UI](../../../../packages/client/ui-settings-account/README.zh.md#desktop-onboarding)仅在存在 Desktop preload 标记、已存储账号凭证且本地引导未完成时,挂载一个 shell 覆盖层。Host 设置持有当前安装的版本化进度与完成状态,不使用 Platform 完成标记或逐账号历史。重启恢复已保存的步骤;退出登录会隐藏引导,但不清除进度。未登录且已配置模型凭证的安装会记录 API Key 完成状态,并应用标准过程、详细用量和开启开发者工具的配置。账号登录时丢弃失败的 API Key 完成写入,避免重试绕过必需的欢迎页。 + +欢迎页必须显式开始。额度页始终出现,包括余额为正的情况。仅在余额已就绪且没有正余额钱包时,才额外确认是否继续而不充值;查询失败或等待中绝不表示余额为零。从原生充值页面返回后保留额度页,由账号属主在后台刷新资料。充值期间关闭应用会保留额度步骤。 + +用途和过程选择会作为草稿持久化。仅选办公用途时采用简洁展示并完成。选择开发或两种用途时必须选择过程详情。任意可跳过步骤均采用标准过程、简洁用量和关闭开发者工具。完成时先应用 Chat 和共享开发者工具偏好,再保存完成标记,避免偏好写入被拒绝后静默完成引导。compact、standard 和 detailed 各自持久化为对应的 `ui-chat.transcriptView` 值。 + +既有[原生欢迎决策](../architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责凭证入口、IPC 限制和窗口材质。Desktop 抑制自动 Web 凭证引导,保留显式 API Key 编辑。引导不创建工作区、示例任务或 Session 事件。静态 Figma 插图使用透明、调色板压缩的 3× PNG 资源;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊。账号包负责整窗覆盖层及其控件样式,共享基础组件保留通用行为。品牌标题通过 Web 入口加载附带许可的 Montserrat WOFF2 字体。保存选择时保留焦点,减少动态效果偏好会禁用动画,应用在退出覆盖层卸载前保持不可交互。确认弹窗模糊源页面,避免在半透明窗口内再次采样背景。 + +## 考虑过的替代方案 + +**云端或逐账号完成状态。** 当前版本要求设备本地进度,且没有账号完成 API。将登录状态用作完成标记,会混淆认证成功与设置完成。 + +**正余额时跳过额度页,或等待充值确认。** 引导独立于钱包状态说明计费。等待余额查询会使返回导航依赖网络,却无法确定是否完成支付。 + +**重复实现偏好控制。** 引导通过现有属主写入工作过程、用量与开发者工具配置。 + +## 后果 + +尚无引导完成标记的已有 API Key 安装,在升级后首次启动时也会应用这些默认值,覆盖之前的 Chat 展示、用量和开发者工具偏好;已经标记完成的安装保留原偏好。 + +同一安装的完成状态适用于切换后的账号,另一安装则运行自己的引导。偏好与完成状态按顺序写入,但不属于同一事务;完成标记写入失败后,重试可能再次应用同一偏好。已完成引导的安装不会在后续启动时重新应用偏好。[控制器测试](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts)与 [UI 测试](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx)负责状态和交互覆盖;生产授权与支付行为仍需独立的集成验证。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 1609de0af7..490c2cd775 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -10,7 +10,7 @@ zh: b53c1a932d547f55 /agent-note-shared-developer-tool-settings/decision: en: 3fc14af35b025970 - zh: 0e6047f33b7e91db + zh: 8359262a5c5d3805 /agent-note-shared-developer-tool-settings/alternatives-considered: en: df1b80e5b8d2a178 zh: 0136d16fc44d57bf diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index e7a9d3c28d..d6e8e138a3 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -12,7 +12,7 @@ Status: implemented 设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml index eec425be40..bfc8acd582 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml @@ -9,11 +9,11 @@ en: 952ca70e83872f88 zh: 471b4e39dba2039a /agent-note-first-use-default-workspace/decision: - en: 7f51354b3082f290 - zh: 8f6ce1c14f11f888 + en: 5f70a77db26ed9e2 + zh: 8f6bfb84385315d4 /agent-note-first-use-default-workspace/alternatives-considered: - en: 65c93ecfbbcf22a9 - zh: c88ed47852200752 + en: 58523eb415e53e2a + zh: 714ddd81ef7d3262 /agent-note-first-use-default-workspace/consequences: en: ab39d4e9302a2641 zh: 8921bb8451ef2d0e diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md index 1674079403..b358cba3ad 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -14,7 +14,9 @@ Startup waits for complete Workspace and Session baselines. When both lists are The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently. -The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). +The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). + +The naming half of this decision is superseded: [language-neutral default Workspace naming](2026-09-23-language-neutral-default-workspace-naming.md) fixes the directory name and stored title and localizes only the on-screen label. Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message. @@ -23,7 +25,7 @@ Ineligible first use returns no Workspace without a failure dialog. Preparation - Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout. - Falling back to `/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override. - Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. -- Using the localized path as the initialization marker would allow language changes or deletion to create another default. +- Using the initialization directory's path as the initialization marker would allow renaming, relocation, or deletion to create another default. - Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md index 81a7c96efa..e9a8bd6d57 100644 --- a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -14,7 +14,9 @@ Status: implemented [Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。 -Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 +Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 + +本决策中与命名相关的部分已被取代:[语言中立的默认工作区命名](2026-09-23-language-neutral-default-workspace-naming.zh.md)固定目录名与存储标题,只本地化屏幕上的标签。 不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。 @@ -23,7 +25,7 @@ Client 在启动时按其语言解析初始目录名和标题。Host 控制器 - 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。 - 系统查询不可用时回退到 `/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。 - 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 -- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 +- 将初始化目录的路径作为初始化标记,会允许改名、迁移或删除后再次创建默认工作区。 - 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml index cda575574b..bf652c02d6 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml @@ -9,11 +9,11 @@ en: 7926983089aebab2 zh: 366e32db00674a83 /agent-note-archiving-a-session-with-running-work/decision: - en: ba02c1b57e6ca76b - zh: 2908fcda98c4734e + en: 23adfbe72d671d6c + zh: 6fec976acfa54720 /agent-note-archiving-a-session-with-running-work/alternatives-considered: - en: c9d0a5c8d9924ad6 - zh: f848ba8fa3c0d030 + en: 708268e1fe4b4b75 + zh: 64a8f6d6f333e090 /agent-note-archiving-a-session-with-running-work/consequences: - en: 977e6f36e62c0618 - zh: 706de6bc6bb06282 + en: cb3fdefd60483d62 + zh: 87d3cead6dc8c37c diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md index 2df18c00e6..4bc3bab644 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md @@ -14,7 +14,7 @@ Archive never hides running work. The registry owns the rule and declares one ca - **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is. - **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive. -- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active reminder as a management delete in its transaction queue — the same durable change the `schedule_delete` tool records, between the same two persistence barriers — answering from its owned runtime's fold of the live log rather than from the projection registry, which is a Client view. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. +- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. - **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. - **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work. @@ -32,10 +32,10 @@ Archive never hides running work. The registry owns the rule and declares one ca **Report the families from the API Session Controller.** The controller sees every live Agent, the job registry, and the lineage fields on Session headers, so it could enumerate turns, children, and jobs itself — but then it would know each owner's vocabulary (descriptors and labels, the parent-cause cancel, job ownership). The Agent registry, the job seam, and the Subagent runtime already own that vocabulary, so each family lives with its owner; the controller keeps only the lineage step gate, which reads the archive set and header fields alone. The gate cannot move into `dsh-workspace` in turn: the owners now depend on its types, so the workspace package cannot depend on the Agent event vocabulary without a cycle. -**Refuse cold Sessions with active reminders.** A cold reminder fires only after a resume; an archived Session cannot be opened from the sidebar, and a resume through another operation meets the pre-step gate, so nothing runs hidden; only live Sessions report the `schedule` family. +**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)). ## Consequences -An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's delete barrier leaves an archived Session whose reminders are still recorded; unarchiving shows them again, as if the stop had never been requested. A cold archived Session reports no reminders, and an operation that resumes its Agent — rename, model, or queue edits address a Session without unarchiving it — re-arms them: a due one-shot then dispatches its follow-up into a turn the gate ends as `blocked`, so that occurrence is consumed without a model step and is not replayed after unarchive. +An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's stop leaves an archived Session whose stored tasks are still armed; unarchiving shows them again, as if the stop had never been requested, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself, so a one-shot dispatches its follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step and is not replayed after unarchive. Admission itself reports the stored tasks of a Session without a live Agent, so such a Session is refused before that residue can start. Peer products surveyed for this choice: Codex and Kimi Code stop then archive; Claude Code stops by starving the remote session of writes; Reasonix and MiniMax refuse a busy Session, MiniMax after a bounded cancel; Pi has no archive but aborts before every session switch; OpenCode neither refuses nor stops. Every product that stops does so through its ordinary cancel path so the log ends regularly. diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md index 423a01bd2f..c7a912fa65 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md @@ -14,7 +14,7 @@ - **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。 - **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。 -- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件把每条活动提醒作为其事务队列中的管理删除来删除——与 `schedule_delete` 工具相同的持久化变更,处于同样的两道持久化屏障之间——答案来自其归属 runtime 对活日志的 fold,而不是投影注册表这个 Client 视图。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 +- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 - **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 - **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。 @@ -32,10 +32,10 @@ **由 API Session Controller 报告各族。** 控制器看得到每个活 Agent、任务注册表和 Session header 上的血缘字段,本可以自己枚举回合、子代理和任务——但那样它就得认识每个 owner 的词汇(描述符与名称、父级原因的取消、任务归属)。Agent 注册表、任务接缝与 Subagent runtime 本来就拥有这些词汇,所以每一族都跟着自己的 owner;控制器只保留只读归档集合与 header 字段的血缘步骤门禁。门禁也不能反过来搬进 `dsh-workspace`:各 owner 现在依赖它的类型,workspace 包再依赖 Agent 的事件词汇就会成环。 -**拒绝带活动提醒的冷 Session。** 冷提醒只在 resume 后才触发;已归档的 Session 无法从侧栏打开,经其他操作 resume 也会撞上 pre-step 门禁,所以没有东西在隐藏运行;只有活 Session 报告 `schedule` 族。 +**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。 ## 后果 -已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的删除屏障之间崩溃,会留下一个提醒仍被记录的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样。冷的已归档 Session 不报告提醒,而恢复其 Agent 的操作——重命名、模型、队列编辑都按 Session 寻址而不取消归档——会重新武装它们:到期的一次性提醒把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。 +已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的停止之间崩溃,会留下一个已存储任务仍被武装的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent,一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。准入本身会报告没有活 Agent 的 Session 的已存储任务,因此这类 Session 在残余发生之前就被拒绝。 为此决策调研的同行产品:Codex 与 Kimi Code 先停止再归档;Claude Code 通过让远端会话无法写入来停止;Reasonix 与 MiniMax 拒绝忙碌的 Session,MiniMax 在有界取消之后才拒绝;Pi 没有归档但在每次切换会话前 abort;OpenCode 既不拒绝也不停止。每一个会停止的产品都走自己的常规取消路径,以便日志正常收尾。 diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml new file mode 100644 index 0000000000..cc02859b0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-archive-admission-reads-stored-tasks.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/feature/2026-09-22-archive-admission-reads-stored-tasks.md +/agent-note-archive-admission-reads-the-stored-tasks: + en: 8be4b1d210d5d8bf + zh: e9124b48467818b3 +/agent-note-archive-admission-reads-the-stored-tasks/problem: + en: 093de39b40f7cc97 + zh: 50c712bde174bc96 +/agent-note-archive-admission-reads-the-stored-tasks/decision: + en: afbf818442c0794a + zh: 4749e63d9757bbbb +/agent-note-archive-admission-reads-the-stored-tasks/alternatives-considered: + en: 099360f2c97a50ea + zh: eac4746c4602f152 +/agent-note-archive-admission-reads-the-stored-tasks/consequences: + en: cb2883b34c2c73fa + zh: 9bb1489c9711cf99 diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md new file mode 100644 index 0000000000..2073ae420d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md @@ -0,0 +1,27 @@ +# Agent Note: Archive admission reads the stored tasks + +Status: implemented + +English | [中文](2026-09-22-archive-admission-reads-stored-tasks.zh.md) + +## Problem + +Archive admission asks every provider what still runs for a Session before the archive is written, and a stop-archive then asks those providers to end it ([archiving a Session with running work](2026-09-21-archive-stops-running-session-work.md)). The Schedule provider answered from its owned runtime's fold of the live Session log, so only a Session with a live Agent reported the `schedule` family, and the stop deleted through the management `delete` path. + +Host tasks are stored in the Schedule domain document and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep. + +## Decision + +The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads `list({ sessionId })` — the stored rows whose binding is that Session and whose status is active — reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows directly inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit; it attempts every row, because a row left armed would still fire, and it notifies observers for the rows that landed before the first failure reaches the caller. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together. + +## Alternatives considered + +**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step. The admission answer costs one table read either way. + +**Delete each row through the public management `delete` path.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one notification and one timer re-drive per stop instead of one per row. + +**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative document, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for. + +## Consequences + +Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop deletes rows one at a time — the domain table offers no batch delete — so a failure partway leaves the earlier deletes committed; observers hear about those, every row was attempted, and the first failure still reaches the caller. One residue follows. A crash between the archive write and the stop leaves stored rows armed in an archived Session: unarchiving shows them again, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself — that resolution is ungated, and only `agent/pre-step` refuses the model step — so a one-shot dispatches a follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders. diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md new file mode 100644 index 0000000000..c95fb280c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md @@ -0,0 +1,27 @@ +# Agent Note:归档准入读取已存储的任务 + +状态:implemented + +[English](2026-09-22-archive-admission-reads-stored-tasks.md) | 中文 + +## 问题 + +归档准入在写入归档前询问每个提供方:该 Session 还有什么在跑;随后「停止并归档」再要求它们结束这些工作([归档仍有工作在跑的 Session](2026-09-21-archive-stops-running-session-work.zh.md))。Schedule 提供方原先回答的是其归属 runtime 对活会话日志的 fold,因此只有带活 Agent 的 Session 才报告 `schedule` 族,停止也走管理 `delete` 路径。 + +宿主任务存储在 Schedule 领域文档中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。 + +## 决策 + +Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取 `list({ sessionId })`——绑定到该 Session 且状态为活动的已存储行——把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里直接删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行;它会尝试每一行,因为留下的行仍会触发;对已经落地的行先通知观察者,再把首个失败交给调用方。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。 + +## 已考虑的备选 + +**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发。两种做法在准入上都只需一次读表。 + +**每行都走公开的管理 `delete` 路径删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次通知、一次计时器重算,而不是每行各一次。 + +**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威文档、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。 + +## 后果 + +准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止逐行删除——领域表不提供批量删除——因此删到一半失败时,先前删除已提交;观察者会被告知这些,每一行都已被尝试,首个失败仍交给调用方。由此剩下一处残余:在归档写入与停止之间崩溃,会在已归档的 Session 里留下仍被武装的已存储行;取消归档时它们再次出现;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent——这一步没有门禁,只有 `agent/pre-step` 拒绝模型步——一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发在没有模型步的情况下被消耗。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。 diff --git a/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.i18n.yaml b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.i18n.yaml new file mode 100644 index 0000000000..3a51c979a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-23-language-neutral-default-workspace-naming.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/feature/2026-09-23-language-neutral-default-workspace-naming.md +/agent-note-language-neutral-default-workspace-naming: + en: f9065db27c1cd7f9 + zh: 3dd1a223a6fcc6c6 +/agent-note-language-neutral-default-workspace-naming/problem: + en: 35967bed9e895460 + zh: 9cf4da88466ff37c +/agent-note-language-neutral-default-workspace-naming/decision: + en: 72fd5b65bbee5434 + zh: 08a4967a1530e686 +/agent-note-language-neutral-default-workspace-naming/alternatives-considered: + en: 556df0ed81444162 + zh: 5eee5029659419c3 +/agent-note-language-neutral-default-workspace-naming/consequences: + en: 5f710ca9cf60d514 + zh: 9ea7bdaf8925fd44 diff --git a/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.md b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.md new file mode 100644 index 0000000000..7d96f91aef --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.md @@ -0,0 +1,36 @@ +# Agent Note: Language-neutral default Workspace naming + +Status: implemented + +English | [中文](2026-09-23-language-neutral-default-workspace-naming.zh.md) + +## Problem + +[First-use default Workspace](2026-09-20-default-workspace.md) let the Client pick both the directory name and the stored title from its startup language: Chinese installations created `默认工作区`, English ones `Default workspace`, everything else `default-workspace`. The name a reader sees and the name written to disk were the same string, so one product decision carried two incompatible requirements. A path is addressed by shell commands, tool arguments, `@path` references, session logs, and backups, and must not depend on which language happened to be active when the installation started. A label is read, and should be in the reader's language. Switching language afterwards satisfied neither: the path was already fixed, and so was the label derived from it. + +## Decision + +The directory name and the stored title are one fixed, language-neutral string; only the on-screen label follows the reader's language. + +`DEFAULT_WORKSPACE_DIRECTORY` (`default-workspace`) is owned by the [Workspace controller](../../../../packages/api/workspace-controller/README.md#first-use-workspace) and published from its own `./default-workspace` subpath — a pure fold with no imports, so client bundles inline it rather than requesting a module-table row the package does not publish. The Host joins it under `/deepseek-harness`, and the registry titles the Workspace after the canonical directory's final segment, so the automatic title *is* that same name with no second source to keep in step. + +`workspace.initializeDefault` therefore takes no request at all; `WorkspaceInitializeDefaultRequest` and its name validation are deleted, and the registry's resolver returns a path instead of a path and title. No language reaches the Host, so nothing there can disagree with the Client about naming. + +Browser consumers label one Workspace through `workspaceDisplayTitle(title, localizedDefault)`: a title still equal to the automatic one reads as the localized default name, and every other title reads verbatim. The name lives in the `common` locale namespace (`workspace.defaultName`) because two packages show it — the Workspace browser resolves it once at the store read so rows, hover card, search meta, and the rename and delete dialogs all inherit it, and the Conversation workspace chip resolves it for the hero. + +A rename is what stops a row following the language, and needs no stored flag: the dialog is seeded with the label on screen while the *stored* title decides whether confirming saves. The two differ for a Workspace still carrying its automatic title, so confirming the untouched prefill is a real rename that pins that name. Self-exclusion in the duplicate check moved to Workspace identity for the same reason — the seeded label equals the row's own displayed title without being a conflict with itself. + +## Alternatives considered + +- Marking the never-renamed state on `WorkspaceView` (a boolean the Host derives from `defaultWorkspaceId` plus the automatic title) removes the false positive below, but widens a shared transport type to carry presentation state the Host has no other use for. Comparing the stored title is the same test with no wire surface. +- Localizing the title inside the Client Workspace store would cover every consumer at one point, but a store that returns product copy owes the locale revision a re-projection and puts copy outside the `t` seat the i18n gate checks. +- Keeping the localized directory name and adding a separate stored title preserves the old path spellings, but leaves every installation's path decided by its first language — the defect this note exists to remove. +- Renaming an existing localized directory on language change would break recorded session `cwd`s, tool arguments, and `@path` references for a cosmetic gain. + +## Consequences + +Installations created before this change keep their existing localized directory and title. Nothing renames or relocates them; their titles no longer match the automatic name, so they read verbatim — which is what a user who never renamed a Chinese installation already saw. + +A Workspace whose stored title is exactly `default-workspace` is labeled as the default even when it is not the registry's default — a folder of that name adopted from the picker, or a rename to that literal. Nothing but the label depends on the distinction. The Workspace browser's client-side duplicate-title check compares localized titles, so it reports a conflict for a second Workspace named the localized default while the Host, comparing stored titles, would allow it; refusing two rows that read identically is the better answer, and the Host still owns the authoritative uniqueness check. + +Two *Session* labels derived from a working directory keep showing the raw path segment, because they name a directory rather than a Workspace: the Session Controller's `displayTitleOf` falls back to the cwd basename for a Session with no durable title, and the sidebar's search-result metadata falls back the same way for a Session with no owning Workspace. Both read `default-workspace` where they previously read the localized name. Routing them through `workspaceDisplayTitle` would spread the automatic-title test into Session naming for a case the Session title generator almost always fills in first. diff --git a/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.zh.md b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.zh.md new file mode 100644 index 0000000000..4095ccf7ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 语言中立的默认工作区命名 + +Status: implemented + +[English](2026-09-23-language-neutral-default-workspace-naming.md) | 中文 + +## Problem + +[首次使用默认工作区](2026-09-20-default-workspace.zh.md)让 Client 按启动语言同时决定目录名和存储标题:中文安装环境创建 `默认工作区`,英文创建 `Default workspace`,其他语言创建 `default-workspace`。读者看到的名称与写入磁盘的名称是同一个字符串,因此一个产品决策承担了两项互不兼容的要求。路径会被 shell 命令、工具参数、`@path` 引用、会话日志和备份寻址,不应取决于安装环境启动时恰好使用哪种语言。标签是给人读的,应当使用读者的语言。事后切换语言两者都无法满足:路径已经固定,由它派生的标签也已固定。 + +## Decision + +目录名与存储标题是同一个固定的、语言中立的字符串;只有屏幕上的标签跟随读者语言。 + +`DEFAULT_WORKSPACE_DIRECTORY`(`default-workspace`)由 [Workspace 控制器](../../../../packages/api/workspace-controller/README.zh.md#first-use-workspace)拥有,并通过其独立的 `./default-workspace` 子路径导出——它是一个没有任何 import 的纯折叠函数,因此 Client 包会内联它,而不是去请求本包并未发布的模块表条目。Host 将它拼接在 `/deepseek-harness` 下,注册表则以规范目录的最后一段作为工作区标题,因此自动标题*就是*同一个名称,不存在需要同步的第二个来源。 + +因此 `workspace.initializeDefault` 完全不接受请求参数;`WorkspaceInitializeDefaultRequest` 及其名称校验被删除,注册表的解析器返回路径而不是路径加标题。语言不会传到 Host,因此那里不可能与 Client 在命名上产生分歧。 + +浏览器消费方通过 `workspaceDisplayTitle(title, localizedDefault)` 为单个工作区加标签:标题仍等于自动标题时显示为本地化的默认名称,其他标题一律原样显示。该名称放在 `common` 语言命名空间(`workspace.defaultName`),因为有两个包展示它——Workspace 浏览区在读取 store 时解析一次,行、悬浮卡片、搜索结果元信息以及重命名与删除对话框都继承该结果;Conversation 的工作区 chip 则为 hero 自行解析。 + +让某一行不再跟随语言的动作就是重命名,无需额外存储标记:对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是*存储*标题。对于仍保留自动标题的工作区两者不同,因此直接确认未改动的初值就是一次真实的重命名,会把该名称固定下来。重名检查中排除自身也因此改为按工作区身份判断——初值标签与该行自己显示的标题相同,但这并不构成与自身的冲突。 + +## Alternatives considered + +- 在 `WorkspaceView` 上标记"从未重命名"状态(由 Host 依据 `defaultWorkspaceId` 与自动标题推导的布尔值)可以消除下述误判,但会让一个共享的传输类型多承载一项 Host 本身别无用途的展示状态。比较存储标题是同一项判断,且不增加 wire 表面。 +- 在 Client Workspace store 内部本地化标题可以一处覆盖所有消费方,但返回产品文案的 store 需要在语言修订变化时重新投影,而且会把文案放到 i18n 门禁检查的 `t` 座位之外。 +- 保留本地化目录名并另存一个标题可以保住旧路径拼写,但每个安装环境的路径仍由其首个语言决定——正是本笔记要消除的缺陷。 +- 在语言切换时重命名已有的本地化目录,会为了外观收益破坏已记录的会话 `cwd`、工具参数和 `@path` 引用。 + +## Consequences + +本次改动之前创建的安装环境保留其既有的本地化目录和标题。不会有任何重命名或迁移;它们的标题不再与自动名称相同,因此原样显示——这与从未重命名过中文安装环境的用户已经看到的结果一致。 + +存储标题恰好为 `default-workspace` 的工作区会按默认工作区显示,即使它并非注册表的默认工作区——例如从选择器采用的同名文件夹,或被重命名为该字面值。除标签之外没有其他行为依赖该判断。Workspace 浏览区在 Client 侧的重名检查比较的是本地化标题,因此当第二个工作区被命名为本地化默认名称时它会报冲突,而按存储标题比较的 Host 会允许;拒绝两行显示完全相同的名称是更好的答案,权威的唯一性检查仍由 Host 拥有。 + +有两处由工作目录派生的 *Session* 标签仍显示原始路径片段,因为它们命名的是目录而非工作区:Session Controller 的 `displayTitleOf` 在 Session 没有持久标题时回退到 cwd 最后一段,侧栏搜索结果的元信息在 Session 没有所属工作区时同样回退。两处此前显示本地化名称,现在显示 `default-workspace`。把它们接入 `workspaceDisplayTitle` 会把自动标题判断扩散进 Session 命名,而这种情形几乎总会先被 Session 标题生成填上。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 05b70cb836..5b40ba01a0 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -48,6 +48,7 @@ The command orchestration shape is: pnpm run build: tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index d42d9b7abe..c250cdd831 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -48,6 +48,7 @@ Status: implemented pnpm run build: tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml new file mode 100644 index 0000000000..0cd788f54a --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-desktop-main-bundle-after-workspace-tsdown.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/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md +/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass: + en: 47f6a531ef05a7be + zh: 18dc33bcd916e2b5 +/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/problem: + en: dc5156548e545037 + zh: 72010884a2a27877 +/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/decision: + en: 9b3c9c696207b13e + zh: 4996ad87dd07d839 +/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/alternatives-considered: + en: a15cfe689823ae57 + zh: 1d691cd335bdcb87 +/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/consequences: + en: e46c28e2ba972cfb + zh: 70d4076a89e0b2ec diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md new file mode 100644 index 0000000000..3c997f0707 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md @@ -0,0 +1,29 @@ +# Agent Note: Bundle the Desktop main process after the workspace tsdown pass + +Status: implemented + +English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md) + +## Problem + +A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`. + +The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process. + +## Decision + +`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it. + +The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `moduleParsed` hook fails the build when a module imports a bare specifier the packaged application cannot resolve, whether through a static import, a dynamic `import()`, or a `require()` call: the main bundle may import `electron`, Node builtins, and the manifest's `dependencies`; a sandboxed preload only `electron`, `events`, `timers`, and `url`, the modules Electron's sandbox `require` polyfill resolves. The hook reads each module's import records, where an external import keeps its bare specifier and a bundled one carries an absolute id; chunk metadata (`imports`/`dynamicImports`) omits external `import()` and `require()` targets and cannot serve the check. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output. + +## Alternatives considered + +**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen. + +**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule. + +**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler. + +## Consequences + +A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the importing module and the offending specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, `scripts/wine-windows-gates.sh` mirrors it, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule. diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md new file mode 100644 index 0000000000..3f741125f6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle + +Status: implemented + +[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文 + +## 问题 + +从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。 + +根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。 + +## 决策 + +`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。 + +Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `moduleParsed` 钩子在某个模块以静态 import、动态 `import()` 或 `require()` 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron`、Node 内置模块与清单的 `dependencies`;沙箱 preload 只能导入 `electron`、`events`、`timers` 与 `url`,即 Electron 沙箱 `require` polyfill 能解析的模块。该钩子读取每个模块的导入记录,其中外部导入保留裸说明符、被打包的导入带有绝对 id;chunk 元数据(`imports`/`dynamicImports`)不包含外部的 `import()` 与 `require()` 目标,无法用于这项检查。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。 + +## 备选方案 + +**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。 + +**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者。 + +**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。 + +## 后果 + +干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出导入方模块与违规说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,`scripts/wine-windows-gates.sh` 与之保持一致,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml index d49e11a253..540d081a58 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml @@ -15,8 +15,8 @@ en: 6c82820724930bb5 zh: 5f6a0bbfd6fb6b16 /agent-note-desktop-mandatory-update-api/proposal/request: - en: d015126512123574 - zh: a77e66d4f1808402 + en: fb66b137a600f3a6 + zh: 83f5c74a1716d57c /agent-note-desktop-mandatory-update-api/proposal/no-mandatory-update: en: 4814b1bd7002d6ed zh: 3f0d77beb046e2fc @@ -24,8 +24,8 @@ en: d3ae240665143695 zh: 486115cd2fdf35ed /agent-note-desktop-mandatory-update-api/proposal/errors-and-policy-matching: - en: 5da5e6a38a739201 - zh: 5af5743f9f162dd4 + en: ce7ab9adf3cbc840 + zh: d6f9734ab54b8f33 /agent-note-desktop-mandatory-update-api/proposal/policy-publication-and-current-decisions: en: d4d70e1541b2c2d1 zh: a1b6e62a05b239be diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md index f88216065f..429547c85b 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md @@ -28,21 +28,23 @@ The query itself must return the complete mandatory response; do not rely on int GET /api/v0/check_client_update?scenario=launch x-client-platform: desktop-win x-client-version: 0.1.3-rc.2 -x-client-bundle-id: com.deepseek.dsh -x-client-locale: zh-CN +x-client-bundle-id: +x-client-locale: zh_CN +x-client-timezone-offset: 28800 x-client-arch: x64 x-client-update-channel: nightly x-client-bundled-dsh-version: 0.1.3-rc.2 ``` -All listed headers are required for Desktop. Values describing installed software come from the application and its release metadata, not editable UI fields. +All listed headers are required on every Desktop request; a required header may still carry an empty value, as `x-client-bundle-id` does. The values describing installed software come from the application and its release metadata, not editable UI fields. | Header | Meaning and allowed values | |---|---| | `x-client-platform` | `desktop-win` or `desktop-mac` | -| `x-client-version` | Full Desktop SemVer, retaining prerelease identifiers; initially equal to bundled dsh | -| `x-client-bundle-id` | Application identity, for example `com.deepseek.dsh`; distinguishes Harness from Chat | -| `x-client-locale` | UI locale, for example `zh-CN`; selects localized content, not region | +| `x-client-version` | Shared client build version, inlined as `DSH_CLIENT_VERSION` at packaging; full SemVer retaining prerelease identifiers, initially equal to the bundled dsh version | +| `x-client-bundle-id` | Platform uses this field to identify the Chat application; the field does not apply to Harness, so Desktop sends it empty | +| `x-client-locale` | `zh_CN` or `en_US`, taken from the active UI language's primary subtag; selects localized content, not region | +| `x-client-timezone-offset` | Whole-second offset from UTC, positive east of Greenwich, sampled from the shell for every request | | `x-client-arch` | Windows `x64`; macOS `x64` or `arm64` | | `x-client-update-channel` | Initially always `nightly`, independent of version suffix | | `x-client-bundled-dsh-version` | Full bundled dsh version from release metadata | @@ -102,7 +104,7 @@ Do not add `mode`, `force_update`, `show_key`, `target_version`, button copy, or Missing required headers, invalid SemVer, and unsupported platform/architecture/channel combinations return explicit parameter errors, not no-force success or a mandatory policy. Prefer the existing business meanings of `biz_code = 1` for a missing version and `biz_code = 2` for an invalid version; final error allocation belongs to the backend. Service failures must not masquerade as success because success may remove an existing block. -Match application identity, platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before. +Match platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before. ### Policy publication and current decisions diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md index cb3e631040..e2b992f746 100644 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md @@ -28,21 +28,23 @@ GET /api/v0/check_client_update GET /api/v0/check_client_update?scenario=launch x-client-platform: desktop-win x-client-version: 0.1.3-rc.2 -x-client-bundle-id: com.deepseek.dsh -x-client-locale: zh-CN +x-client-bundle-id: +x-client-locale: zh_CN +x-client-timezone-offset: 28800 x-client-arch: x64 x-client-update-channel: nightly x-client-bundled-dsh-version: 0.1.3-rc.2 ``` -以上 header 对 Desktop 均为必填。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。 +以上 header 在每次 Desktop 请求中均为必填;必填 header 的取值可以为空,`x-client-bundle-id` 即如此。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。 | Header | 含义与取值 | |---|---| | `x-client-platform` | `desktop-win` 或 `desktop-mac` | -| `x-client-version` | 完整 Desktop SemVer,保留预发布标识;一期等于内置 dsh | -| `x-client-bundle-id` | 应用身份,例如 `com.deepseek.dsh`,区分 Harness 与 Chat | -| `x-client-locale` | UI 语言,例如 `zh-CN`;选择本地化内容,不代表区域 | +| `x-client-version` | 共享的客户端构建版本,在打包时内联为 `DSH_CLIENT_VERSION`;完整 SemVer,保留预发布标识,一期等于内置 dsh 版本 | +| `x-client-bundle-id` | Platform 用该字段标识 Chat 应用;该字段对 Harness 不适用,因此 Desktop 发送空值 | +| `x-client-locale` | `zh_CN` 或 `en_US`,取自当前 UI 语言的主语言子标签;选择本地化内容,不代表区域 | +| `x-client-timezone-offset` | 与 UTC 的整秒偏移,东为正,每次请求时从壳采样 | | `x-client-arch` | Windows 为 `x64`;macOS 为 `x64` 或 `arm64` | | `x-client-update-channel` | 一期固定 `nightly`,独立于版本后缀 | | `x-client-bundled-dsh-version` | 发布元数据中的完整内置 dsh 版本 | @@ -102,7 +104,7 @@ x-client-bundled-dsh-version: 0.1.3-rc.2 缺少必填 header、非法 SemVer 或不支持的平台/架构/通道组合返回明确参数错误,不能伪装为无强制成功或强制策略。优先沿用 `biz_code = 1` 表示缺少版本、`biz_code = 2` 表示版本非法的既有业务含义;最终错误码分配由后端负责。服务异常不得伪装成成功,因为成功可能解除已有阻断。 -按应用身份、平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。 +按平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。 ### 策略发布与当前判定 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 862299b9a2..3f325012a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -43,6 +43,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC | | [`@lexical/history`](https://github.com/facebook/lexical) | MIT | | [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | | [`@lexical/text`](https://github.com/facebook/lexical) | MIT | diff --git a/apps/cli/config/examples/schedule/cordis.yml b/apps/cli/config/examples/schedule/cordis.yml index 04fe85214d..eab335e0a5 100644 --- a/apps/cli/config/examples/schedule/cordis.yml +++ b/apps/cli/config/examples/schedule/cordis.yml @@ -1,5 +1,4 @@ -# Opt-in Schedule patch over the shipped Web composition. The owner observes -# only roots published after this overlay loads. +# Opt-in host Schedule service and its Session task catalog. - insert: - id: time-context diff --git a/apps/cli/package.json b/apps/cli/package.json index b9513d0184..fb5f762794 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -129,6 +129,7 @@ "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-sdk-client": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index e915da38b3..3db7f52dd3 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -1,11 +1,11 @@ -{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":1,"stream":[],"message":{"role":"assistant","content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"tool","toolCallId":"unknown-outcome-call","isError":true,"source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"interrupted"}}} {"type":"session/end-seed","data":{}} @@ -16,10 +16,10 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["I will verify the external state before deciding whether to retry the side-effecting operation."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index a359220fbb..b210549cfa 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} @@ -14,15 +14,15 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 44d31a36ab..f4077c4510 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","data":{"mode":"read-only"}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}} @@ -14,16 +14,17 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"resume"}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index 4e2b7ae5a5..7dc7407bff 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -14,11 +14,11 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index 71d92dd690..76af22df23 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -14,11 +14,11 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts new file mode 100644 index 0000000000..e90f5b4b54 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -0,0 +1,121 @@ +/** Real provider cache usage across registry additions through the built SDK profile. */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { describe, expect, it, onTestFinished } from 'vitest' + +const fixturePath = fileURLToPath(new URL('./fixtures/dynamic-tool-cache.mjs', import.meta.url)) +const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url)) +const sampleTool = 'cache_sample' +const sampleLabelGuidance = 'Place each generated sample label on its own line.' +// Provider cache blocks may leave a short uncached suffix of an unchanged request. +const cacheSuffixTokens = 256 + +interface ObservedRequest { + stage: 'initial' | 'added' | 'removed' + turn: number + step: number + body: { + system?: string + messages: { role: string; content: { type: string; text?: string; tool?: { name: string } }[] }[] + tools?: { name: string; defer_loading?: true }[] + } +} + +function usageFor(request: ObservedRequest, events: SessionEvent[]): TokenUsage { + const settled = events.find(event => event.type === 'assistant/message' + && event.data.turn === request.turn && event.data.step === request.step) + if (settled?.type !== 'assistant/message' || settled.data.usage === undefined) { + throw new Error(`Missing provider usage for turn ${request.turn}, step ${request.step}`) + } + return settled.data.usage +} + +function totalInput(usage: TokenUsage): number { + return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with real DeepSeek', () => { + it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', { retry: 0 }, async (updatePrompt) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-tool-cache-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + const sampleLabel = `CACHE_SAMPLE_${randomUUID()}` + const evidencePath = join(root, 'requests.jsonl') + const callsPath = join(root, 'calls.txt') + const patch = join(root, 'dynamic-tool-cache.patch.yml') + await writeFile(patch, JSON.stringify([ + { id: 'llm-deepseek', config: { + apiKeyEnv: 'DEEPSEEK_API_KEY', thinking: 'disabled', maxTokens: 1024, + retryPolicy: { maxRetries: 0 }, + models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], + } }, + { id: 'tools', config: { mode: 'native' } }, + { id: 'session-log-deepseek', disabled: true }, + { id: 'plugin-package-inventory-deepseek', disabled: true }, + { insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] }, + ])) + const harness = new DeepSeekHarness({ + dshBin, profile: 'sdk', patches: [patch], dshHome: join(root, 'home'), + cwd: root, processCwd: root, provider: 'deepseek-official', model: 'deepseek-flash', + env: { ...process.env, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'danger-full-access' }, + initializeTimeoutMs: 30_000, + }) + onTestFinished(async () => { await harness.close() }) + await harness.start() + const session = harness.session() + const events: SessionEvent[] = [] + const run = async (prompt: string) => { + const result = await session.run(prompt) + events.push(...result.events) + return result.finalResponse + } + await run('Do not call tools. Reply READY.') + // Unique user history makes hits on shared profile instructions insufficient. + const records = Array.from({ length: 96 }, (_, index) => `record-${index}: ${randomUUID()}`).join('\n') + await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`) + await run('Keep the reference records. Do not call tools. Reply READY.') + expect(await run(`Use cache_tool_control with action add. After it returns, call ${sampleTool} exactly once and report the generated sample label. This is synthetic example data for this run. Call these separately in order.`)).toContain(sampleLabel) + expect(await readFile(callsPath, 'utf8')).toBe('add\nsample\n') + expect(await run('Use cache_tool_control with action remove. After it returns, reply REMOVED and do not call the sample tool.')).toContain('REMOVED') + expect(await readFile(callsPath, 'utf8')).toBe('add\nsample\nremove\n') + + const requests = (await readFile(evidencePath, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as ObservedRequest) + expect(events.filter(event => event.type === 'assistant/attempt')).toHaveLength(0) + expect(requests).toHaveLength(events.filter(event => event.type === 'assistant/message').length) + const [initial, history, warm] = requests + const addedIndex = requests.findIndex(request => request.stage === 'added') + const before = requests[addedIndex - 1] + const added = requests[addedIndex] + const removed = requests.find(request => request.stage === 'removed') + if (!initial || !history || !warm || !before || !added || !removed) throw new Error('Missing cache transition requests') + const historyInput = totalInput(usageFor(history, events)) + const precedingInput = totalInput(usageFor(before, events)) + const cached = usageFor(added, events).cacheReadTokens ?? 0 + process.stdout.write(`SDK tool addition cache: ${JSON.stringify({ updatePrompt, historyInput, precedingInput, cached, deficit: precedingInput - cached, removedCached: usageFor(removed, events).cacheReadTokens })}\n`) + expect(historyInput - totalInput(usageFor(initial, events))).toBeGreaterThan(1024) + expect(usageFor(warm, events).cacheReadTokens ?? 0).toBeGreaterThanOrEqual(historyInput - cacheSuffixTokens) + expect(added.turn).toBe(before.turn) + expect(added.step).toBe(before.step + 1) + expect(before.body.system).toBe(initial.body.system) + expect(added.body.system).toBe(initial.body.system) + expect(added.body.messages.slice(0, before.body.messages.length)).toEqual(before.body.messages) + expect(added.body.messages.some(message => message.role === 'system' + && message.content.some(block => block.type === 'text' && block.text?.includes(sampleLabelGuidance)))).toBe(updatePrompt) + expect(cached, 'the first post-addition request must reuse the preceding conversation input').toBeGreaterThanOrEqual(precedingInput - cacheSuffixTokens) + expect(initial.body.tools?.map(tool => tool.name)).toEqual(['cache_tool_control']) + expect(added.body.tools?.slice(0, initial.body.tools?.length)).toEqual(initial.body.tools) + expect(added.body.tools?.find(tool => tool.name === sampleTool)).toMatchObject({ name: sampleTool, defer_loading: true }) + expect(added.body.messages.flatMap(message => message.content)).toContainEqual({ type: 'tool_addition', tool: { type: 'tool_reference', name: sampleTool } }) + expect(removed.body.tools?.map(tool => tool.name)).toEqual(['cache_tool_control']) + expect(removed.body.messages.flatMap(message => message.content).some(block => block.type === 'tool_addition')).toBe(false) + expect(events.filter(event => event.type === 'developer/message').map(event => event.data.message.content)).toEqual([ + [{ type: 'tool-addition', toolName: sampleTool }], + [{ type: 'tool-removal', toolName: sampleTool }], + ]) + }) +}) diff --git a/apps/cli/tests/profiles/sdk/fixtures/dynamic-tool-cache.mjs b/apps/cli/tests/profiles/sdk/fixtures/dynamic-tool-cache.mjs new file mode 100644 index 0000000000..d01a204673 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/dynamic-tool-cache.mjs @@ -0,0 +1,68 @@ +/** SDK fixture for native registry changes and unmodified DeepSeek request observation. */ +import { appendFile } from 'node:fs/promises' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'sdk-dynamic-tool-cache-fixture' +export const inject = ['tools', 'systemPrompt', 'deepseekLlmApiExtensions'] + +/** Observe each request and let the model add or remove a scoped tool. */ +export function apply(ctx, config) { + const states = new Map() + ctx.on('session/event', (session, event) => { + const state = states.get(session.id) + if (state !== undefined && event.type === 'step/start') { + state.turn = event.data.turn + state.step = event.data.step + } + }) + ctx.effect(() => ctx.deepseekLlmApiExtensions.register('dynamic_tool_cache_observer', { + async prepare(request) { + if (request.purpose !== undefined) return undefined + const state = states.get(request.sessionId) + if (state === undefined) throw new Error('The SDK request has no observed agent.') + await appendFile(config.evidencePath, `${JSON.stringify({ ...state, body: request.body })}\n`) + return undefined + }, + })) + ctx.on('agent/created', ({ agent }) => { + const scope = agent.ctx + const state = { stage: 'initial', turn: 0, step: 0 } + states.set(agent.id, state) + scope.effect(() => () => { states.delete(agent.id) }) + scope.effect(() => scope.tools.restrict({ allow: [] })) + let guidance = '' + scope.systemPrompt.section({ name: 'sdk:dynamic-tool-guidance', order: 400, text: () => guidance }) + let removeTool + scope.effect(() => scope.tools.register(defineTool({ + name: 'cache_tool_control', + description: 'Add or remove the temporary cache_sample tool. It becomes available on the model step after add returns.', + parameters: { action: { type: 'string', enum: ['add', 'remove'], required: true } }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute({ action }) { + if (action === 'add') { + if (removeTool !== undefined) throw new Error('The sample tool is already registered.') + if (config.updatePrompt) guidance = 'Place each generated sample label on its own line.' + removeTool = scope.effect(() => scope.tools.register(defineTool({ + name: 'cache_sample', + description: 'Read a randomly generated sample label created for this example run.', + parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute() { + await appendFile(config.callsPath, 'sample\n') + return config.sampleLabel + }, + }))) + state.stage = 'added' + await appendFile(config.callsPath, 'add\n') + return 'Tool added. Call cache_sample on the next model step to obtain the generated sample label.' + } + if (removeTool === undefined) throw new Error('The sample tool is not registered.') + await removeTool() + removeTool = undefined + state.stage = 'removed' + await appendFile(config.callsPath, 'remove\n') + return 'Tool removed. The sample tool is unavailable.' + }, + }))) + }) +} diff --git a/apps/cli/tests/profiles/web/tests/default-web-process.ts b/apps/cli/tests/profiles/web/tests/default-web-process.ts index 341a97e4c7..537ac77702 100644 --- a/apps/cli/tests/profiles/web/tests/default-web-process.ts +++ b/apps/cli/tests/profiles/web/tests/default-web-process.ts @@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { get } from 'node:http' +import { request as httpRequest } from 'node:http' import type { IncomingHttpHeaders } from 'node:http' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import ts from 'typescript' @@ -18,6 +18,7 @@ const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) /** Live process whose IPC observer reports independently collected runtime state. */ interface DefaultWeb { + root: string url: string request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise } @@ -26,13 +27,19 @@ interface DefaultWeb { * Boot the built Web profile under plain Node and dispose it to quiescence after an assertion callback. * @param test - owning Vitest case, including its timeout, cancellation, and cleanup hooks. * @param inspect - assertions against the running process and its ephemeral loopback URL. + * @param options - additional profile patches and test data prepared before startup. */ -export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWeb) => Promise): Promise { +export async function withDefaultWeb( + test: TestContext, + inspect: (app: DefaultWeb) => Promise, + options: { patches?: readonly string[]; prepare?: (root: string) => Promise } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-')) let removal: Promise | undefined const removeRoot = (): Promise => removal ??= rm(root, { recursive: true, force: true }) test.onTestFinished(removeRoot) try { + await options.prepare?.(root) await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }) + '\n') for (const relative of ['runtime-roster.ts', 'fixtures/runtime-roster-observer.ts']) { const source = await readFile(new URL(relative, import.meta.url), 'utf8') @@ -53,7 +60,10 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe const launch = resolveExampleLaunch({ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'), mode: 'lib', - configArgs: ['--profile', 'web', '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'], + configArgs: [ + '--profile', 'web', ...options.patches?.flatMap(path => ['--patch', path]) ?? [], + '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open', + ], env: { NODE_OPTIONS: undefined, NODE_PATH: undefined, @@ -145,7 +155,7 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe return /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1] }, { timeout: test.task.timeout }).toBeDefined() const url = /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)![1]! - await inspect({ url, request }) + await inspect({ root, url, request }) } finally { const result = await close() test.signal.removeEventListener('abort', abort) @@ -167,13 +177,26 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe * @param headers - optional authentication cookie. * @returns complete response after its stream ends. */ -export function webGet(url: string | URL, signal: AbortSignal, headers: Record = {}): Promise<{ - status: number | undefined - headers: IncomingHttpHeaders - text: string -}> { +export function webGet( + url: string | URL, signal: AbortSignal, headers: Record = {}, +): ReturnType { + return webRequest(url, signal, { method: 'GET', headers }) +} + +/** + * Send one public-protocol request directly to the test-owned loopback process. + * @param url - Exact URL of the owned process. + * @param signal - Owning test cancellation signal. + * @param options - HTTP method, headers, and optional serialized request body. + * @returns Complete response after its stream ends. + */ +export function webRequest( + url: string | URL, + signal: AbortSignal, + options: { method: 'GET' | 'POST'; headers?: Record; body?: string }, +): Promise<{ status: number | undefined; headers: IncomingHttpHeaders; text: string }> { return new Promise((resolve, reject) => { - const request = get(url, { headers, agent: false, signal }, (response) => { + const request = httpRequest(url, { method: options.method, headers: options.headers, agent: false, signal }, (response) => { response.setEncoding('utf8') let text = '' response.on('data', (chunk: string) => { text += chunk }) @@ -181,5 +204,6 @@ export function webGet(url: string | URL, signal: AbortSignal, headers: Record { resolve({ status: response.statusCode, headers: response.headers, text }) }) }) request.once('error', reject) + request.end(options.body) }) } 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 new file mode 100644 index 0000000000..14081ab1a1 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts @@ -0,0 +1,120 @@ +/** Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule' +import { expect, it } from 'vitest' +import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts' + +const overlay = fileURLToPath(new URL('../../../../config/examples/schedule/cordis.yml', import.meta.url)) +const id = 'retained-remote-task' as DailyScheduleRecord['id'] +const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId'] +const record: DailyScheduleRecord = { + id, kind: 'daily', title: 'Retained instruction', prompt: 'Retained instruction\nwith a second line', + time: '09:00:00.125', timeZone: 'Asia/Shanghai', scheduledAt: '2099-01-01T01:00:00.125Z', +} +const lastDelivery = { + scheduledAt: '2026-01-01T01:00:00.125Z', deliveredAt: '2026-01-01T01:00:01.250Z', messageId: 'saved-receipt', +} +const task = { + sessionId, record, status: 'active', lastDelivery, + deliveryHistory: { earlierRecordsUnavailable: false, records: [{ ...lastDelivery, prompt: record.prompt }] }, +} +const taskPath = (root: string) => join(root, 'home', 'storages', 'schedule.json') + +async function seed(root: string, bytes: string): Promise { + await mkdir(join(root, 'home', 'storages'), { recursive: true }) + await writeFile(taskPath(root), bytes) +} + +function encoded(value: unknown): string { + return `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: value } } }, null, 2)}\n` +} + +async function connect(url: string, signal: AbortSignal) { + const auth = await webGet(url, signal) + const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] + if (cookie === undefined) throw new Error('Owned Web process did not issue its authentication cookie') + return async (method: string, args: Record) => { + const rpcId = randomUUID() + const response = await webRequest(new URL(`/api/${method}`, url), signal, { + method: 'POST', headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args } }), + }) + expect(response.status, response.text).toBe(200) + const envelope = JSON.parse(response.text) as { type?: unknown; rpcId?: unknown; result?: unknown } + expect(envelope).toMatchObject({ type: 'server-response', rpcId }) + return envelope.result + } +} + +it('edits a retained task through built Remote bindings and preserves its identity and seeded history after cold startup', async (test) => { + let persisted = encoded(task) + let updated = record + const historyRequest = { request: { sessionId, id, limit: 20 } } + const expectedHistory = { + ok: true, + value: { + id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false, + earlierRecordsPruned: false, retention: { days: 30, records: 200 }, + }, + } + await withDefaultWeb(test, async ({ root, url }) => { + const rpc = await connect(url, test.signal) + expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...record, sessionId, status: 'active', lastDelivery }] }) + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + const target = new Date(Date.now() + 12 * 60 * 60 * 1_000) + target.setUTCMilliseconds(125) + updated = { ...record, time: target.toISOString().slice(11, 23), timeZone: 'UTC', scheduledAt: target.toISOString() } + const change = { kind: 'daily', daily: { time: updated.time, time_zone: 'UTC' } } + expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } })) + .toEqual({ ok: true, value: { id, updated: true, record: updated } }) + persisted = await readFile(taskPath(root), 'utf8') + expect(JSON.parse(persisted)).toEqual({ + unit: { name: 'schedule', version: 1 }, + global: null, + tables: { tasks: { [id]: { ...task, record: updated } } }, + }) + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + + expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } })) + .toEqual({ ok: true, value: { id, updated: false, code: 'schedule_conflict' } }) + expect(await rpc('schedule/update', { request: { sessionId: 'other-owner', id, expected: updated, change } })) + .toEqual({ ok: true, value: { id, updated: false, code: 'schedule_not_found' } }) + expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) + }, { patches: [overlay], prepare: root => seed(root, persisted) }) + + await withDefaultWeb(test, async ({ root, url }) => { + const rpc = await connect(url, test.signal) + expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...updated, sessionId, status: 'active', lastDelivery }] }) + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + expect(await rpc('schedule/update', { request: { + sessionId, id, expected: updated, change: { kind: 'daily', daily: { time: updated.time, time_zone: 'Etc/UTC' } }, + } })).toEqual({ ok: true, value: { id, updated: false, record: updated } }) + expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) + }, { patches: [overlay], prepare: root => seed(root, persisted) }) +}) + +it('creates and idempotently adopts the explicit Session id bound to seeded task data without touching its task file', async (test) => { + const persisted = encoded(task) + const expectedList = { ok: true, value: [record] } + const expectedHistory = { + ok: true, + value: { + id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false, + earlierRecordsPruned: false, retention: { days: 30, records: 200 }, + }, + } + await withDefaultWeb(test, async ({ root, url }) => { + const rpc = await connect(url, test.signal) + const create = { request: { sessionId, cwd: root } } + const expectedCreate = { ok: true, value: { sessionId, agentPreset: 'standard' } } + expect(await rpc('session/create', create)).toEqual(expectedCreate) + expect(await rpc('session/create', create)).toEqual(expectedCreate) + expect(await rpc('schedule/list', { request: { sessionId } })).toEqual(expectedList) + expect(await rpc('schedule/history', { request: { sessionId, id, limit: 20 } })).toEqual(expectedHistory) + expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) + }, { patches: [overlay], prepare: root => seed(root, persisted) }) +}) diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index 7be4154979..fbc26da6e1 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -13,10 +13,12 @@ class Account extends DeepSeekAccount { ended = Promise.withResolvers() reading = Promise.withResolvers() finished = Promise.withResolvers() - read: () => Promise = async () => ({ origin: 'https://platform.example', token: 'test' }) + read: () => Promise = async () => ({ origin: 'https://platform.example', userId: null, token: 'test' }) override async getState() { return state } override async getProfile() { return null } override async getBalance() { return null } + override async getUnnotifiedBonuses() { return null } + override async ackBonusNotified() { return false } override async startSignIn() { return state } override async cancelSignIn() { return state } override async signOut() { return state } @@ -51,7 +53,7 @@ it('clears removed credentials and publishes the replacement provider session', delivered = Promise.withResolvers() await ctx.plugin((scope) => { const replacement = new Account(scope) - replacement.read = async () => ({ origin: 'https://platform.example', token: 'replacement' }) + replacement.read = async () => ({ origin: 'https://platform.example', userId: null, token: 'replacement' }) }) await delivered.promise expect(sessions.at(-1)?.token).toBe('replacement') @@ -95,7 +97,7 @@ it('suppresses a credential read that completes during provider disposal', async await account.reading.promise const disposed = provider.dispose() await cleared.promise - pending.resolve({ origin: 'https://platform.example', token: 'stale' }) + pending.resolve({ origin: 'https://platform.example', userId: null, token: 'stale' }) await disposed expect(sessions).toEqual([null]) await account.finished.promise diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index b4f6e3304b..4f2317b1ef 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -3,23 +3,23 @@ # 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: 907e8bc5ebef2678 - zh: f23af881b27cad94 + en: 45561fc0dd3a1b20 + zh: f4b03355d4bd0693 /deepseek-harness-desktop/key-technical-decisions: - en: 47ec880441223749 - zh: 3be7731db888b4a4 + en: 069fdfd72d2ea26a + zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: c2eb16f568cd8554 - zh: 686d64af17b10375 + en: 31cbb7452be8e1ad + zh: 502ec556e2ec301e /deepseek-harness-desktop/installation-ownership: - en: 3fe85101acf17b0a - zh: 70cd207cadc85b94 + en: 8307baf6c5b691d0 + zh: 86e46b9552017ff6 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: 0d11af8639977a6d zh: 86b086b12d2210d3 /deepseek-harness-desktop/develop: - en: 99f5299ebcf69b97 - zh: 0411074daee0cd79 + en: 9b9b362a9865e763 + zh: c748901168e2db8c /deepseek-harness-desktop/develop/startup-onboarding: en: f01d0dbafe8b1613 zh: 91ad92f5d7e505c4 @@ -36,8 +36,8 @@ en: 7d72469be4975afe zh: 2f644b582cb581be /deepseek-harness-desktop/package/upload-updates: - en: 7828da306d42ed2e - zh: 0b8bc61c5f612dcb + en: c64d7fe2fe2534b7 + zh: 2188e1ae99ab4b33 /deepseek-harness-desktop/package/unsigned-windows-test-installer: en: cfd5780dd26a8e46 zh: f7a16b1ede793b7b @@ -48,11 +48,11 @@ en: 8efe3498dbe8686d zh: a88c16f20a9430f3 /deepseek-harness-desktop/updates: - en: 8ce8647a3ff94b9a - zh: 93250420c9d6f4ce + en: acbd12b8fc5ec5fd + zh: 031e15f931479f27 /deepseek-harness-desktop/updates/mandatory-update-policy: - en: b99d35abf25d5347 - zh: ed250334da3f149d + en: 2f789a4949fafb12 + zh: d8b28c01cfee11b7 /deepseek-harness-desktop/updates/local-updater-qualification: en: bf34de891411c618 zh: aa6923fd5060174b @@ -60,8 +60,8 @@ en: 0b225f914142ef0b zh: 9ffd07768d37824e /deepseek-harness-desktop/known-limitations: - en: e084ae80b7ebfaf1 - zh: f20ba012b4330a3f + en: 5b77213c86bbcfb6 + zh: b34bf21773bb852b /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fb83b0fc66..2821d2ed5a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,9 +10,9 @@ Desktop’s local native directory flow opens an Electron folder dialog attached Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. -Embedded Platform documents use a dedicated non-persistent WebContentsView session. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. +Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. -Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). +Desktop Host Platform API requests and update-policy requests identify the installed client with the same Platform client headers: platform, client version, locale, timezone offset in seconds, and a bundle id that is intentionally empty. Account operations take the calling UI's identity per call; the account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). Update policy additionally reports architecture, update channel, and bundled runtime version. A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. Both Welcome and the workspace display the localized expiry notice. @@ -24,10 +24,16 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. +Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics. + +The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Update overlays block product shortcuts and editing-key delivery to the parent window and its browser guests from creation until the last overlay closes. Every overlay opening or closing invalidates pending chord state. The main process shares one overlay owner between update dialogs and shortcut input through explicit creation and input-state capabilities. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice. + The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. ### Bundled workspace dependencies +electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron`, Node builtins, and those `dependencies` as its only bare imports; a sandboxed preload may leave only `electron`, `events`, `timers`, and `url`, the modules its `require` polyfill resolves. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose static, dynamic, or `require()` import the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. + Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. @@ -68,9 +74,9 @@ The product UI retains Web actions, including "Open In..." through the shared au 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. 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. +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. -On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus. +On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus. ### Runtime and plugin activation @@ -92,7 +98,6 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. - ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. @@ -113,6 +118,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. ### Startup onboarding @@ -259,7 +265,7 @@ Mac packaging reads three tuning fields from `.env.macos`: The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. -Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. +Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. ### Unsigned Windows test installer @@ -348,6 +354,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates +Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its background blur, input interception, and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. + Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -375,7 +383,7 @@ The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09- | `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` | | `jitter` | Random additional interval fraction; default `0.2`, allowed range `0` through `1` | -Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, exact shell and bundled dsh versions, application ID, locale, and fixed Nightly. It uses no business login credentials or installation ID. +Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, DSH_CLIENT_VERSION, bundled dsh version, current locale and UTC offset, an empty bundle ID, and fixed Nightly. It uses no business login credentials or installation ID. With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually. @@ -414,6 +422,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. - Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. +The app-only `dshOnboarding.hasApiKey()` preload method returns the welcome backend’s current API-key presence boolean; native login and onboarding share credential discovery, and only the owned application main frame may invoke it. + Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Successful browser sign-in switches Welcome to the workspace without activating the application; the completion page’s dsh://open link brings it to the foreground. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 490eff4b35..8fc5ba35e0 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,9 +10,9 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 -内嵌 Platform 文档使用独立且不持久化的 WebContentsView 会话。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 +Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 -Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。 +Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 客户端请求头标识已安装客户端:平台、客户端版本、语言、以秒为单位的时区偏移,以及有意保持为空的 bundle id。账号操作按调用逐次传入调用界面的身份;账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。更新策略额外上报架构、更新通道和内置运行时版本。 账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。Welcome 和工作区均显示本地化的登录失效提示。 @@ -24,10 +24,18 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 +快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。 + +macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。更新蒙层从创建到最后一个蒙层关闭期间阻挡父窗口及其浏览器 guest 的产品快捷键和编辑按键递送。每次打开或关闭蒙层都会作废待完成的组合键状态。主进程通过显式的创建能力和输入状态读取能力,让更新对话框与快捷键输入共享同一个蒙层管理实例。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。 + macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 + + ### 内置工作区依赖 +electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron`、Node 内置模块与这些 `dependencies`;沙箱 preload 只能留下 `electron`、`events`、`timers` 与 `url`,即其 `require` polyfill 能解析的模块。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何静态、动态或 `require()` 导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。 + Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 @@ -58,7 +66,7 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 @@ -68,9 +76,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 -macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 +macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 ### 运行时与插件激活 @@ -92,7 +100,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 - ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -113,6 +120,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 和蒙层模块。 ### 启动引导 @@ -259,7 +267,7 @@ Mac 打包从 `.env.macos` 读取三个调优字段: 两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 -Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 +Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 ### 未签名 Windows 测试安装包 @@ -348,6 +356,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 +原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 + 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -375,13 +385,13 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up | `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` | | `jitter` | 随机增加的间隔比例;默认 `0.2`,范围为 `0` 至 `1` | -时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、完整壳与内置 dsh 版本、应用 ID、语言和固定 Nightly。不使用业务登录凭据或安装 ID。 +时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、DSH_CLIENT_VERSION、内置 dsh 版本、当前语言与 UTC 偏移、空 bundle ID 和固定 Nightly。不使用业务登录凭据或安装 ID。 启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 @@ -414,6 +424,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 - 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 +仅向应用提供的 `dshOnboarding.hasApiKey()` preload 方法返回欢迎后端当前的 API Key 存在状态布尔值;原生登录与引导共用凭证发现逻辑,且只有受管理的应用主 frame 可以调用。 + 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。浏览器登录成功后,Welcome 切换到工作区但不激活应用;完成页的 dsh://open 链接负责将客户端置前。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e47bc6614b..0326324fae 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,8 @@ "main": "lib/main.js", "scripts": { "test:installer": "node scripts/test-windows-installer.mjs", - "build": "tsc -b && tsdown", + "build": "tsc -b && pnpm run bundle", + "bundle": "tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", @@ -70,6 +71,8 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "vite": "^6.0.0", - "@deepseek-ai/dsh-client-ui-theme": "workspace:*" + "@deepseek-ai/dsh-client-ui-theme": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*" } } diff --git a/apps/desktop/scripts/desktop-bundle-imports.d.mts b/apps/desktop/scripts/desktop-bundle-imports.d.mts new file mode 100644 index 0000000000..3743e8ea39 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.d.mts @@ -0,0 +1,46 @@ +import type { Rolldown } from 'tsdown' + +/** Bare specifiers one Desktop bundle may leave for the runtime to resolve. */ +export interface BundleImportPolicy { + /** Package names present at runtime, matched by package name so subpaths pass. */ + readonly packages: ReadonlySet + /** + * Whether Node builtins resolve at runtime: true for the Electron main + * process; false for sandboxed preloads, whose `require` polyfill offers only + * the modules listed in `packages`. + */ + readonly nodeBuiltins: boolean +} + +/** + * Package name of a bare import specifier. + * @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns The package name without its subpath. + */ +export function importPackageName(specifier: string): string + +/** + * Whether a module id rolldown reports is a bare specifier, i.e. an import the + * bundle leaves external. Bundled modules carry absolute ids; virtual modules + * start with `\0`. + * @param id - Module id from `importedIds` or `dynamicallyImportedIds`. + * @returns True for a bare specifier. + */ +export function isBareSpecifier(id: string): boolean + +/** + * Bare imports the packaged application cannot resolve. + * @param imports - External specifiers a module imports statically, dynamically, or through `require()`. + * @param policy - What this bundle may leave external. + * @returns Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports: readonly string[], policy: BundleImportPolicy): string[] + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. The failure names the importing module and every + * offending specifier, and no output is written. + * @param policy - What this bundle may leave external. + * @returns The plugin. + */ +export function packagedImportsPlugin(policy: BundleImportPolicy): Rolldown.Plugin diff --git a/apps/desktop/scripts/desktop-bundle-imports.mjs b/apps/desktop/scripts/desktop-bundle-imports.mjs new file mode 100644 index 0000000000..d1aa8f5b9d --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.mjs @@ -0,0 +1,90 @@ +/** + * Keep every bare import a Desktop bundle leaves to the runtime resolvable + * inside the packaged application. + * + * electron-builder copies only the manifest's `dependencies` into + * `app.asar/node_modules`, so a bare import of anything else bundles without + * complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process + * bundle inlines its workspace devDependencies; when one of their `lib/` + * outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a + * warning and keeps the specifier as an external import, which is exactly the + * broken artifact. This check fails the bundle instead of the installed + * application. + * + * The check runs in `moduleParsed`, where rolldown reports every import record + * of a module: static imports, dynamic `import()`, and `require()` calls. A + * bundled import resolves to an absolute module id; an external one keeps its + * bare specifier. Chunk metadata (`imports`/`dynamicImports`) omits external + * `import()` and `require()` targets, so it cannot serve this check. + */ + +import { isBuiltin } from 'node:module' +import { isAbsolute, relative } from 'node:path' + +/** + * Bare specifiers one Desktop bundle may leave for the runtime to resolve. + * @typedef {object} BundleImportPolicy + * @property {ReadonlySet} packages - Package names present at runtime, matched by package name so subpaths pass. + * @property {boolean} nodeBuiltins - Whether Node builtins resolve at runtime (true for the Electron main process; false for sandboxed preloads, whose `require` polyfill offers only the modules listed in `packages`). + */ + +/** + * Package name of a bare import specifier. + * @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns {string} The package name without its subpath. + */ +export function importPackageName(specifier) { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +/** + * Whether a module id rolldown reports is a bare specifier, i.e. an import the + * bundle leaves external. Bundled modules carry absolute ids; virtual modules + * start with `\0`. + * @param {string} id - Module id from `importedIds` or `dynamicallyImportedIds`. + * @returns {boolean} True for a bare specifier. + */ +export function isBareSpecifier(id) { + return !id.startsWith('.') && !id.startsWith('\0') && !isAbsolute(id) +} + +/** + * Bare imports the packaged application cannot resolve. + * @param {readonly string[]} imports - External specifiers a module imports statically, dynamically, or through `require()`. + * @param {BundleImportPolicy} policy - What this bundle may leave external. + * @returns {string[]} Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports, policy) { + const offending = new Set() + for (const specifier of imports) { + if (policy.nodeBuiltins && isBuiltin(specifier)) continue + if (policy.packages.has(importPackageName(specifier))) continue + offending.add(specifier) + } + return [...offending] +} + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. The failure names the importing module and every + * offending specifier, and no output is written. + * @param {BundleImportPolicy} policy - What this bundle may leave external. + * @returns {import('tsdown').Rolldown.Plugin} The plugin. + */ +export function packagedImportsPlugin(policy) { + const allowed = [...policy.packages].join(', ') + (policy.nodeBuiltins ? ', and Node builtins' : '') + return { + name: 'desktop-packaged-imports', + moduleParsed(info) { + const external = [...info.importedIds, ...info.dynamicallyImportedIds].filter(isBareSpecifier) + const offending = unpackagedImports(external, policy) + if (offending.length === 0) return + this.error( + `desktop bundle: ${relative(process.cwd(), info.id)} imports ${offending.join(', ')}, which the packaged application does not ship. ` + + `This bundle may leave only ${allowed} as bare imports; anything else must be bundled, ` + + 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).', + ) + }, + } +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index fdd724e579..1ae2f3e952 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -64,7 +64,7 @@ async function launchElectron(): Promise { const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) - const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data')) const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, @@ -74,6 +74,7 @@ async function launchElectron(): Promise { ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', } console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: userData=${userData}`) console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) if (process.platform === 'darwin') { const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData, diff --git a/apps/desktop/scripts/flock-entry.ts b/apps/desktop/scripts/flock-entry.ts new file mode 100644 index 0000000000..7c2f3e75da --- /dev/null +++ b/apps/desktop/scripts/flock-entry.ts @@ -0,0 +1,94 @@ +/** Load the POSIX flock entry on first use so packaging entry scripts start on a checkout that has not built `native/system`. */ + +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +type FlockEntry = typeof import('@deepseek-ai/node-addon-system/flock') + +const REPOSITORY_ROOT = resolve(import.meta.dirname, '..', '..', '..') +/** Root package script that compiles the host addon, then the `native/system` script that emits the entry's JavaScript. */ +const BUILD_COMMANDS: readonly (readonly string[])[] = [ + ['run', 'build:native-system'], + ['--dir', 'native/system', 'run', 'build:ts'], +] +const BUILD_COMMAND_TEXT = BUILD_COMMANDS.map(args => `pnpm ${args.join(' ')}`).join(' && ') + +/** Loader steps; tests replace the module import, the addon probe, and the build. */ +export interface FlockEntryLoaderSteps { + /** Import `@deepseek-ai/node-addon-system/flock`; rejects with `ERR_MODULE_NOT_FOUND` while its `lib/` is unbuilt. */ + readonly importEntry: () => Promise + /** Whether every binary the host platform package declares in `prebuilds.json` exists. */ + readonly hostAddonBuilt: () => boolean + /** Compile the host addon and emit the entry's JavaScript; rejects when a build fails. */ + readonly build: () => Promise +} + +function isModuleNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ERR_MODULE_NOT_FOUND' +} + +/** + * Create a memoized loader that imports the flock entry, building `native/system` once when its entry or host addon is missing. + * @param steps - Module import, addon probe, and build used by the loader. + * @returns Loader that resolves the same entry on every call; a failed load is retried by the next call. + */ +export function createFlockEntryLoader(steps: FlockEntryLoaderSteps): () => Promise { + let loading: Promise | undefined + const load = async (): Promise => { + let entry: FlockEntry | undefined + try { + entry = await steps.importEntry() + } catch (error) { + if (!isModuleNotFound(error)) throw error + } + if (entry !== undefined && steps.hostAddonBuilt()) return entry + process.stderr.write(`desktop package: flock addon is not built; running ${BUILD_COMMAND_TEXT}\n`) + await steps.build() + return steps.importEntry() + } + return () => { + loading ??= load().catch((error: unknown) => { loading = undefined; throw error }) + return loading + } +} + +function hostAddonBuilt(): boolean { + const { platform, arch } = process + // flock itself rejects other platforms; the probe only reports missing builds for supported ones. + if (platform !== 'darwin' && platform !== 'linux') return true + const entryManifest = createRequire(import.meta.url).resolve('@deepseek-ai/node-addon-system/package.json') + let prebuilds: string + try { + prebuilds = createRequire(entryManifest).resolve(`@deepseek-ai/node-addon-system-${platform}-${arch}/prebuilds.json`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error + return false + } + const manifest = JSON.parse(readFileSync(prebuilds, 'utf8')) as { readonly binaries: readonly { readonly path: string }[] } + return manifest.binaries.every(binary => existsSync(join(dirname(prebuilds), binary.path))) +} + +async function buildNativeSystem(): Promise { + const pnpmEntry = process.env.npm_execpath + if (pnpmEntry === undefined || pnpmEntry === '') { + throw new Error(`desktop package: flock addon is not built; run ${BUILD_COMMAND_TEXT} or invoke this script through a pnpm package command`) + } + for (const args of BUILD_COMMANDS) { + const child = spawn(process.execPath, [pnpmEntry, ...args], { cwd: REPOSITORY_ROOT, stdio: 'inherit' }) + const [code, signal] = await once(child, 'close') as [number | null, NodeJS.Signals | null] + if (code !== 0) throw new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`) + } +} + +/** + * Load `@deepseek-ai/node-addon-system/flock`, building the host addon and the entry's JavaScript through pnpm when either is missing. + * @returns The flock entry; rejects when a build fails or the script did not run under pnpm. + */ +export const loadFlockEntry = createFlockEntryLoader({ + importEntry: () => import('@deepseek-ai/node-addon-system/flock'), + hostAddonBuilt, + build: buildNativeSystem, +}) diff --git a/apps/desktop/scripts/macos-notarization-proxy.ts b/apps/desktop/scripts/macos-notarization-proxy.ts index 11f19b4022..bd5900a758 100644 --- a/apps/desktop/scripts/macos-notarization-proxy.ts +++ b/apps/desktop/scripts/macos-notarization-proxy.ts @@ -5,7 +5,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } f import { connect } from 'node:net' import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock' +import { loadFlockEntry } from './flock-entry.ts' interface ProxyState { readonly enabled: boolean @@ -50,6 +50,7 @@ const operations: ProxyOperations = { } async function withProxyLock(lock: string, action: () => Promise): Promise { + const { tryLockExclusive } = await loadFlockEntry() mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }) // Keep this inode across transactions: unlinking it would allow two independent locks. const fd = openSync(`${lock}.flock`, 'a', 0o600) diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index d816de55fc..889b4371bc 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import WebSocket from 'ws' import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol' -import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import type { AccountClientMetadata, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' /** Authenticated unary caller shared with native onboarding. */ export type AccountInvoke = (request: { namespace: string; method: string; args: Record }) => Promise @@ -57,12 +57,12 @@ function validateBrowserDestination(value: string): void { export interface DesktopAccountBackend { /** @returns current account snapshot. */ state(): Promise - /** @param locale - active desktop UI language. @returns new or already-running login attempt. */ - start(locale: string): Promise + /** @param client - this window's identity and current language. @returns new or already-running login attempt. */ + start(client: AccountClientMetadata): Promise /** @param id - attempt to cancel. @returns cancellation or completed commit state. */ cancel(id: SignInAttemptId): Promise - /** @returns state after local sign-out. */ - signOut(): Promise + /** @param client - this window's identity. @returns state after local sign-out. */ + signOut(client: AccountClientMetadata): Promise /** * @param listener - state recipient. * @param failed - stream failure recipient. @@ -83,8 +83,9 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo const call = async (method: string, args: Record = {}): Promise => accountView(await invoke({ namespace: 'account', method, args })) return { - state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), - cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'), + 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) { let closed = false let socket: WebSocket | undefined diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts index 027b7cee5b..c3f0b10eea 100644 --- a/apps/desktop/src/browser-guests.ts +++ b/apps/desktop/src/browser-guests.ts @@ -9,6 +9,7 @@ interface GuestLease { readonly partition: string attached: boolean guest?: WebContents + releaseInput?: () => void } /** Owns workspace storage partitions independently from individual tab guests. */ @@ -51,6 +52,7 @@ export class DesktopBrowserGuests { const lease = this.leases.get(key) if (lease === undefined) return if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window') + lease.releaseInput?.() this.leases.delete(key) const guest = lease.guest if (guest !== undefined && !guest.isDestroyed()) { @@ -63,8 +65,9 @@ export class DesktopBrowserGuests { /** * Install attachment checks before the application document can create a webview. * @param window - primary application window. + * @param attachInput - attaches native input after guest ownership is verified and returns its disposer. */ - bind(window: BrowserWindow): void { + bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void { const owner = window.webContents owner.on('will-attach-webview', (event, preferences, params) => { const id = typeof params.src === 'string' && params.src.startsWith('about:blank#') @@ -102,7 +105,8 @@ export class DesktopBrowserGuests { } lease.guest = guest attachedLease = id - guest.once('destroyed', () => { this.leases.delete(id) }) + lease.releaseInput = attachInput(guest, id) + guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) }) }) guest.setWindowOpenHandler(({ url, postBody }) => { const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease) diff --git a/apps/desktop/src/client-metadata.ts b/apps/desktop/src/client-metadata.ts new file mode 100644 index 0000000000..0191bea92d --- /dev/null +++ b/apps/desktop/src/client-metadata.ts @@ -0,0 +1,29 @@ +/** Desktop client identity for one Platform account call. */ +import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types' + +/** + * Read the client build version inlined by the Desktop build. + * @returns the version embedded in this application build. + * @throws Error when the build carries no client version, instead of reporting a guessed one. + */ +export function desktopClientVersion(): string { + const version = process.env.DSH_CLIENT_VERSION + if (version === undefined || version === '') { + throw new Error('desktop account: this application build carries no DSH_CLIENT_VERSION') + } + return version +} + +/** + * Sample the Desktop client identity for one Platform request. + * @param locale - current resolved Desktop language. + * @returns this call's build version, the raw active language, and the UTC offset in whole seconds east. + */ +export function desktopClientMetadata(locale: string): AccountClientMetadata { + return { + version: desktopClientVersion(), + locale, + // Date.getTimezoneOffset reports minutes west of UTC; Platform wants seconds east. + timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 37d8c31e4b..8b3a901a69 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -45,6 +45,8 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { if (session === null) return true if (typeof session !== 'object' || !('origin' in session) || !('token' in session) || typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false + if (!('userId' in session) || (session.userId !== null + && (typeof session.userId !== 'string' || session.userId.length === 0))) return false if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null || Array.isArray(session.requestHeaders) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index cf03b7e6c2..45175929d2 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,12 +1,21 @@ /** Typed preload operations exposed only by the Electron shell. */ +import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { + shortcutsInput: 'dsh-desktop:shortcuts-input', + shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window', + shortcutsGet: 'dsh-desktop:shortcuts-get', + shortcutsEdit: 'dsh-desktop:shortcuts-edit', + shortcutsChanged: 'dsh-desktop:shortcuts-changed', + shortcutsRecording: 'dsh-desktop:shortcuts-recording', boot: 'dsh-desktop:boot', enterWorkspace: 'dsh-desktop:enter-workspace', + onboardingActive: 'dsh-desktop:onboarding-active', + onboardingApiKey: 'dsh-desktop:onboarding-api-key', bootFailed: 'dsh-desktop:boot-failed', browserAcquire: 'dsh-desktop:browser-acquire', browserRelease: 'dsh-desktop:browser-release', @@ -62,6 +71,8 @@ export interface DesktopUpdatePresentation { export interface DshDesktopProductApi { readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge + readonly keyboard: DesktopKeyboardApi + readonly shortcuts: DesktopShortcutsApi readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/keybindings.ts b/apps/desktop/src/keybindings.ts new file mode 100644 index 0000000000..0825906c27 --- /dev/null +++ b/apps/desktop/src/keybindings.ts @@ -0,0 +1,27 @@ +/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol' + +/** + * Open the device configuration with atomic replacement. + * @param userData - Electron-owned userData directory, never a Renderer-supplied path. + * @param platform - local input platform. + * @param publish - updates the native binding index and trusted product page after commit. + * @returns the single-writer transaction coordinator. + */ +export function desktopKeybindings(userData: string, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence { + const path = join(userData, 'keybindings.json') + return new ShortcutPersistence({ + read: async () => { + try { return await readFile(path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + }, + write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }), + }, 'desktop', platform, false, publish) +} diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts new file mode 100644 index 0000000000..d41f6b47c2 --- /dev/null +++ b/apps/desktop/src/keyboard.ts @@ -0,0 +1,261 @@ +/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */ +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { desktopKeybindings } from './keybindings.ts' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' + +/** + * Install application-owned configuration handlers and attach each product window's input lifecycle. + * @param getWindow - current product window. + * @param userData - Electron-resolved device preference directory. + * @param platform - local device platform. + * @param updateMenu - rebuild the application menu when the close accelerator or availability changes. + * @param overlayInput - Current shell-owned input blocking state for the product window. + * @returns menu construction, editor key delivery, window attachment, and teardown operations. + */ +export function installDesktopShortcuts( + getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void, + overlayInput: (window: BrowserWindow) => { readonly revision: number; readonly blocked: boolean }, +): { + fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions + /** + * Send a native Edit action to the editor without matching user shortcuts. + * @param keyCode - edit key. + * @param modifiers - edit modifiers. + */ + sendEditingKey(keyCode: string, modifiers: Array<'control'>): void + attach(window: BrowserWindow): void + /** + * Intercept an approved browser guest's keys until its lease ends. + * @param window - owning product window. + * @param guest - approved browser contents. + * @param name - main-issued lease used as the webview element name. + * @returns idempotent listener disposer. + */ + attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void + dispose(): void +} { + let definitions: readonly ShortcutDefinition[] = [] + let recording = false + let revision: ShortcutRevision | undefined + let closeBinding: NormalizedBinding | null = null + let editingInput: BrowserWindow['webContents'] | undefined + const scopedDesktop = platform === 'windows' || platform === 'macos' + const disposers = new Set<() => void>() + const guestInputs = new Map() + const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria + ?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1') + const sendMenuClose = (): void => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled() + || revision === undefined || recording || overlayInput(window).blocked) return + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision }) + } + let keys = new Set() + const publish = (snapshot: ShortcutConfigSnapshot): void => { + const wasEnabled = revision !== undefined + const previousAccelerator = closeAccelerator() + revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision + const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform) + keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0 + ? [bindingKey(row.binding)] : [])) + const close = rows.find(row => row.id === 'page.close') + closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null + if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu() + const window = getWindow() + if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) { + window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot) + } + } + const persistence = desktopKeybindings(userData, platform, publish) + const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender') + assertDesktopSender(event, ['app']) + return window + } + ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => { + assertSender(event) + definitions = parseShortcutDefinitions(input) + persistence.setDefinitions(definitions) + return persistence.readCurrent() + }) + ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, expectedRevision: unknown) => { + assertSender(event) + if (typeof expectedRevision !== 'string') throw new Error('desktop shortcuts: invalid revision') + return persistence.edit(parseShortcutEdit(input), expectedRevision as ShortcutRevision) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => { + const window = assertSender(event) + if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state') + recording = active + window.webContents.setIgnoreMenuShortcuts(active) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => { + const window = assertSender(event) + if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled() + || overlayInput(window).blocked) return + window.close() + }) + function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void { + let deadKey = false + const held = new Set() + const consumed = new Map() + let inputFrame: typeof contents.focusedFrame = null + let inputRevision: ShortcutRevision | undefined + let overlayRevision = overlayInput(window).revision + const resetInput = (): void => { + deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput }) + const resetWindow = (): void => { + resetInput() + for (const guest of guestInputs.values()) if (guest.window === window) guest.reset() + } + const clear = (): void => { + resetInput() + if (guestName !== undefined) return + definitions = []; keys.clear(); recording = false + persistence.setDefinitions(null) + } + const navigation = (event: Electron.Event): void => { + if (event.isMainFrame && !event.isSameDocument) clear() + } + const beforeInput = (event: Electron.Event, input: Input): void => { + const overlay = overlayInput(window) + if (overlay.revision !== overlayRevision) { resetInput(); overlayRevision = overlay.revision } + if (overlay.blocked) { event.preventDefault(); return } + if (event.defaultPrevented) { resetInput(); return } + if (editingInput === contents) { + held.clear() + consumed.clear() + contents.setIgnoreMenuShortcuts(true) + return + } + if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined + || (guestName !== undefined && !contents.isFocused())) { + contents.setIgnoreMenuShortcuts(false) + held.clear() + consumed.clear() + return + } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier]) + const key = bindingKey({ code: input.code, modifiers }) + const match = keys.has(key) + const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+') + && input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '') + contents.setIgnoreMenuShortcuts(recording || match || menuMatch) + const frame = contents.focusedFrame + const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr') + if (input.type === 'keyDown') deadKey = input.key === 'Dead' + if (recording || composing || frame === null) { held.clear(); consumed.clear(); return } + let binding: NormalizedBinding = { code: input.code, modifiers } + let priority = false + if (scopedDesktop) { + if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision } + const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code) + if (input.type === 'keyUp') { + // A chord's first key reached the renderer, so its release must reach the same input handlers. + if (consumed.get(input.code) === 'press') event.preventDefault() + consumed.delete(input.code) + held.delete(input.code) + if (modifierKey) held.clear() + return + } + if (!input.isAutoRepeat) consumed.delete(input.code) + if (modifierKey) { held.clear(); return } + if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return } + if (input.isAutoRepeat && !held.has(input.code) && !match) return + held.add(input.code) + const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)] + codes.sort() + const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers } + priority = keys.has(key) + if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true } + } + const main = guestName === undefined && frame === contents.mainFrame + if (!priority && (main || !match)) return + event.preventDefault() + if (input.type !== 'keyDown') return + if (scopedDesktop) { + if (!input.isAutoRepeat) { + if (binding.secondCode !== undefined) { + consumed.set(binding.code, 'repeat') + consumed.set(binding.secondCode, 'repeat') + } + consumed.set(input.code, 'press') + } + // Electron can omit both keyups after interception; completed presses cannot seed another chord. + held.clear() + } + let embedding = frame + while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) { + embedding = embedding.parent + } + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview', + revision, frameName: guestName ?? (main ? '' : embedding.name), + code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }), + repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta }) + } + const dispose = (): void => { + contents.off('did-start-navigation', navigation) + contents.off('before-input-event', beforeInput) + contents.off('blur', resetInput) + contents.off('destroyed', dispose) + if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) } + disposers.delete(dispose) + guestInputs.delete(contents) + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + const closed = (): void => { clear(); dispose() } + contents.on('did-start-navigation', navigation) + contents.on('before-input-event', beforeInput) + contents.on('blur', resetInput) + contents.once('destroyed', dispose) + if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) } + disposers.add(dispose) + return dispose + } + + return { + sendEditingKey(keyCode, modifiers) { + const window = getWindow() + if (window === undefined || window.isDestroyed() || overlayInput(window).blocked) return + const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0] + ?? window.webContents + contents.focus() + const previous = editingInput + editingInput = contents + try { + // Electron emits before-input-event synchronously for these editor-owned keys. + contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + } finally { + editingInput = previous + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording) + } + }, + fileMenu: (labels) => { + const accelerator = closeAccelerator() + return { label: labels.fileMenu, submenu: [{ + id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined, + ...accelerator === undefined ? {} : { accelerator }, + click: sendMenuClose, + }] } + }, + attach(window) { attachInput(window, window.webContents) }, + attachGuest: attachInput, + dispose() { + for (const dispose of disposers) dispose() + persistence.dispose() + for (const channel of [DESKTOP_IPC.shortcutsGet, + DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) { + ipcMain.removeHandler(channel) + } + }, + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 22c5147d96..5fbe23f7e6 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -2,6 +2,8 @@ export const en = { application: 'Application', + fileMenu: 'File', + closePage: 'Close Page or Window', aboutMenu: 'About DeepSeek Harness', aboutProduct: 'DeepSeek Harness', aboutVersion: 'Version V{version}', @@ -133,6 +135,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + fileMenu: '文件', + closePage: '关闭页面或窗口', aboutMenu: '关于 DeepSeek Harness', aboutProduct: 'DeepSeek Harness', aboutVersion: '版本 V{version}', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2d9b8d4e32..63caff12ed 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,11 +43,14 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts' import { desktopErrorState } from './startup-error.ts' import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts' +import { desktopClientMetadata } from './client-metadata.ts' import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' +import { installDesktopShortcuts } from './keyboard.ts' +import { DesktopUpdateOverlays } from './update-overlay.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -323,7 +326,8 @@ async function main(): Promise { const currentMainWindow = (): BrowserWindow | undefined => mainWindow const ordinaryDialogs = new Set() const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow - const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale) + const updateOverlays = new DesktopUpdateOverlays() + const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { const controller = new AbortController() @@ -377,7 +381,7 @@ async function main(): Promise { return next.promise } const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'), - () => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US') + () => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin') const backend = new DesktopBackendController((onFailure) => { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, @@ -544,6 +548,8 @@ async function main(): Promise { const stillActive = await host.updateTasks('lock') if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', locale.messages.updateTasksChanged) mandatoryUI?.preparingRestart(stillActive) + // The embedded Platform document holds credentials issued by the Host that is about to stop. + await platformView.closeAndWait() requireCleanStop = true updateStopFailure = undefined await backend.stop() @@ -592,6 +598,9 @@ async function main(): Promise { installDesktopDirectoryPicker(() => mainWindow) installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) + const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), + process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window)) + app.on('will-quit', () => { shortcuts.dispose() }) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -673,12 +682,27 @@ async function main(): Promise { locale = current platformView.notifyLocaleChanged() windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) return presentDesktopUpdate(updates.state) }) + ipcMain.handle(DESKTOP_IPC.onboardingApiKey, async (event) => { + assertProductSender(event) + return (await readWelcomeState()).hasApiKey + }) + ipcMain.on(DESKTOP_IPC.onboardingActive, (event, active: unknown) => { + const window = mainWindow + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame + || !event.senderFrame.url.startsWith(`${SCHEME}://app/`) || typeof active !== 'boolean') return + window.setMinimumSize(active ? 960 : 520, 600) + if (active) { + const { width, height } = window.getBounds() + if (width < 960) window.setSize(960, height) + } + }) ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => { assertProductSender(event) await openUpdatePrompt() @@ -825,8 +849,8 @@ async function main(): Promise { // its standard menus and application hide commands declared explicitly. // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' - const platformMenus: MenuItemConstructorOptions[] = darwin - ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] + const platformMenus = (): MenuItemConstructorOptions[] => darwin + ? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, @@ -860,13 +884,13 @@ async function main(): Promise { { role: 'toggleDevTools', visible: false }, { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, ] - const installMenu = (): void => { + const refreshApplicationMenu = (): void => { Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{ label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: [...applicationItems(), ...devToolsItems], - }, ...platformMenus])) + }, ...platformMenus()])) } - installMenu() + refreshApplicationMenu() if (process.platform === 'win32') { ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { @@ -882,9 +906,7 @@ async function main(): Promise { label, ...(accelerator === undefined ? {} : { accelerator }), click: () => { - window.webContents.focus() - window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) - window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + shortcuts.sendEditingKey(keyCode, modifiers) }, }) const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [ @@ -920,7 +942,8 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, false, true) mainWindow = window - browserGuests.bind(window) + browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name)) + shortcuts.attach(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('console-message', (details) => { @@ -977,7 +1000,7 @@ async function main(): Promise { }, startSignIn: async () => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') - return welcomeBackend.account.start(locale.id) + return welcomeBackend.account.start(desktopClientMetadata(locale.id)) }, cancelSignIn: async (id) => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') @@ -1021,7 +1044,7 @@ async function main(): Promise { if (isQuitting() || backend.state.phase !== 'ready') return locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages) windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) { await showWelcome() } else { @@ -1062,6 +1085,8 @@ async function main(): Promise { if (shellInstallerOwnsQuit) { updateDialog.dispose() mandatoryUI?.dispose() + // Installation preparation already awaited Platform storage cleanup. + void platformView.dispose().catch((error: unknown) => { console.error(error) }) return } if (quitting) return @@ -1073,7 +1098,9 @@ async function main(): Promise { updateSchedule.dispose() updateDialog.dispose() mandatoryUI?.dispose() - void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close()]) + void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(), + // A Platform cleanup failure is logged without cutting the remaining Host shutdown short. + platformView.dispose().catch((error: unknown) => { console.error(error) })]) .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) }) @@ -1091,16 +1118,11 @@ async function main(): Promise { () => mandatoryUI?.confirmationWindow ?? currentDialogWindow(), (event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) }) } - const bundleId = app.isPackaged - ? ('dshDesktopAppId' in manifest ? manifest.dshDesktopAppId : undefined) - : process.env.DSH_DESKTOP_APP_ID - if (typeof bundleId !== 'string' || bundleId.trim() === '') throw new Error('desktop policy: missing application bundle ID') if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform') let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', - version: app.getVersion(), bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), - bundleId, locale: locale.id, + bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false if (state.blocking) { @@ -1110,9 +1132,10 @@ async function main(): Promise { mandatoryUI?.sync() if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) }) wasBlocking = state.blocking - }, policyAuth?.request) + }, policyAuth?.request, () => desktopClientMetadata(locale.id)) const policy = mandatoryPolicy mandatoryUI = new DesktopMandatoryUpdateWindow({ + overlays: updateOverlays, preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, policy: () => policy.state, update: () => updates.state, diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 7f4aabb4bd..27fd51bd44 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -1,15 +1,12 @@ /** Mandatory-update policy, independent of local business traffic and updater artifacts. */ import { valid } from 'semver' -import { desktopClientHeaders } from '@deepseek-ai/dsh-deepseek-account' +import { platformClientHeaders, type AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account' /** Installed release identity; no field is supplied by a renderer. */ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' - readonly version: string readonly bundledDshVersion: string - readonly bundleId: string - readonly locale: string readonly arch: 'x64' | 'arm64' } @@ -127,7 +124,7 @@ function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): D throw new Error('desktop policy: response does not contain a valid mandatory or no-force decision') } -/** Owns one immutable installed-client context, its in-flight request, and polling schedule. */ +/** Owns one installed-client context, its in-flight request, and polling schedule. */ export class DesktopMandatoryUpdatePolicy { private current: DesktopPolicyState = { blocking: false, checking: false } private pending: Promise | undefined @@ -136,30 +133,34 @@ export class DesktopMandatoryUpdatePolicy { private disposed = false private failures = 0 private nextCheck = -Infinity - private readonly headers: Readonly> /** * @param config - Resolved deployment settings. - * @param identity - Installed software identity, copied once for this lifetime. + * @param identity - Installed software identity, fixed for this application build. * @param publish - Receives policy changes without controlling downloads or existing tasks. * @param request - Anonymous Fetch or the dedicated test-authentication Session transport. + * @param client - UI build version, language, and UTC offset, sampled for every check. * @param random - Jitter source, replaceable for clock-driven tests. */ constructor( private readonly config: DesktopPolicyConfig, - identity: DesktopPolicyIdentity, + private readonly identity: DesktopPolicyIdentity, private readonly publish: (state: DesktopPolicyState) => void, private readonly request: typeof fetch = fetch, + private readonly client: () => AccountClientMetadata, private readonly random: () => number = Math.random, ) { - if (valid(identity.version) === null || valid(identity.bundledDshVersion) === null || identity.bundleId.trim() === '' + if (valid(this.client().version) === null || valid(identity.bundledDshVersion) === null || (identity.platform === 'win32' && identity.arch !== 'x64')) throw new Error('desktop policy: invalid installed client identity') - this.headers = Object.freeze({ - ...desktopClientHeaders(identity.platform), 'x-client-version': identity.version, - 'x-client-bundle-id': identity.bundleId, 'x-client-locale': identity.locale, - 'x-client-arch': identity.arch, 'x-client-update-channel': 'nightly', - 'x-client-bundled-dsh-version': identity.bundledDshVersion, - }) + } + + /** Platform headers for one check; the calling UI's language and UTC offset are read now. */ + private requestHeaders(): Record { + return { + ...platformClientHeaders(this.identity.platform, this.client()), + 'x-client-arch': this.identity.arch, 'x-client-update-channel': 'nightly', + 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, + } } /** Latest policy; failures never erase a known mandatory decision. */ @@ -185,7 +186,7 @@ export class DesktopMandatoryUpdatePolicy { try { const url = new URL('/api/v0/check_client_update', this.config.origin) url.searchParams.set('scenario', scenario) - const response = await this.request(url, { headers: this.headers, signal: controller.signal, + const response = await this.request(url, { headers: this.requestHeaders(), signal: controller.signal, credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' }) const body: unknown = await response.json() if (this.config.authentication === 'feishu-test' && response.status === 401 diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index cfca36679b..724df4ff97 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -5,7 +5,7 @@ import type { DesktopLocale } from './locale.ts' import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' import { MANDATORY_IPC } from './mandatory-update-ipc.ts' -import { createUpdateOverlay } from './update-overlay.ts' +import type { DesktopUpdateOverlays } from './update-overlay.ts' import { DesktopUpdateAttention } from './update-attention.ts' /** A renderer action never carries a URL or authorizes a different version. */ @@ -33,6 +33,7 @@ export interface MandatoryUpdateApi { /** Main-process operations owned by the policy client, updater, and application lifecycle. */ export interface MandatoryUpdateWindowOptions { readonly preload: string + readonly overlays: Pick readonly locale: DesktopLocale readonly allowedPageOrigins: readonly string[] readonly parent: () => BrowserWindow | undefined @@ -190,7 +191,7 @@ export class DesktopMandatoryUpdateWindow { if (this.window === undefined) { const parent = this.options.parent() if (parent === undefined) return - const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) + const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) this.window = window window.setMenu(null) window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } }) diff --git a/apps/desktop/src/platform-view.ts b/apps/desktop/src/platform-view.ts index eb7733e156..ce759c61b1 100644 --- a/apps/desktop/src/platform-view.ts +++ b/apps/desktop/src/platform-view.ts @@ -1,8 +1,9 @@ /** Isolated Platform documents owned by the desktop account lifetime. */ import type { EventEmitter } from 'node:events' -import { randomUUID } from 'node:crypto' -import { WebContentsView, session, shell, type View, type WebFrameMain } from 'electron' -import { mergePlatformCookies, type PlatformSession } from '@deepseek-ai/dsh-deepseek-account' +import { createHash, randomUUID } from 'node:crypto' +import { WebContentsView, session, shell, type Session, type View, type WebFrameMain } from 'electron' +import { mergePlatformCookies, platformClientHeaders, type PlatformSession } from '@deepseek-ai/dsh-deepseek-account' +import { desktopClientMetadata } from './client-metadata.ts' import { PLATFORM_IPC, type PlatformLocale } from './platform-ipc.ts' @@ -43,66 +44,50 @@ export class DesktopPlatformView { private owner: PlatformOwner | undefined private releaseOwner: (() => void) | undefined private generation = 0 + private readonly storageCleanup = new Map>() + private disposed = false /** * @param preload - bundled sandboxed Platform preload path. * @param getLocale - current resolved Desktop language. + * @param platform - operating system this shell runs on, reported to Platform. */ - constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale) {} + constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale, + private readonly platform: 'darwin' | 'win32') {} - /** @param next - private Host credential snapshot; replacement invalidates the current document. */ + /** @param next - private Host credentials; identity enrichment preserves an already open temporary document. */ setSession(next: PlatformSession | null): void { if (next?.token === this.account?.token && next?.origin === this.account?.origin && next?.embeddedPageDist === this.account?.embeddedPageDist - && JSON.stringify(next?.requestHeaders) === JSON.stringify(this.account?.requestHeaders)) return + && JSON.stringify(next?.requestHeaders) === JSON.stringify(this.account?.requestHeaders)) { + if (next?.userId === this.account?.userId || this.account?.userId === null) { + this.account = next + return + } + } this.close() this.account = next } /** - * Create an in-memory browser session after account preparation has completed. + * Open account-scoped persistent storage, or temporary storage when the account ID is unavailable. * @param owner - application window containing the view. * @param page - explicit supported Platform page. * @param bounds - owned renderer rectangle. - * @returns when the document finishes loading. + * @returns when loading finishes, or without a document when superseded or the owner closes or navigates. */ async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise { + if (this.disposed) throw new Error('Platform view disposed') this.close() const account = this.account if (account === null) throw new Error('Platform account unavailable') + if (owner.isDestroyed()) return const generation = this.generation - const browserSession = session.fromPartition(`dsh-platform-${randomUUID()}`) - browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) }) - browserSession.setPermissionCheckHandler(() => false) - const deploymentHeaders = account.requestHeaders ?? {} - const injectedRequests = new Set() - browserSession.webRequest.onCompleted((details) => { injectedRequests.delete(details.id) }) - browserSession.webRequest.onErrorOccurred((details) => { injectedRequests.delete(details.id) }) - browserSession.webRequest.onBeforeSendHeaders((details, callback) => { - let headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value])) - if (new URL(details.url).origin === account.origin) { - injectedRequests.add(details.id) - const cookie = headers.cookie ?? '' - Object.assign(headers, deploymentHeaders) - if (deploymentHeaders.cookie !== undefined) headers.cookie = mergePlatformCookies(cookie, deploymentHeaders.cookie) - } else if (injectedRequests.has(details.id)) { - // Redirected subresources must not carry deployment headers to another origin. - headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !Object.hasOwn(deploymentHeaders, name))) - } - callback({ requestHeaders: headers }) - }) - const view = new WebContentsView({ webPreferences: { - session: browserSession, preload: this.preload, sandbox: true, contextIsolation: true, - additionalArguments: [`--dsh-platform-origin=${account.origin}`], - nodeIntegration: false, webSecurity: true, - } }) - this.view = view this.owner = owner - const closeOwnedView = () => { if (this.view === view) this.close() } + const closeOwnedView = () => { if (generation === this.generation) this.close() } const navigateOwner = (_event: Electron.Event, _url: string, isInPlace: boolean, isMainFrame: boolean) => { if (isMainFrame && !isInPlace) closeOwnedView() } - // Renderer document replacement does not run React effect cleanup. owner.webContents.on('did-start-navigation', navigateOwner) owner.webContents.on('render-process-gone', closeOwnedView) owner.webContents.on('destroyed', closeOwnedView) @@ -113,6 +98,49 @@ export class DesktopPlatformView { owner.webContents.removeListener('destroyed', closeOwnedView) owner.removeListener('closed', closeOwnedView) } + const partition = account.userId === null ? `dsh-platform-${randomUUID()}` + : `persist:dsh-platform-${createHash('sha256').update(JSON.stringify([account.origin, account.userId])).digest('hex')}` + const browserSession = session.fromPartition(partition) + // Sanitize persisted authentication even after an unclean previous process exit. + const failure = await this.cleanStorage(browserSession) + if (generation !== this.generation) return + if (failure !== null) { + this.close() + throw failure.error + } + browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) }) + browserSession.setPermissionCheckHandler(() => false) + const deploymentHeaders = account.requestHeaders ?? {} + const injectedNames = new Set([...Object.keys(deploymentHeaders), + ...Object.keys(platformClientHeaders(this.platform, desktopClientMetadata(this.getLocale())))] + .map(name => name.toLowerCase())) + const injectedRequests = new Set() + browserSession.webRequest.onCompleted((details) => { injectedRequests.delete(details.id) }) + browserSession.webRequest.onErrorOccurred((details) => { injectedRequests.delete(details.id) }) + browserSession.webRequest.onBeforeSendHeaders((details, callback) => { + let headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value])) + if (new URL(details.url).origin === account.origin) { + injectedRequests.add(details.id) + // The document identifies the UI that is asking, so language and UTC offset are sampled now. + const injected = { + ...deploymentHeaders, + ...platformClientHeaders(this.platform, desktopClientMetadata(this.getLocale())), + } + const cookie = headers.cookie ?? '' + Object.assign(headers, injected) + if (injected.cookie !== undefined) headers.cookie = mergePlatformCookies(cookie, injected.cookie) + } else if (injectedRequests.has(details.id)) { + // Redirected subresources must not carry injected headers to another origin. + headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !injectedNames.has(name.toLowerCase()))) + } + callback({ requestHeaders: headers }) + }) + const view = new WebContentsView({ webPreferences: { + session: browserSession, preload: this.preload, sandbox: true, contextIsolation: true, + additionalArguments: [`--dsh-platform-origin=${account.origin}`], + nodeIntegration: false, webSecurity: true, + } }) + this.view = view // External payment and documentation pages open without the embedded session or token. view.webContents.setWindowOpenHandler(({ url }) => { const destination = new URL(url) @@ -180,20 +208,61 @@ export class DesktopPlatformView { } } - /** Destroy the document before releasing its temporary browser storage. */ + /** Destroy the document and clear authentication; account-scoped page preferences survive reopening. */ close(): void { this.generation++ const view = this.view this.view = undefined this.releaseOwner?.() this.releaseOwner = undefined - if (view === undefined) return - if (this.owner !== undefined && !this.owner.isDestroyed()) this.owner.contentView.removeChildView(view) + const owner = this.owner this.owner = undefined + if (view === undefined) return + if (owner !== undefined && !owner.isDestroyed()) owner.contentView.removeChildView(view) const browserSession = view.webContents.session - if (!view.webContents.isDestroyed()) view.webContents.close() - void browserSession.clearStorageData().catch(() => { - // The non-persistent partition is unreachable after its only view is closed. + const destroyed = new Promise((resolve) => { + if (view.webContents.isDestroyed()) resolve() + else { + view.webContents.once('destroyed', resolve) + view.webContents.close({ waitForBeforeUnload: false }) + } }) + browserSession.webRequest.onBeforeSendHeaders(null) + browserSession.webRequest.onCompleted(null) + browserSession.webRequest.onErrorOccurred(null) + browserSession.flushStorageData() + void this.cleanStorage(browserSession, destroyed) + } + + /** Stop accepting documents and await all scheduled authentication cleanup. */ + async dispose(): Promise { + this.disposed = true + this.account = null + await this.closeAndWait() + } + + /** Destroy the current document and await authentication cleanup before an installer takes over. */ + async closeAndWait(): Promise { + this.close() + const results = await Promise.all(this.storageCleanup.values()) + const failures = results.filter(result => result !== null) + if (failures.length > 0) throw new AggregateError(failures.map(result => result.error), 'Platform storage cleanup failed') + } + + private cleanStorage(browserSession: Session, destroyed: Promise = Promise.resolve()): Promise<{ error: unknown } | null> { + const previous = this.storageCleanup.get(browserSession) + const cleanup = Promise.all([previous, destroyed]).then(async () => { + await browserSession.closeAllConnections() + const results = await Promise.allSettled([ + browserSession.clearStorageData(browserSession.isPersistent() + ? { storages: ['cookies', 'filesystem', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] } : undefined), + browserSession.clearCache(), + browserSession.clearAuthCache(), + ]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length > 0) throw new AggregateError(failures.map((result): unknown => result.reason), 'Platform storage cleanup failed') + }).then(() => null, (error: unknown) => ({ error })) + this.storageCleanup.set(browserSession, cleanup) + return cleanup } } diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 13bbc20690..ee262d97d5 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,6 @@ /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { PLATFORM_IPC } from './platform-ipc.ts' @@ -13,6 +14,37 @@ function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, browser: createDesktopBrowserBridge(), + keyboard: { + closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, + subscribe: (listener) => { + const handle = (_event: Electron.IpcRendererEvent, input: DesktopShortcutInput): void => { + if (input.kind === 'iframe') { + const element = document.activeElement + if (!(element instanceof HTMLIFrameElement) || !element.isConnected + || !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return + if (input.frameName === '' || element.name !== input.frameName) return + } + if (input.kind === 'webview') { + const element = document.activeElement + if (element?.matches('webview[data-sidebar-browser-frame]') !== true || !element.isConnected + || input.frameName === '' || element.getAttribute('name') !== input.frameName) return + } + listener(input) + } + ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle) } + }, + }, + shortcuts: { + get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise, + edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise, + recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, snapshot: ShortcutConfigSnapshot): void => { listener(snapshot) } + ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle) } + }, + }, updates: { status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, @@ -26,6 +58,10 @@ function createProductApi(): DshDesktopProductApi { } if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { + contextBridge.exposeInMainWorld('dshOnboarding', { + hasApiKey: () => ipcRenderer.invoke(DESKTOP_IPC.onboardingApiKey) as Promise, + setActive: (active: boolean) => { ipcRenderer.send(DESKTOP_IPC.onboardingActive, active) }, + }) ipcRenderer.on(DESKTOP_IPC.enterWorkspace, () => { const body = document.body const previous = body.getAttribute('tabindex') @@ -60,7 +96,7 @@ markDocumentPlatform() syncWindowFullscreen() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. -contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 }) +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' && process.isMainFrame ? createProductApi() : { protocolVersion: 1 }) if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_LOCALE__', { diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index a0ac654f57..db0fd390e6 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -1,7 +1,7 @@ /** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */ import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron' import type { DesktopLocale } from './locale.ts' -import { createUpdateOverlay } from './update-overlay.ts' +import type { DesktopUpdateOverlays } from './update-overlay.ts' /** Channels available only to the isolated update-dialog document. */ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const @@ -51,8 +51,10 @@ export class DesktopUpdateDialog { /** * @param preload - Bundled isolated preload. * @param locale - Shell-owned copy or a reader of the current UI language. + * @param overlays - Application-owned overlay creation and input tracking. */ - constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale)) { + constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale), + private readonly overlays: Pick) { ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null }) ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => { this.owned(event) @@ -83,7 +85,7 @@ export class DesktopUpdateDialog { clearTimeout(this.closing) this.closing = undefined const existing = this.window - const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? locale.messages.updateTitle, false) + const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false) this.window = window this.parent = parent const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index a6e341ecb9..93fe3117c4 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -1,49 +1,75 @@ /** Shell-owned modal windows cover the parent's content without replacing its native window controls. */ import { BrowserWindow } from 'electron' -/** - * @param parent - Product window whose content is blocked while the overlay is open. - * @param preload - Isolated shell-only preload. - * @param title - Localized window title. - * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. - * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. - */ -export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { - const window = new BrowserWindow({ - parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, - ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, - skipTaskbar: true, hasShadow: false, title, - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, - }) - // macOS native modals animate the entire viewport as a sheet. - const focus = (): void => { if (!window.isDestroyed()) window.focus() } - const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } - if (!nativeModal && process.platform === 'darwin') { - parent.on('focus', focus) - parent.webContents.on('before-input-event', blockInput) - window.once('closed', () => { - parent.off('focus', focus) - parent.webContents.off('before-input-event', blockInput) +const unblockedInput = { revision: 0, blocked: false } as const + +/** Tracks application-owned update overlays and their parent input state. */ +export class DesktopUpdateOverlays { + private readonly inputStates = new WeakMap() + + /** + * @param parent - Product window whose input may belong to an update dialog. + * @returns Current blocking state; its revision changes whenever an overlay opens or closes. + */ + input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } { + return this.inputStates.get(parent) ?? unblockedInput + } + + /** + * @param parent - Product window whose content is blocked while the overlay is open. + * @param preload - Isolated shell-only preload. + * @param title - Localized window title. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. + * @returns A transparent child that follows its parent's bounds and visibility after loading and releases its listeners on close. + */ + create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { + const window = new BrowserWindow({ + parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, + ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, + skipTaskbar: true, hasShadow: false, title, + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) + const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } } + this.inputStates.set(parent, inputState) + inputState.active++; inputState.revision++ + window.once('closed', () => { inputState.active--; inputState.revision++ }) + // macOS native modals animate the entire viewport as a sheet. + const focus = (): void => { if (!window.isDestroyed()) window.focus() } + const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } + if (!nativeModal && process.platform === 'darwin') { + parent.on('focus', focus) + // Shell dialogs block input before product shortcut listeners can dispatch it. + parent.webContents.prependListener('before-input-event', blockInput) + window.once('closed', () => { + parent.off('focus', focus) + if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput) + }) + } + const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } + parent.on('move', follow) + parent.on('resize', follow) + let closed = false + let blur: string | undefined + const unblur = (): void => { + if (blur === undefined || parent.isDestroyed()) return + void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) }) + blur = undefined + } + void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => { + blur = key + if (closed) unblur() + }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) + window.once('closed', () => { closed = true; unblur() }) + window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) + let ready = false + const show = (): void => { + if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show() + } + parent.on('show', show) + window.once('closed', () => { parent.off('show', show) }) + window.once('ready-to-show', () => { ready = true; show() }) + window.setMenu(null) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + return window } - const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } - parent.on('move', follow) - parent.on('resize', follow) - let closed = false - let blur: string | undefined - const unblur = (): void => { - if (blur === undefined || parent.isDestroyed()) return - void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) }) - blur = undefined - } - void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => { - blur = key - if (closed) unblur() - }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) - window.once('closed', () => { closed = true; unblur() }) - window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) - window.setMenu(null) - window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - return window } diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index b74175fd52..f043547c8b 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -9,8 +9,11 @@ en: 94559f431b0b2151 zh: 1a22166a2bc59686 /desktop-local-update-verification/table-of-contents: - en: ee11b5279fb8c29b - zh: f9520fd7adfcd2fd + en: a5edee063916cd27 + zh: 7a5286350ff09e58 +/desktop-local-update-verification/native-overlay-visibility: + en: b81edb5b5540247f + zh: d824b176de12865e /desktop-local-update-verification/manual-walkthrough: en: c4b4ee8763d8464a zh: ee2159bc08966592 diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index a2684a2bce..6064f4d3b9 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -10,10 +10,24 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu ## Table of Contents +- [Native overlay visibility](#verification-overlay) - [Evidence](#verification-evidence) - [Manual walkthrough](#verification-interactive) - [Open verification](#verification-open) + + +## Native overlay visibility + +On macOS, reuse the cached Electron runtime after compiling the Desktop Host sources: + +```sh +pnpm exec tsc -b apps/desktop/tsconfig.host.json +apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs +``` + +The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, background blur, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow. + ## Manual walkthrough diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index fba5b65aa4..d72883d0fa 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -10,10 +10,24 @@ ## 目录 +- [原生浮层可见性](#verification-overlay) - [验证证据](#verification-evidence) - [手动演练](#verification-interactive) - [待验证事项](#verification-open) + + +## 原生浮层可见性 + +在 macOS 上,编译 Desktop Host 源码后复用缓存的 Electron 运行时: + +```sh +pnpm exec tsc -b apps/desktop/tsconfig.host.json +apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs +``` + +夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、背景模糊和监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。 + ## 手动演练 diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index e82dd5601b..5dde64c7d0 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -23,8 +23,13 @@ it('uses account Remote commands without returning additional wire fields', asyn requests.push(request) return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' }) }, () => Promise.resolve('')) - expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) - expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }]) + const client = { version: '1.2.3', locale: 'en', timezoneOffsetSeconds: 28_800 } + expect(await backend.start(client)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) + expect(await backend.signOut(client)).toMatchObject({ status: 'signed-out' }) + expect(requests).toEqual([ + { namespace: 'account', method: 'startSignIn', args: { client, callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }, + { namespace: 'account', method: 'signOut', args: { client } }, + ]) }) it('does not replay an expiry reason from an account snapshot', () => { diff --git a/apps/desktop/tests/client-metadata.spec.ts b/apps/desktop/tests/client-metadata.spec.ts new file mode 100644 index 0000000000..3f52557d03 --- /dev/null +++ b/apps/desktop/tests/client-metadata.spec.ts @@ -0,0 +1,18 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { desktopClientMetadata, desktopClientVersion } from '../src/client-metadata.ts' + +afterEach(() => { vi.unstubAllEnvs() }) + +it('reports the inlined client build version and the requested language', () => { + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') + expect(desktopClientVersion()).toBe('1.2.3') + expect(desktopClientMetadata('zh-CN')).toMatchObject({ version: '1.2.3', locale: 'zh-CN', + timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }) +}) + +it('refuses a build that carries no client version instead of guessing one', () => { + vi.stubEnv('DSH_CLIENT_VERSION', undefined) + expect(() => desktopClientVersion()).toThrow(/DSH_CLIENT_VERSION/) + vi.stubEnv('DSH_CLIENT_VERSION', '') + expect(() => desktopClientMetadata('en')).toThrow(/DSH_CLIENT_VERSION/) +}) diff --git a/apps/desktop/tests/desktop-bundle-imports.spec.ts b/apps/desktop/tests/desktop-bundle-imports.spec.ts new file mode 100644 index 0000000000..11c71c9662 --- /dev/null +++ b/apps/desktop/tests/desktop-bundle-imports.spec.ts @@ -0,0 +1,121 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Rolldown } from 'tsdown' +import { afterAll, describe, expect, it } from 'vitest' +import { + importPackageName, + isBareSpecifier, + packagedImportsPlugin, + unpackagedImports, + type BundleImportPolicy, +} from '../scripts/desktop-bundle-imports.mjs' + +const MAIN: BundleImportPolicy = { + packages: new Set(['electron', 'electron-updater', '@deepseek-ai/dsh-api-gateway']), + nodeBuiltins: true, +} +const PRELOAD: BundleImportPolicy = { packages: new Set(['electron', 'events', 'timers', 'url']), nodeBuiltins: false } + +describe('desktop bundle imports', () => { + it('names the package behind a bare specifier', () => { + expect(importPackageName('electron-updater/out/electronHttpExecutor.js')).toBe('electron-updater') + expect(importPackageName('@deepseek-ai/dsh-api-gateway/stream-protocol')).toBe('@deepseek-ai/dsh-api-gateway') + expect(importPackageName('ws')).toBe('ws') + }) + + it('tells external specifiers from bundled and virtual module ids', () => { + expect(isBareSpecifier('electron')).toBe(true) + expect(isBareSpecifier('@deepseek-ai/dsh-home-paths')).toBe(true) + expect(isBareSpecifier('node:fs')).toBe(true) + expect(isBareSpecifier('./helper.js')).toBe(false) + expect(isBareSpecifier(join(tmpdir(), 'lib', 'index.js'))).toBe(false) + expect(isBareSpecifier('\0rolldown/runtime')).toBe(false) + }) + + it('accepts Node builtins and packaged dependencies, including their subpaths, for the main process', () => { + expect(unpackagedImports([ + 'node:fs/promises', + 'crypto', + 'electron', + 'electron-updater/out/electronHttpExecutor.js', + '@deepseek-ai/dsh-api-gateway/stream-protocol', + ], MAIN)).toEqual([]) + }) + + it('rejects Node builtins outside the sandbox polyfill for a preload', () => { + expect(unpackagedImports(['electron', 'events', 'url', 'node:fs', 'crypto'], PRELOAD)).toEqual(['node:fs', 'crypto']) + }) + + it('reports each specifier the packaged application cannot resolve once, in import order', () => { + expect(unpackagedImports([ + '@deepseek-ai/dsh-home-paths', + 'ws', + '@deepseek-ai/dsh-home-paths', + 'electron', + ], MAIN)).toEqual(['@deepseek-ai/dsh-home-paths', 'ws']) + }) +}) + +describe('packaged imports plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-bundle-imports-')) + const entries = { + static: ['import { app } from "electron";', 'import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";', 'export const home = resolveDshHome(app);'], + dynamic: ['import { app } from "electron";', 'export const home = () => import("@deepseek-ai/dsh-home-paths").then(m => m.resolveDshHome(app));'], + require: ['import { app } from "electron";', 'export const home = () => require("@deepseek-ai/dsh-home-paths").resolveDshHome(app);'], + builtin: ['import { contextBridge } from "electron";', 'import { readFileSync } from "node:fs";', 'contextBridge.exposeInMainWorld("x", readFileSync);'], + } as const + for (const [name, lines] of Object.entries(entries)) writeFileSync(join(root, `${name}.js`), lines.join('\n')) + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function generate(entry: keyof typeof entries, policy: BundleImportPolicy, format: 'esm' | 'cjs' = 'esm'): Promise { + // Every import stays external, which is what an unresolved workspace lib/ output produces. + const bundle = await Rolldown.rolldown({ + input: join(root, `${entry}.js`), + platform: 'node', + external: () => true, + logLevel: 'silent', + plugins: [packagedImportsPlugin(policy)], + }) + try { + return await bundle.generate({ format }) + } finally { + await bundle.close() + } + } + + const unshipped = /imports @deepseek-ai\/dsh-home-paths, which the packaged application does not ship/u + + it('fails the bundle whose static import the packaged application does not ship', async () => { + await expect(generate('static', { ...MAIN, packages: new Set(['electron']) })).rejects.toThrow(unshipped) + }) + + it('fails the bundle whose dynamic import() the packaged application does not ship', async () => { + await expect(generate('dynamic', { ...MAIN, packages: new Set(['electron']) })).rejects.toThrow(unshipped) + }) + + it('fails the CommonJS bundle whose require() the packaged application does not ship', async () => { + await expect(generate('require', { ...MAIN, packages: new Set(['electron']) }, 'cjs')).rejects.toThrow(unshipped) + }) + + it('fails a preload bundle that requires a Node builtin the sandbox does not polyfill', async () => { + await expect(generate('builtin', PRELOAD, 'cjs')).rejects.toThrow( + /builtin\.js imports node:fs, which the packaged application does not ship\. /u, + ) + await expect(generate('builtin', PRELOAD, 'cjs')).rejects.toThrow( + /This bundle may leave only electron, events, timers, url as bare imports/u, + ) + }) + + it('passes the bundles whose external imports are all packaged', async () => { + const policy = { ...MAIN, packages: new Set(['electron', '@deepseek-ai/dsh-home-paths']) } + for (const entry of ['static', 'dynamic'] as const) { + const output = await generate(entry, policy) + expect(output.output.map(chunk => chunk.fileName)).toEqual([`${entry}.js`]) + } + const output = await generate('require', policy, 'cjs') + expect(output.output.map(chunk => chunk.fileName)).toEqual(['require.js']) + }) +}) diff --git a/apps/desktop/tests/expected/platform-storage.txt b/apps/desktop/tests/expected/platform-storage.txt new file mode 100644 index 0000000000..e94d9b35e1 --- /dev/null +++ b/apps/desktop/tests/expected/platform-storage.txt @@ -0,0 +1,8 @@ +first-open=visible +first-got-it=hidden +first-reopen=hidden +reopen-cookies=cleared +second-account=visible +back-to-first=hidden +signed-in-again=hidden +restart-first-open=hidden diff --git a/apps/desktop/tests/expected/update-overlay-visibility.json b/apps/desktop/tests/expected/update-overlay-visibility.json new file mode 100644 index 0000000000..6efbafdc4b --- /dev/null +++ b/apps/desktop/tests/expected/update-overlay-visibility.json @@ -0,0 +1,15 @@ +[ + { + "initiallyVisible": true, + "loaded": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "restored": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 } + }, + { + "initiallyVisible": false, + "loaded": { "parent": false, "overlay": false, "filter": "blur(2px)" }, + "restored": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 } + }, + { "parentDestroyed": true, "overlayDestroyed": true } +] diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs index 1c6ef7d104..f030078b75 100644 --- a/apps/desktop/tests/fixtures/local-updater.mjs +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -240,8 +240,9 @@ async function main() { const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 600_000, timeoutMs: 1000 }, true) let modal - const policy = new DesktopMandatoryUpdatePolicy(config, { platform: 'win32', arch: 'x64', - version: '1.0.0', bundledDshVersion: '1.0.0', bundleId: 'com.deepseek.dsh', locale: 'zh-CN' }, () => { modal?.sync() }) + const policy = new DesktopMandatoryUpdatePolicy(config, { platform: 'win32', arch: 'x64', bundledDshVersion: '1.0.0' }, + () => { modal?.sync() }, undefined, + () => ({ version: '1.0.0', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 })) modal = new DesktopMandatoryUpdateWindow({ preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)), locale: resolveDesktopLocale('zh-CN'), allowedPageOrigins: config.allowedPageOrigins, diff --git a/apps/desktop/tests/fixtures/platform-storage-smoke.mjs b/apps/desktop/tests/fixtures/platform-storage-smoke.mjs new file mode 100644 index 0000000000..5ce54af28f --- /dev/null +++ b/apps/desktop/tests/fixtures/platform-storage-smoke.mjs @@ -0,0 +1,126 @@ +/** Real Electron fixture for account-scoped Platform storage across process restarts. */ +import { app, BrowserWindow } from 'electron' +import { pathToFileURL } from 'node:url' + +const OBSERVE = `(() => { + const button = document.querySelector('button') + return button === null ? 'hidden' : 'visible' +})()` + +const bounds = { x: 0, y: 0, width: 800, height: 600 } + +/** + * Read the notice state from the embedded document. + * @param contents - embedded Platform page. + * @returns `visible` while the dismissal control is rendered, otherwise `hidden`. + */ +async function observe(contents) { + const value = await contents.executeJavaScript(OBSERVE, true) + if (value !== 'visible' && value !== 'hidden') throw new Error(`unexpected notice state ${String(value)}`) + return value +} + +/** + * @param owner - application window containing the embedded view. + * @returns the current embedded Platform view. + */ +function platformView(owner) { + const view = owner.contentView.children.find(child => typeof child.webContents?.executeJavaScript === 'function') + if (view === undefined) throw new Error('embedded Platform view is missing') + return view +} + +/** + * Click the notice's dismissal control and wait, bounded, until the notice leaves the DOM. + * @param contents - embedded Platform page. + */ +async function dismissNotice(contents) { + const clicked = await contents.executeJavaScript( + `(() => { const button = document.querySelector('button'); if (button === null) return false; button.click(); return true })()`, + true) + if (clicked !== true) throw new Error('notice dismissal control is missing') + if (await observe(contents) !== 'hidden') throw new Error('notice stayed visible after dismissal') +} + +async function run() { + const [viewModule, userData, origin, phase] = process.argv.slice(2) + if (viewModule === undefined || userData === undefined || origin === undefined || phase === undefined) { + throw new Error('expected ') + } + if (phase !== 'first' && phase !== 'restart') throw new Error(`unknown phase ${phase}`) + const base = new URL(origin).origin + app.setPath('userData', userData) + await app.whenReady() + const { DesktopPlatformView } = await import(pathToFileURL(viewModule).href) + const owner = new BrowserWindow({ show: false, width: 900, height: 700 }) + const manager = new DesktopPlatformView('', () => 'en_US', process.platform === 'win32' ? 'win32' : 'darwin') + const account = userId => ({ origin: base, token: 'fixture-secret', userId }) + const states = [] + const record = async name => { + states.push(`${name}=${await observe(platformView(owner).webContents)}`) + } + + manager.setSession(account('first')) + await manager.open(owner, 'usage', bounds) + await record(phase === 'first' ? 'first-open' : 'restart-first-open') + if (phase === 'restart') { + const cookies = await platformView(owner).webContents.executeJavaScript('document.cookie') + if (cookies !== '') throw new Error('previous-process authentication survived startup') + } + if (phase === 'first') { + await dismissNotice(platformView(owner).webContents) + await record('first-got-it') + await platformView(owner).webContents.executeJavaScript("document.cookie = 'fixture-auth=secret; SameSite=Lax'") + await platformView(owner).webContents.executeJavaScript("window.addEventListener('beforeunload', event => { event.preventDefault(); event.returnValue = false })") + await platformView(owner).webContents.executeJavaScript(`(async () => { + await (await caches.open('fixture-auth')).put('/secret', new Response('secret')) + await new Promise((resolve, reject) => { + const request = indexedDB.open('fixture-auth') + request.onsuccess = () => { request.result.close(); resolve() } + request.onerror = () => reject(request.error) + }) + })()`) + manager.close() + await manager.open(owner, 'usage', bounds) + await record('first-reopen') + const cookies = await platformView(owner).webContents.executeJavaScript('document.cookie') + if (cookies !== '') throw new Error('authentication cookies survived close') + states.push('reopen-cookies=cleared') + const stores = await platformView(owner).webContents.executeJavaScript('(async () => (await caches.keys()).length + (await indexedDB.databases()).length)()') + if (stores !== 0) throw new Error('nonpreference stores survived close') + manager.setSession(account('second')) + await manager.open(owner, 'usage', bounds) + await record('second-account') + manager.setSession(account('first')) + await manager.open(owner, 'usage', bounds) + await record('back-to-first') + manager.setSession(null) + manager.setSession({ ...account('first'), token: 'new-grant' }) + await manager.open(owner, 'usage', bounds) + await record('signed-in-again') + } + const persistedSession = platformView(owner).webContents.session + manager.setSession(account(null)) + await manager.open(owner, 'usage', bounds) + if (await observe(platformView(owner).webContents) !== 'visible') throw new Error('temporary session inherited preferences') + await dismissNotice(platformView(owner).webContents) + manager.close() + await manager.open(owner, 'usage', bounds) + if (await observe(platformView(owner).webContents) !== 'visible') throw new Error('temporary session retained preferences') + await manager.dispose() + if (phase === 'first') { + // Seed residue left by an interrupted process after normal cleanup has completed. + await persistedSession.cookies.set({ url: base, name: 'crash-auth', value: 'secret', expirationDate: Date.now() / 1000 + 3600 }) + await persistedSession.cookies.flushStore() + } + + const lines = `${states.map(state => `state ${state}`).join('\n')}\nPLATFORM_STORAGE_RESULT ${JSON.stringify(states)}\n` + await new Promise((resolve, reject) => process.stdout.write(lines, error => error ? reject(error) : resolve())) + owner.destroy() + app.quit() +} + +run().catch(error => { + console.error(`PLATFORM_STORAGE_ERROR ${error instanceof Error ? error.message : String(error)}`) + app.exit(1) +}) diff --git a/apps/desktop/tests/fixtures/update-overlay-visibility.mjs b/apps/desktop/tests/fixtures/update-overlay-visibility.mjs new file mode 100644 index 0000000000..4991e328a2 --- /dev/null +++ b/apps/desktop/tests/fixtures/update-overlay-visibility.mjs @@ -0,0 +1,95 @@ +/** Native overlay visibility qualification without a Host, network, or user profile. */ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdirSync, mkdtempSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, BrowserWindow } from 'electron' +import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js' + +const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url)) +mkdirSync(directory, { recursive: true }) +const root = mkdtempSync(join(directory, 'update-overlay-')) +mkdirSync(join(root, 'profile')) +app.setPath('userData', join(root, 'profile')) +app.on('window-all-closed', () => {}) + +async function main() { + const cases = [] + const windows = [] + const closeWindows = () => { + for (const window of [...windows].reverse()) if (!window.isDestroyed()) window.destroy() + } + let phase = 'application ready' + const deadline = setTimeout(() => { + console.error(`Update overlay qualification exceeded 30 seconds: ${phase}`) + closeWindows() + app.exit(1) + }, 30_000) + + async function qualify(initiallyVisible, closeParentFirst = false) { + const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 }) + windows.push(parent) + phase = `parent load (${initiallyVisible})` + await parent.loadURL('data:text/html,

Update overlay visibility

') + const initialShowListeners = parent.listenerCount('show') + const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Overlay qualification', false) + windows.push(overlay) + const ready = once(overlay, 'ready-to-show') + const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined + phase = `overlay load (${initiallyVisible})` + await overlay.loadURL('data:text/html,

Update confirmation

') + phase = `overlay ready (${initiallyVisible})` + await ready + await initiallyShown + // A renderer round trip observes the CSS request already queued by the overlay. + const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter') + const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() } + if (initiallyVisible) { + const hidden = once(parent, 'hide') + parent.hide() + await hidden + } + const shown = once(parent, 'show') + phase = `overlay restored (${initiallyVisible})` + parent.show() + await shown + const restoredFilter = await filter() + const restored = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: restoredFilter } + phase = `overlay release (${initiallyVisible})` + if (closeParentFirst) { + parent.destroy() + if (!overlay.isDestroyed()) overlay.destroy() + cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() }) + return + } + overlay.destroy() + const released = { filter: await filter(), showListenersRestored: parent.listenerCount('show') === initialShowListeners, + keyboardListeners: parent.webContents.listenerCount('before-input-event') } + parent.hide() + parent.show() + cases.push({ initiallyVisible, loaded, restored, released }) + parent.destroy() + } + + try { + await app.whenReady() + await qualify(true) + await qualify(false) + await qualify(true, true) + await writeFile(join(root, 'result.json'), `${JSON.stringify(cases, null, 2)}\n`) + const expected = JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8')) + assert.deepEqual(cases, expected) + console.log(`Update overlay qualification passed: ${root}`) + } catch (error) { + console.error(error) + process.exitCode = 1 + } finally { + clearTimeout(deadline) + closeWindows() + app.exit(process.exitCode ?? 0) + } +} + +void main().catch(error => { console.error(error); app.exit(1) }) diff --git a/apps/desktop/tests/flock-entry.spec.ts b/apps/desktop/tests/flock-entry.spec.ts new file mode 100644 index 0000000000..b7d8315f6a --- /dev/null +++ b/apps/desktop/tests/flock-entry.spec.ts @@ -0,0 +1,114 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, it, vi } from 'vitest' +import { createFlockEntryLoader } from '../scripts/flock-entry.ts' + +type Entry = Awaited>> + +const entry = { tryLockExclusive: vi.fn() } as Entry +const BUILD_TEXT = 'pnpm run build:native-system && pnpm --dir native/system run build:ts' + +function notFound(): NodeJS.ErrnoException { + return Object.assign(new Error("Cannot find module 'lib/flock.js'"), { code: 'ERR_MODULE_NOT_FOUND' }) +} + +function steps(overrides: Partial[0]> = {}) { + return { + importEntry: vi.fn(overrides.importEntry ?? (async () => entry)), + hostAddonBuilt: vi.fn(overrides.hostAddonBuilt ?? (() => true)), + build: vi.fn(overrides.build ?? (async () => {})), + } +} + +function quietStderr(): () => void { + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + return () => { spy.mockRestore() } +} + +it('returns the imported entry without building and memoizes it', async () => { + const used = steps() + const load = createFlockEntryLoader(used) + expect(await load()).toBe(entry) + expect(await load()).toBe(entry) + expect(used.importEntry).toHaveBeenCalledTimes(1) + expect(used.hostAddonBuilt).toHaveBeenCalledTimes(1) + expect(used.build).not.toHaveBeenCalled() +}) + +it('builds once and imports again when the JavaScript entry is missing', async () => { + const order: string[] = [] + const used = steps({ + importEntry: async () => { + order.push('import') + if (order.length === 1) throw notFound() + return entry + }, + build: async () => { order.push('build') }, + }) + const written: string[] = [] + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { written.push(String(chunk)); return true }) + try { + const load = createFlockEntryLoader(used) + expect(await load()).toBe(entry) + expect(await load()).toBe(entry) + } finally { stderr.mockRestore() } + expect(order).toEqual(['import', 'build', 'import']) + expect(written).toEqual([expect.stringContaining(BUILD_TEXT)]) +}) + +it('builds when the entry imports but the host addon binary is missing', async () => { + const used = steps({ hostAddonBuilt: () => false }) + const restore = quietStderr() + try { + expect(await createFlockEntryLoader(used)()).toBe(entry) + } finally { restore() } + expect(used.build).toHaveBeenCalledTimes(1) + expect(used.importEntry).toHaveBeenCalledTimes(2) +}) + +it('propagates other import errors without building', async () => { + const used = steps({ importEntry: async () => { throw new Error('addon binding failed') } }) + await expect(createFlockEntryLoader(used)()).rejects.toThrow('addon binding failed') + expect(used.build).not.toHaveBeenCalled() +}) + +it('propagates a build failure, does not import again, and retries on the next call', async () => { + const used = steps({ + importEntry: async () => { throw notFound() }, + build: async () => { throw new Error('tsc exited with 2') }, + }) + const restore = quietStderr() + try { + const load = createFlockEntryLoader(used) + await expect(load()).rejects.toThrow('tsc exited with 2') + expect(used.importEntry).toHaveBeenCalledTimes(1) + used.importEntry.mockResolvedValueOnce(entry) + expect(await load()).toBe(entry) + } finally { restore() } + expect(used.build).toHaveBeenCalledTimes(1) +}) + +it('loads the proxy module where the addon package cannot be resolved', async () => { + // A directory outside every node_modules tree stands in for a checkout whose native/system is unbuilt. + const root = await mkdtemp(join(tmpdir(), 'flock-entry-test-')) + try { + for (const name of ['macos-notarization-proxy.ts', 'flock-entry.ts']) { + await copyFile(new URL(`../scripts/${name}`, import.meta.url), join(root, name)) + } + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + const module = await import(${JSON.stringify(pathToFileURL(join(root, 'macos-notarization-proxy.ts')).href)}); + process.stdout.write(Object.keys(module).sort().join(',')); + `], { stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH } }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-8192) }) + const [code] = await once(child, 'close') as [number | null] + expect(code, stderr).toBe(0) + expect(stdout).toBe('restoreMacOSNotarizationProxy,withMacOSNotarizationProxy') + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index cb092742a7..ff28283bd6 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -197,13 +197,25 @@ describe('desktop host process', () => { }) }) -it('carries Platform credentials over private IPC and clears them on shutdown', async () => { - const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' } }); process.send({ type: 'ready'")) +it.each([null, 'stable-account'])('carries Platform identity %s over private IPC and clears credentials on shutdown', async (userId) => { + const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', userId: " + JSON.stringify(userId) + ", token: 'fixture-secret', embeddedPageDist: 'feat/test' } }); process.send({ type: 'ready'")) const changed = vi.fn() const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, undefined, changed) hosts.push(host) await host.start() - expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' }) + expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', userId, token: 'fixture-secret', embeddedPageDist: 'feat/test' }) await host.stop() expect(changed).toHaveBeenLastCalledWith(null) }) + +it.each([undefined, '', 7])('rejects malformed Platform account identity %s on private IPC', async (userId) => { + const session = { origin: 'https://platform.deepseek.com', token: 'fixture-secret', userId } + const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", + `process.send({ type: 'platform-session', session: ${JSON.stringify(session)} }); process.send({ type: 'ready'`)) + const changed = vi.fn() + const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, undefined, changed) + hosts.push(host) + await expect(host.start()).rejects.toThrow('invalid IPC event') + expect(changed.mock.calls).toEqual([[null]]) + await host.stop() +}) diff --git a/apps/desktop/tests/keybindings.spec.ts b/apps/desktop/tests/keybindings.spec.ts new file mode 100644 index 0000000000..d826a36151 --- /dev/null +++ b/apps/desktop/tests/keybindings.spec.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { desktopKeybindings } from '../src/keybindings.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '@deepseek-ai/dsh-client-shortcuts/protocol' + +it('persists into the supplied userData, reloads null bindings, and retains original future-version bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-keybindings-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + let state!: ShortcutConfigSnapshot + const first = desktopKeybindings(root, 'windows', (value) => { state = value }) + onTestFinished(() => { first.dispose() }) + const id = 'test.toggle' as ShortcutCommandId + const definitions = [{ id, defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const }, + } }] + first.setDefinitions(definitions) + await first.readCurrent() + expect((await first.edit({ type: 'set', id, binding: null }, state.revision)).status).toBe('saved') + const path = join(root, 'keybindings.json') + const saved: unknown = JSON.parse(await readFile(path, 'utf8')) + expect(saved).toMatchObject({ profiles: { 'desktop:windows': { [id]: null } } }) + const second = desktopKeybindings(root, 'windows', (value) => { state = value }) + onTestFinished(() => { second.dispose() }) + second.setDefinitions(definitions) + expect((await second.readCurrent()).document.profiles['desktop:windows']?.[id]).toBeNull() + const original = '{"schemaVersion":7,"custom":"retain exactly"}\n' + await writeFile(path, original) + expect((await second.readCurrent()).error).toBe('future') + expect((await second.edit({ type: 'reset-all' }, state.revision)).status).toBe('unreadable') + expect(await readFile(path, 'utf8')).toBe(original) +}) diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts new file mode 100644 index 0000000000..3d435e52f8 --- /dev/null +++ b/apps/desktop/tests/keyboard.spec.ts @@ -0,0 +1,826 @@ +// @vitest-environment jsdom +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import type { BrowserWindow, WebContents, WebFrameMain } from 'electron' +import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot, + ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts' +import { installKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/dom.ts' +import { installNativeKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/native.ts' +import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() })) +const overlays = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + class Window extends EventEmitter { + private destroyed = false + readonly webContents = { setWindowOpenHandler: vi.fn() } + readonly focus = vi.fn() + readonly show = vi.fn() + readonly setBounds = vi.fn() + setMenu() {} + isDestroyed() { return this.destroyed } + destroy() { this.destroyed = true; this.emit('closed') } + } + return { Window } +}) +vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({ + setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() }, +}) } })) +const { installDesktopShortcuts } = await import('../src/keyboard.ts') +const { DesktopBrowserGuests } = await import('../src/browser-guests.ts') +const { DesktopUpdateOverlays } = await import('../src/update-overlay.ts') +afterEach(() => { vi.clearAllMocks() }) + +type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null } +type ContentsFixture = EventEmitter & Pick & { + mainFrame: FrameFixture + focusedFrame: FrameFixture | null + } +type WindowFixture = EventEmitter & Pick & { + webContents: ContentsFixture +} +type InvokeFixture = { sender: object; senderFrame: object | null } +type KeyboardFixture = Omit, 'attach' | 'attachGuest'> & { + attach(window: WindowFixture): void + attachGuest(window: WindowFixture, guest: ContentsFixture, name: DesktopBrowserLeaseId): () => void +} +// Electron is substituted at module load; fixtures implement the native members exercised by this installer. +const installFixture = installDesktopShortcuts as ( + getWindow: () => WindowFixture | undefined, userData: string, + platform: Parameters[2], updateMenu: () => void, + overlayInput: (window: WindowFixture) => { readonly revision: number; readonly blocked: boolean }, +) => KeyboardFixture +type GuestsFixture = { + acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation + release(owner: ContentsFixture, id: unknown): Promise + bind(window: WindowFixture, attachInput: (guest: ContentsFixture, name: DesktopBrowserLeaseId) => () => void): void +} + +function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] { + return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding } +} + +function browserGuest(reservation: DesktopBrowserReservation) { + const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true), + setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(), + focus: vi.fn(), sendInputEvent: vi.fn() }) + onTestFinished(() => { guest.emit('destroyed') }) + return { frame, guest } +} + +async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') { + const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + const frame: FrameFixture = { url: 'dsh-app://app/', name: '', parent: null } + const contents = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, send: vi.fn(), + setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() }) + let current: WindowFixture | undefined = window + const updateMenu = vi.fn() + const updateOverlays = new DesktopUpdateOverlays() + const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow)) + keyboard.attach(current) + onTestFinished(() => { keyboard.dispose() }) + const handlers = new Map unknown>( + ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler])) + const event: InvokeFixture = { sender: contents, senderFrame: frame } + const call = async (channel: string, ...args: unknown[]): Promise => await handlers.get(channel)!(event, ...args) as T + const definitions: readonly ShortcutDefinition[] = [ + { id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) }, + ] + return { keyboard, window, updateOverlays, contents, frame, event, handlers, call, definitions, updateMenu, + detach: () => { current = undefined } } +} + +function updateOverlayFixture(f: Awaited>) { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + onTestFinished(() => { platform.mockRestore() }) + const parent: WindowFixture & Pick = Object.assign(f.window, { + getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }), + }) + Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }) + return () => { + const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false) + onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() }) + expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled() + return overlay + } +} + +it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => { + const f = await fixture() + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const press = (code: string) => f.contents.emit('before-input-event', { preventDefault: vi.fn() }, + { type: 'keyDown', modifiers: [], code, key: code.replace('Key', ''), meta: true, control: false, alt: false, shift: false }) + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, initial.revision)).status).toBe('stale') + await f.call(DESKTOP_IPC.shortcutsRecording, true) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + f.window.isFocused.mockReturnValue(false) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }, 'dsh-app://app/', false, true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('not-ready') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale') +}) + +it('blocks macOS shortcuts while an update overlay loads and clears held and consumed keys before dismissal', async () => { + const f = await fixture() + const open = updateOverlayFixture(f) + const snapshot = await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } }, + { id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) }, + ]) + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + const closeMenu = menu[0]!.click as () => void + const press = (code: string, type = 'keyDown', meta = false) => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } } + f.contents.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [], + meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + return event.defaultPrevented + } + f.contents.send.mockClear() + expect(press('KeyA')).toBe(false) + const first = open() + expect(press('KeyB')).toBe(true) + expect(press('KeyK', 'keyDown', true)).toBe(true) + closeMenu() + await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision) + f.keyboard.sendEditingKey('C', ['control']) + expect(f.window.close).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + first.destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(f.contents.send).not.toHaveBeenCalled() + expect(press('KeyA')).toBe(false) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code: 'KeyA', secondCode: 'KeyB' })) + const second = open() + expect(press('KeyB', 'keyUp')).toBe(true) + second.destroy() + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code: 'KeyK', meta: true })) + closeMenu() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'menu' })) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision) + expect(f.window.close).toHaveBeenCalledOnce() + f.keyboard.sendEditingKey('C', ['control']) + expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2) +}) + +it.each([false, true])('blocks approved browser guest input across update overlays, including guests attached while blocked: %s', async (attachWhileBlocked) => { + const f = await fixture() + const open = updateOverlayFixture(f) + await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } }, + { id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) }, + ]) + const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture + guests.bind(f.window, (guest, name) => f.keyboard.attachGuest(f.window, guest, name)) + const reservation = guests.acquire(f.contents, 'session:test') + const { frame, guest } = browserGuest(reservation) + const attach = () => { + const event = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', event, {}, { src: frame.url, partition: reservation.partition }) + expect(event.preventDefault).not.toHaveBeenCalled() + f.contents.emit('did-attach-webview', {}, guest) + guest.emit('dom-ready') + } + if (!attachWhileBlocked) attach() + const first = open() + if (attachWhileBlocked) attach() + const press = (code: string, type = 'keyDown', meta = false) => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } } + guest.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [], + meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + return event.defaultPrevented + } + f.contents.send.mockClear() + expect(press('KeyK', 'keyDown', true)).toBe(true) + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.sendInputEvent).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + const second = open() + first.destroy() + expect(press('KeyA')).toBe(true) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).not.toHaveBeenCalled() + second.destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyA')).toBe(false) + open().destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(f.contents.send).not.toHaveBeenCalled() + expect(press('KeyA')).toBe(false) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'webview', frameName: reservation.lease, code: 'KeyA', secondCode: 'KeyB' })) + open().destroy() + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.sendInputEvent).toHaveBeenCalledTimes(2) +}) + +it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => { + const f = await fixture() + const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)! + for (const event of [{ ...f.event, sender: {} }, { ...f.event, senderFrame: null }, { ...f.event, senderFrame: {} }]) { + await expect(get(event, f.definitions)).rejects.toThrow('rejected sender') + } + for (const url of ['dsh-app://shell/', 'https://example.com/', 'http://localhost/']) { + f.frame.url = url + await expect(get(f.event, f.definitions)).rejects.toThrow('unowned renderer') + } + f.frame.url = 'dsh-app://app/' + const ready = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all', path: '/tmp' }, ready.revision)).rejects.toThrow('Invalid') + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, 1)).rejects.toThrow('revision') + await expect(f.call(DESKTOP_IPC.shortcutsRecording, 'true')).rejects.toThrow('recording') + f.window.isDestroyed.mockReturnValue(true) + await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') + f.detach(); await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') +}) + +it('routes embedded input once, follows rebindings, and guards native window closure by revision and focus', async () => { + const f = await fixture() + const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]! + const initial = await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }, + { id: 'page.refresh', defaults: desktopDefaults({ code: 'KeyR', modifiers: ['primary'] }) }, + ]) + expect(closeItem().accelerator).toBe('Command+W') + const preventDefault = vi.fn() + const input = { modifiers: [], type: 'keyDown', code: 'KeyW', key: 'w', meta: true, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyW' })) + preventDefault.mockClear(); f.contents.send.mockClear() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', revision: initial.revision, frameName: 'embedded', code: 'KeyW' })) + f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' }) + expect(f.contents.send).toHaveBeenCalledTimes(1) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(closeItem().accelerator).toBe('Command+J') + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).not.toHaveBeenCalled() + f.contents.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyZ', key: 'j' }) + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsCloseWindow, initial.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(false) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(true) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).toHaveBeenCalledTimes(1) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'page.close', binding: null }, saved.snapshot.revision) + expect(closeItem().accelerator).toBeUndefined() + f.keyboard.dispose() + expect(f.contents.listenerCount('before-input-event')).toBe(0) +}) + +it.each([ + ['browser.new', 'KeyT'], ['session.new', 'KeyN'], +])('forwards Linux %s from embedded frames while leaving main-document input to the DOM', async (id, code) => { + const f = await fixture('linux') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id, defaults: desktopDefaults({ code, modifiers: ['primary'] }) }]) + const input = { modifiers: ['control'], type: 'keyDown', code, key: code.slice(3).toLowerCase(), + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + + Object.assign(f.contents, { focusedFrame: { name: 'browser', parent: f.frame } }) + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'iframe', frameName: 'browser', code, revision: initial.revision })) + f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' }) + expect(f.contents.send).toHaveBeenCalledOnce() + + const cleared = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id, binding: null }, initial.revision) + expect(cleared.status).toBe('saved') + preventDefault.mockClear(); f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each([ + ['ArrowUp', 'Command+Up'], ['ArrowDown', 'Command+Down'], + ['ArrowLeft', 'Command+Left'], ['ArrowRight', 'Command+Right'], +])('uses the Electron accelerator for a saved %s binding', async (code, accelerator) => { + const f = await fixture() + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code, modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + expect(menu[0]?.accelerator).toBe(accelerator) +}) + +it('refreshes the native menu only when its accelerator or availability changes', async () => { + const f = await fixture() + const close = { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) } + expect(f.updateMenu).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsGet, [close]) + expect(f.updateMenu).toHaveBeenCalledOnce() + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + const registered = await f.call(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions]) + const edited = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, registered.revision) + expect(edited.status).toBe('saved') + expect(f.updateMenu).toHaveBeenCalledOnce() + f.contents.send.mockClear() + const click = menu[0]!.click as () => void + click() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + { kind: 'menu', commandId: 'page.close', revision: edited.snapshot.revision }) + await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyQ', modifiers: ['primary'] } }, edited.snapshot.revision) + expect(f.updateMenu).toHaveBeenCalledTimes(2) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + expect(f.updateMenu).toHaveBeenCalledTimes(3) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + expect(f.updateMenu).toHaveBeenCalledTimes(3) +}) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings before both main and embedded frame handlers', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + const input = { modifiers: [], type: 'keyDown', code: 'KeyC', key: 'c', meta: platform === 'macos', + control: platform === 'windows', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + const press = (changes = {}) => f.contents.emit('before-input-event', { preventDefault }, { ...input, ...changes }) + f.contents.send.mockClear() + press() + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'keyboard', code: 'KeyC' })) + press({ type: 'keyUp' }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenCalledOnce() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + press() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', frameName: 'embedded' })) + await f.call(DESKTOP_IPC.shortcutsRecording, true) + preventDefault.mockClear(); f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsRecording, false) + press({ isComposing: true }) + press({ modifiers: ['altgr'] }) + press({ key: 'Dead' }) + press() + expect(preventDefault).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'session.new', binding: null }, saved.snapshot.revision) + f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) +}) + +it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and leaves their first key untouched', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + expect(menu[0]?.accelerator).toBeUndefined() + const preventDefault = vi.fn() + const press = (code: string, type = 'keyDown', isAutoRepeat = false) => { + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3), type, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat, isComposing: false }) + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear(); preventDefault.mockClear() + press('KeyA'); press('KeyA', 'keyUp'); press('KeyB'); press('KeyB', 'keyUp') + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyB') + expect(preventDefault).not.toHaveBeenCalled() + press('KeyA') + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyA', secondCode: 'KeyB', repeat: false })) + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + for (const reset of [() => { f.window.emit('blur') }, + () => { press('MetaLeft', 'keyUp') }, + () => { Object.assign(f.contents, { focusedFrame: f.frame }) }]) { + press('KeyA'); reset(); f.contents.send.mockClear(); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + f.contents.send.mockClear() + press('KeyC'); press('KeyA'); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each(['C', 'V', 'Z'])('delivers Windows Edit %s to the editor and physical input to its shortcut', async (keyCode) => { + const f = await fixture('windows') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: `Key${keyCode}`, modifiers: ['control'] } }, initial.revision) + const registry = new ShortcutRegistry('desktop', 'windows', saved.snapshot) + const run = vi.fn() + registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let nativeInput: (input: DesktopShortcutInput) => void = () => {} + onTestFinished(installNativeKeyboard(window, { + closeWindow: vi.fn(), subscribe: (listener) => { nativeInput = listener; return () => {} }, + }, registry, () => registry.config.getSnapshot())) + onTestFinished(installKeyboard(window, registry, undefined, true)) + f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => { + if (channel === DESKTOP_IPC.shortcutsInput) nativeInput(input) + }) + const editorInput: string[] = [] + const deliver = (type: 'keyDown' | 'keyUp'): void => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { type, code: `Key${keyCode}`, key: keyCode.toLowerCase(), + modifiers: ['control'], control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + if (!preventDefault.mock.calls.length) { + editorInput.push(type) + document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup', + { code: `Key${keyCode}`, key: keyCode.toLowerCase(), ctrlKey: true, bubbles: true, cancelable: true })) + } + } + f.contents.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { deliver(input.type) }) + f.contents.send.mockClear() + + f.keyboard.sendEditingKey(keyCode, ['control']) + + expect(f.contents.focus).toHaveBeenCalledOnce() + expect(editorInput).toEqual(['keyDown', 'keyUp']) + expect(f.contents.send).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent.mock.calls).toEqual([ + [{ type: 'keyDown', keyCode, modifiers: ['control'] }], + [{ type: 'keyUp', keyCode, modifiers: ['control'] }], + ]) + editorInput.length = 0 + deliver('keyDown') + deliver('keyUp') + expect(editorInput).toEqual([]) + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: `Key${keyCode}` })) + expect(run).toHaveBeenCalledOnce() +}) + +it('restores shortcut interception after native Edit delivery fails', async () => { + const f = await fixture('windows') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + f.contents.sendInputEvent.mockImplementationOnce(() => { throw new Error('editor unavailable') }) + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).toThrow('editor unavailable') + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: ['control'], + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + f.window.isDestroyed.mockReturnValue(true) + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow() + f.detach() + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow() + expect(f.contents.sendInputEvent).toHaveBeenCalledOnce() +}) + +it.each(['macos', 'windows'] as const)('intercepts %s standalone custom keys and clears held state when preferences change', async (platform) => { + const f = await fixture(platform) + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code, modifiers: [] } }, snapshot.revision) + expect(saved.status).toBe('saved'); snapshot = saved.snapshot + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code, key: code, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code, revision: snapshot.revision })) + } +}) + +it.each(['macos', 'windows'] as const)('keeps %s native interception identical for inherited, saved and reset bindings', async (platform) => { + const f = await fixture(platform) + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const operation of [null, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyB', modifiers: ['primary'] } }, + { type: 'reset', id: 'sidebar.left.toggle' }]) { + if (operation !== null) { + const result = await f.call(DESKTOP_IPC.shortcutsEdit, operation, snapshot.revision) + expect(result.status).toBe('saved'); snapshot = result.snapshot + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyB', revision: snapshot.revision })) + } + } +}) + +it('consumes a held single-key repeat after focus reset without treating it as a fresh press', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const input = { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: false, meta: true, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.emit('before-input-event', { preventDefault }, input) + f.window.emit('blur') + f.contents.send.mockClear(); preventDefault.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { ...input, isAutoRepeat: true }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ code: 'KeyB', repeat: true })) +}) + +it('delivers a fresh unbound key release after macOS omitted the previous Command character release', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const press = (code: string, type: string, meta: boolean): void => { + f.contents.emit('before-input-event', { preventDefault }, { type, code, key: code, modifiers: [], + control: false, meta, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + press('KeyB', 'keyDown', true) + expect(preventDefault).toHaveBeenCalledOnce() + press('MetaLeft', 'keyUp', false) + preventDefault.mockClear() + press('KeyB', 'keyDown', false); press('KeyB', 'keyUp', false) + expect(preventDefault).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('releases %s native input when a saved binding overlaps a mounted fixed action', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'Escape', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + await f.call(DESKTOP_IPC.shortcutsGet, [...f.definitions, + { id: 'menu.dismiss', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'Escape', key: 'Escape', modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('keeps %s chord presses singular across native interception and renderer key delivery', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const registry = new ShortcutRegistry('desktop', platform, saved.snapshot) + const run = vi.fn() + registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + onTestFinished(installNativeKeyboard(window, { closeWindow: vi.fn(), subscribe: (listener) => { deliver = listener; return () => {} } }, + registry, () => registry.config.getSnapshot())) + onTestFinished(installKeyboard(window, registry, undefined, true)) + f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => { + if (channel === DESKTOP_IPC.shortcutsInput) deliver(input) + }) + const rendererReleases: string[] = [] + const press = (code: string, type: 'keyDown' | 'keyUp', repeat = false): void => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + // Electron only delivers input to the document when the main process did not intercept it. + if (!preventDefault.mock.calls.length) { + if (type === 'keyUp') rendererReleases.push(code) + document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup', + { code, key: code.slice(3).toLowerCase(), repeat, bubbles: true, cancelable: true })) + } + } + for (const [index, [first, second]] of ([['KeyF', 'KeyG'], ['KeyG', 'KeyF']] as const).entries()) { + press(first, 'keyDown'); press(first, 'keyUp'); press(second, 'keyDown'); press(second, 'keyUp') + expect(run).toHaveBeenCalledTimes(index) + press(first, 'keyDown'); press(second, 'keyDown'); press(second, 'keyDown', true); press(first, 'keyDown', true) + expect(run).toHaveBeenCalledTimes(index + 1) + if (index === 0) { press(first, 'keyUp'); press(second, 'keyUp') } + else { press(second, 'keyUp'); press(first, 'keyUp') } + press(second, 'keyDown'); press(second, 'keyUp'); press(first, 'keyDown'); press(first, 'keyUp') + expect(run).toHaveBeenCalledTimes(index + 1) + } + expect(rendererReleases.filter(code => code === 'KeyF')).toHaveLength(5) + expect(rendererReleases.filter(code => code === 'KeyG')).toHaveLength(5) +}) + +it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Electron omits intercepted key releases', async (platform) => { + const f = await fixture(platform) + const bindings = [ + { id: 'sidebar.left.toggle', binding: { code: 'KeyJ', secondCode: 'KeyK', modifiers: [] } }, + { id: 'sidebar.right.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, + { id: 'shortcuts.open', binding: { code: 'KeyV', modifiers: [] } }, + ] + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + bindings.map(({ id }) => ({ id, defaults: {} }))) + for (const binding of bindings) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', ...binding }, snapshot.revision) + expect(saved.status).toBe('saved') + snapshot = saved.snapshot + } + const press = (code: string, type = 'keyDown', repeat = false): boolean => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + return preventDefault.mock.calls.length > 0 + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear() + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + // Electron on macOS can omit both releases after intercepting the second keydown. + for (const code of ['KeyJ', 'KeyK']) { + expect(press(code)).toBe(false) + expect(press(code, 'keyUp')).toBe(false) + } + expect(f.contents.send).toHaveBeenCalledOnce() + expect(press('KeyK')).toBe(false) + expect(press('KeyJ')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyJ', 'keyDown', true)).toBe(true) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyF')).toBe(false) + expect(press('KeyG')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(3) + expect(press('KeyV')).toBe(true) + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(5) + } +}) + +it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser guest input to its owner and stops delivery before guest destruction', async (platform) => { + const f = await fixture(platform) + const snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'browser.new', defaults: desktopDefaults({ code: 'KeyT', modifiers: ['primary'] }) }]) + const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture + const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name)) + guests.bind(f.window, attach) + const reservation = guests.acquire(f.contents, 'session:test') + const { frame, guest } = browserGuest(reservation) + const rejected = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition }) + expect(rejected.preventDefault).toHaveBeenCalledOnce() + const invalid = Object.assign(new EventEmitter(), { getURL: () => 'about:blank#unknown', setWindowOpenHandler: vi.fn(), close: vi.fn() }) + f.contents.emit('did-attach-webview', {}, invalid) + invalid.emit('dom-ready') + expect(invalid.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(attach).not.toHaveBeenCalled() + + const approved = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', approved, {}, { src: frame.url, partition: reservation.partition }) + expect(approved.preventDefault).not.toHaveBeenCalled() + f.contents.emit('did-attach-webview', {}, guest) + guest.emit('dom-ready') + expect(attach).toHaveBeenCalledExactlyOnceWith(guest, reservation.lease) + const input = { modifiers: [], type: 'keyDown', code: 'KeyT', key: 't', meta: platform === 'macos', + control: platform !== 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + f.contents.send.mockClear() + guest.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, { + kind: 'webview', frameName: reservation.lease, revision: snapshot.revision, code: 'KeyT', + meta: platform === 'macos', control: platform !== 'macos', alt: false, shift: false, repeat: false, + }) + guest.isFocused.mockReturnValue(false) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.isFocused.mockReturnValue(true) + const release = guests.release(f.contents, reservation.lease) + expect(guest.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(guest.listenerCount('before-input-event')).toBe(0) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.emit('destroyed') + await release +}) + +it('keeps browser guest chord state local and leaves accepted bindings active through guest navigation', async () => { + const f = await fixture('macos') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(), + send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const blurListeners = f.window.listenerCount('blur') + const closedListeners = f.window.listenerCount('closed') + const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + expect(f.window.listenerCount('blur')).toBe(blurListeners) + expect(f.window.listenerCount('closed')).toBe(closedListeners) + const press = (contents: EventEmitter, code: string): void => { + contents.emit('before-input-event', { preventDefault: vi.fn() }, { modifiers: [], type: 'keyDown', code, key: code, + meta: false, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + f.contents.send.mockClear() + press(guest, 'KeyF') + guest.emit('blur') + press(guest, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + f.window.emit('blur') + press(guest, 'KeyF') + expect(f.contents.send).not.toHaveBeenCalled() + guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + press(guest, 'KeyF') + press(f.contents, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + press(guest, 'KeyG') + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'webview', frameName: 'guest', code: 'KeyF', secondCode: 'KeyG' })) + guest.emit('destroyed') + press(f.contents, 'KeyF') + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyF', secondCode: 'KeyG' })) +}) + +it('delivers Windows Edit actions to the focused browser guest without invoking its custom shortcut', async () => { + const f = await fixture('windows') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyC', modifiers: ['control'] } }, initial.revision) + const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(), + send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + const events: string[] = [] + guest.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { + const preventDefault = vi.fn() + guest.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyC', key: 'c', modifiers: ['control'], + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + if (!preventDefault.mock.calls.length) events.push(input.type) + }) + f.contents.send.mockClear() + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.focus).toHaveBeenCalledOnce() + expect(events).toEqual(['keyDown', 'keyUp']) + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent).not.toHaveBeenCalled() + dispose() + f.keyboard.sendEditingKey('C', ['control']) + expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 9cd00f74c4..84b042e7f8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,6 +1,6 @@ import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' @@ -51,6 +51,12 @@ const harness = await vi.hoisted(async () => { let embeddedPolicy: unknown let closeWindowsOnQuit = false let updateState: DesktopUpdateState = { phase: 'idle' } + let platformDisposeDeferred: ReturnType | undefined + // The native Platform view owns persistent browser storage; Desktop startup tests replace it so + // each quit can control when that cleanup settles. + const platformDispose = vi.fn(() => platformDisposeDeferred?.promise ?? Promise.resolve()) + let platformCloseDeferred: ReturnType | undefined + const platformCloseAndWait = vi.fn(() => platformCloseDeferred?.promise ?? Promise.resolve()) const updateCheck = vi.fn(async (_manual?: boolean): Promise => updateState) const updateDownload = vi.fn(async (_version: string): Promise => updateState) const updateInstall = vi.fn(async (_version: string): Promise => updateState) @@ -69,6 +75,8 @@ const harness = await vi.hoisted(async () => { getURL: () => this.urls.at(-1) ?? '', mainFrame: { url: '' }, getZoomFactor: () => 1, + isDestroyed: () => this.destroyed, + setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn(), send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => { @@ -80,6 +88,8 @@ const harness = await vi.hoisted(async () => { readonly focus = vi.fn() readonly restore = vi.fn() readonly setSize = vi.fn() + readonly getBounds = vi.fn(() => ({ x: 0, y: 0, width: 800, height: 700 })) + readonly setMinimumSize = vi.fn() readonly setTitleBarOverlay = vi.fn() readonly setVibrancy = vi.fn() readonly setBackgroundColor = vi.fn() @@ -140,7 +150,7 @@ const harness = await vi.hoisted(async () => { getVersion: () => '1.0.0', getAppPath: (): string => 'desktop-test-app', setAppLogsPath: vi.fn(), - getPath: (name: string): string => `desktop-test-${name}`, + getPath: vi.fn<(name: string) => string>(), setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(), requestSingleInstanceLock: () => true, setAsDefaultProtocolClient: vi.fn(), @@ -161,6 +171,8 @@ const harness = await vi.hoisted(async () => { failWindow(error: Error) { windowFailure = error }, windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, + platformDispose, + platformCloseAndWait, watchAccount: (listener: (state: AccountView) => void) => { accountListener = listener @@ -190,6 +202,8 @@ const harness = await vi.hoisted(async () => { set embeddedPolicy(value: unknown) { embeddedPolicy = value }, nextNavigation() { navigated = deferred(); return navigated.promise }, nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + deferPlatformDispose() { platformDisposeDeferred = deferred(); return platformDisposeDeferred }, + deferPlatformClose() { platformCloseDeferred = deferred(); return platformCloseDeferred }, get pluginsEnabled() { return pluginsEnabled }, set pluginsEnabled(value: boolean) { pluginsEnabled = value }, set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value }, @@ -212,6 +226,8 @@ const harness = await vi.hoisted(async () => { navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred() policyBlocked = deferred() embeddedPolicy = undefined + platformDisposeDeferred = undefined + platformCloseDeferred = undefined }, } }) @@ -288,6 +304,16 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class readonly install = harness.updateInstall readonly dispose = vi.fn() } })) +vi.mock('../src/platform-view.ts', async importOriginal => ({ + ...await importOriginal(), + DesktopPlatformView: class { + setSession() {} + setBounds() {} + close() {} + closeAndWait(): Promise { return harness.platformCloseAndWait() } + dispose(): Promise { return harness.platformDispose() } + }, +})) vi.mock('../src/welcome-backend.ts', () => ({ connectDesktopWelcome: async () => ({ readLocalePreference: async () => null, @@ -324,6 +350,9 @@ beforeEach(() => { testAuth.login.mockResolvedValue('cancelled') vi.useFakeTimers() harness.reset() + const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-')) + onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) }) + harness.app.getPath.mockImplementation(name => name === 'userData' ? userData : `desktop-test-${name}`) harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => { if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 }) harness.dialogShown.resolve() @@ -339,6 +368,7 @@ beforeEach(() => { vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') }) afterEach(async () => { @@ -782,7 +812,7 @@ describe('desktop main startup', () => { await edit }) - it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => { + it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await import('../src/main.ts') await harness.preparing.promise @@ -793,7 +823,7 @@ describe('desktop main startup', () => { .find(items => items.some(item => item.role === 'editMenu')) if (template === undefined) throw new Error('application menu missing') expect(template.map(describeItem)).toEqual(platform === 'darwin' - ? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu'] + ? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu'] : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin' @@ -844,6 +874,39 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('shares login key discovery with onboarding and rejects foreign renderers', async () => { + await readyForUpdate() + const window = harness.windows[0]! + const handler = harness.handlers.get(DESKTOP_IPC.onboardingApiKey)! + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + await vi.waitFor(async () => { expect(await handler(event)).toBe(true) }) + harness.hosts.at(-1)!.fetch.mockResolvedValueOnce(Response.json({ hasApiKey: false })) + await expect(handler(event)).resolves.toBe(false) + await expect(handler({ ...event, sender: {} })).rejects.toThrow() + }) + + it('enlarges only an active onboarding window and keeps its size after completion', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.onboardingActive)![1] + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + expect(window.setMinimumSize).not.toHaveBeenCalled() + listener({ ...event, sender: {} }, true) + listener(event, 'true') + expect(window.setMinimumSize).not.toHaveBeenCalled() + listener(event, true) + expect(window.setMinimumSize).toHaveBeenLastCalledWith(960, 600) + expect(window.setSize).toHaveBeenLastCalledWith(960, 700) + window.setSize.mockClear() + window.getBounds.mockReturnValue({ x: 0, y: 0, width: 1200, height: 800 }) + listener(event, true) + expect(window.setSize).not.toHaveBeenCalled() + listener(event, false) + expect(window.setMinimumSize).toHaveBeenLastCalledWith(520, 600) + expect(window.setSize).not.toHaveBeenCalled() + }) + it('registers the window-owned directory picker during startup and rejects foreign callers', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -947,6 +1010,33 @@ describe('desktop main startup', () => { await harness.quitCompleted.promise }) + it('waits for Platform view storage cleanup before an ordinary quit completes', async () => { + const host = await readyForUpdate() + const disposal = harness.deferPlatformDispose() + harness.app.quit() + expect(harness.platformDispose).toHaveBeenCalledOnce() + await host.stopping.promise + host.exited.resolve() + expect(harness.app.quit).toHaveBeenCalledOnce() + disposal.resolve() + await harness.quitCompleted.promise + expect(harness.app.quit).toHaveBeenCalledTimes(2) + }) + + it('reports a Platform cleanup failure without ending the Host shutdown early', async () => { + const host = await readyForUpdate() + const disposal = harness.deferPlatformDispose() + const failure = new Error('platform storage cleanup failed') + harness.app.quit() + await host.stopping.promise + disposal.reject(failure) + await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith(failure) }) + expect(harness.app.quit).toHaveBeenCalledOnce() + host.exited.resolve() + await harness.quitCompleted.promise + expect(harness.app.quit).toHaveBeenCalledTimes(2) + }) + it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => { const host = await readyForUpdate() const window = harness.windows[0]! @@ -1034,7 +1124,11 @@ describe('desktop main startup', () => { expect(modal.isDestroyed()).toBe(false) expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }]) expect(host.stop).not.toHaveBeenCalled() - expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) + expect(request.mock.calls[0]![1]!.headers).toMatchObject({ + 'x-client-bundle-id': '', 'x-client-platform': 'desktop-win', 'x-client-version': '1.2.3', + 'x-client-arch': 'x64', 'x-client-update-channel': 'nightly', 'x-client-bundled-dsh-version': '1.0.0', + 'x-client-locale': 'en_US', 'x-client-timezone-offset': String(-new Date().getTimezoneOffset() * 60), + }) }) it('keeps one checking dialog open until the manual check settles, then reports the current version', async () => { @@ -1247,6 +1341,33 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) + it('waits for Platform storage cleanup before the installer takes over', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const cleanup = harness.deferPlatformClose() + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(harness.platformCloseAndWait).toHaveBeenCalledOnce() }) + expect(host.updateTasks).toHaveBeenLastCalledWith('lock') + expect(host.stop).not.toHaveBeenCalled() + cleanup.resolve() + await host.stopping.promise + expect(host.stop).toHaveBeenCalledWith(true) + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + }) + + it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const cleanup = harness.deferPlatformClose() + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(harness.platformCloseAndWait).toHaveBeenCalledOnce() }) + cleanup.reject(new Error('platform storage cleanup failed')) + await expect(preparing).rejects.toThrow('platform storage cleanup failed') + expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']]) + expect(host.stop).not.toHaveBeenCalled() + }) + async function answerMandatory(action: 'install' | 'later') { const modal = harness.windows[0]! const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } @@ -1271,10 +1392,14 @@ describe('desktop main startup', () => { host.exited.resolve() await expect(preparing).resolves.toBe(true) harness.closeWindowsOnQuit = true + const disposal = harness.deferPlatformDispose() harness.app.quit() await harness.quitCompleted.promise expect(modal.isDestroyed()).toBe(true) expect(harness.app.quit).toHaveBeenCalledOnce() + // The installer owns the exit, so Platform cleanup starts without holding the quit open. + expect(harness.platformDispose).toHaveBeenCalledOnce() + disposal.resolve() }) it.each([false, true])('restores a cleanly stopped Host after installer failure and retains mandatory blocking: %s', async (mandatory) => { diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index 3bc2f93693..84a584389b 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types' import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts' -const identity = { platform: 'win32', arch: 'x64', version: '0.1.5-rc.1', bundledDshVersion: '0.1.5-rc.1', - bundleId: 'com.deepseek.dsh', locale: 'zh-CN' } as const +const identity = { platform: 'win32', arch: 'x64', bundledDshVersion: '0.1.5-rc.1' } as const +const client: AccountClientMetadata = { version: '1.2.3', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } const force = { code: 40005, data: { show_content: { title: 'Update', detail: 'Required upgrade' }, desktop_app_link: 'https://downloads.example.com/desktop?os=win' } } const clear = { code: 0, msg: '', data: { biz_code: 0, biz_msg: '', biz_data: null } } @@ -10,11 +11,12 @@ const deployment = { origin: 'https://policy.example.com', allowedPageOrigins: [ intervalMs: 10_000, timeoutMs: 1_000, maxBackoffMs: 80_000, jitter: 0 } const instances: DesktopMandatoryUpdatePolicy[] = [] -function fixture(authentication: 'anonymous' | 'feishu-test' = 'anonymous') { +function fixture(authentication: 'anonymous' | 'feishu-test' = 'anonymous', + clientSource: () => AccountClientMetadata = () => client) { const request = vi.fn().mockResolvedValue(Response.json(clear)) const publish = vi.fn<(state: DesktopPolicyState) => void>() const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication, ...(authentication === 'feishu-test' ? { allowedAuthOrigins: ['https://login.example.com'] } : {}) })!, - identity, publish, request) + identity, publish, request, clientSource) instances.push(policy) return { policy, request, publish } } @@ -58,12 +60,23 @@ describe('mandatory update policy', () => { expect(url).toBeInstanceOf(URL) expect((url as URL).href).toBe('https://policy.example.com/api/v0/check_client_update?scenario=launch') expect(options).toMatchObject({ credentials: 'omit', cache: 'no-store', redirect: 'error', headers: { - 'x-client-platform': 'desktop-win', 'x-client-version': '0.1.5-rc.1', 'x-client-bundle-id': 'com.deepseek.dsh', - 'x-client-locale': 'zh-CN', 'x-client-arch': 'x64', 'x-client-update-channel': 'nightly', + 'x-client-platform': 'desktop-win', 'x-client-version': '1.2.3', 'x-client-bundle-id': '', + 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '28800', 'x-client-arch': 'x64', 'x-client-update-channel': 'nightly', 'x-client-bundled-dsh-version': '0.1.5-rc.1', } }) }) + it('sends the language and UTC offset sampled for each check', async () => { + let current = { ...client, locale: 'en', timezoneOffsetSeconds: -18_000 } + const { policy, request } = fixture('anonymous', () => current) + await policy.check('launch') + current = { ...client, locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } + await policy.check('manual', true) + const headers = request.mock.calls.map(call => (call[1]?.headers ?? {}) as Record) + expect(headers[0]).toMatchObject({ 'x-client-locale': 'en_US', 'x-client-timezone-offset': '-18000' }) + expect(headers[1]).toMatchObject({ 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '28800' }) + }) + it('coalesces manual checks and honors the interval on foreground/resume', async () => { const { policy, request } = fixture() const pending = Promise.withResolvers() diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index a1c218852a..484d01065f 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, expect, it, vi } from 'vitest' -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebContents, WebFrameMain } from 'electron' import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts' import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' import { resolveDesktopLocale } from '../src/locale.ts' @@ -13,7 +13,11 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler), removeHandler: (channel: string) => native.handlers.delete(channel), }, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } })) -vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) })) + +type WindowFixture = EventEmitter & Pick & { + webContents: EventEmitter & Pick & { mainFrame: Pick } + } let window: ReturnType let ui: DesktopMandatoryUpdateWindow | undefined @@ -32,8 +36,10 @@ function setup(platform: NodeJS.Platform = 'darwin') { let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' } const install = vi.fn(async () => update) - ui = new DesktopMandatoryUpdateWindow({ preload: 'owned', locale: resolveDesktopLocale('zh'), - allowedPageOrigins: ['https://downloads.example.com'], parent: () => window as unknown as BrowserWindow, + const createOverlay = (): WindowFixture => window + const parent = createOverlay as () => BrowserWindow + ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'), + allowedPageOrigins: ['https://downloads.example.com'], parent, policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install }) ui.sync() const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } diff --git a/apps/desktop/tests/platform-storage.e2e.ts b/apps/desktop/tests/platform-storage.e2e.ts new file mode 100644 index 0000000000..7be6930db5 --- /dev/null +++ b/apps/desktop/tests/platform-storage.e2e.ts @@ -0,0 +1,79 @@ +/** + * Real Chromium storage survives native view and Electron process lifetimes. + * Built-artifact regression: run after `pnpm run build:lib:host`, which emits + * apps/desktop/lib/types/platform-view.js; the fixture imports that artifact and resolves its + * workspace dependencies from this checkout's node_modules. + */ +import { createServer } from 'node:http' +import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { execa } from 'execa' +import { expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const repository = fileURLToPath(new URL('../../../', import.meta.url)) +const builtView = join(repository, 'apps/desktop/lib/types/platform-view.js') +const hasDisplay = process.platform !== 'linux' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) + +// Linux Electron needs a display server; the test runs under xvfb in headless environments. +it.skipIf(!hasDisplay)('retains dismissed notices across view and process restarts without sharing accounts', { retry: 0 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-platform-storage-')) + const server = createServer((_request, response) => { + response.setHeader('content-type', 'text/html') + response.end(``) + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { server.removeListener('error', reject); resolve() }) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('missing Platform fixture listener') + const origin = `http://127.0.0.1:${String(address.port)}` + if (!existsSync(builtView)) throw new Error(`missing built Platform view ${builtView}; run pnpm run build:lib:host`) + const userData = join(root, 'browser') + await mkdir(userData) + const electron: unknown = require('electron') + if (typeof electron !== 'string') throw new Error('Electron executable is unavailable') + const fixture = fileURLToPath(new URL('./fixtures/platform-storage-smoke.mjs', import.meta.url)) + const observed: string[] = [] + for (const phase of ['first', 'restart']) { + const result = await execa(electron, [fixture, builtView, userData, origin, phase], { + // The unbundled tsc artifact reads the client version from the environment; the packaged + // build inlines the same value at bundle time. + env: { ELECTRON_RUN_AS_NODE: undefined, DSH_CLIENT_VERSION: '1.2.3' }, + timeout: 45_000, forceKillAfterDelay: 5_000, reject: false, + }) + expect(result.timedOut, result.stderr).toBe(false) + expect(result.signal, result.stderr).toBeUndefined() + expect(result.exitCode, result.stderr).toBe(0) + const line = result.stdout.split('\n').find(value => value.startsWith('PLATFORM_STORAGE_RESULT ')) + if (line === undefined) throw new Error(`Missing Electron result: ${result.stdout}\n${result.stderr}`) + const states: unknown = JSON.parse(line.slice('PLATFORM_STORAGE_RESULT '.length)) + if (!Array.isArray(states) || !states.every((value): value is string => typeof value === 'string')) { + throw new Error('Invalid Electron storage result') + } + observed.push(...states) + } + expect(observed.join('\n') + '\n').toBe(await readFile(new URL('./expected/platform-storage.txt', import.meta.url), 'utf8')) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { if (error) reject(error); else resolve() }) + server.closeAllConnections() + }) + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/platform-view.spec.ts b/apps/desktop/tests/platform-view.spec.ts index 5ecd1bf44d..f62987d0e5 100644 --- a/apps/desktop/tests/platform-view.spec.ts +++ b/apps/desktop/tests/platform-view.spec.ts @@ -1,17 +1,24 @@ import { EventEmitter } from 'node:events' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' import { DesktopPlatformView, platformBounds } from '../src/platform-view.ts' const state = vi.hoisted(() => ({ - views: [] as unknown[], sessions: [] as unknown[], openExternal: vi.fn(async () => {}), loadFailure: undefined as Error | undefined, + views: [] as unknown[], sessions: [] as unknown[], openExternal: vi.fn(async () => {}), + loadFailure: undefined as Error | undefined, loadBarrier: undefined as Promise | undefined, })) vi.mock('electron', () => ({ shell: { openExternal: state.openExternal }, session: { fromPartition: vi.fn((partition: string) => { + const existing = state.sessions.find(value => (value as { partition: string }).partition === partition) + if (existing !== undefined) return existing const value = { partition, webRequest: { onBeforeSendHeaders: vi.fn(), onCompleted: vi.fn(), onErrorOccurred: vi.fn() }, setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), - clearStorageData: vi.fn(async () => {}), + clearStorageData: vi.fn(async (_options?: { storages: string[] }) => {}), + clearCache: vi.fn(async () => {}), flushStorageData: vi.fn(), + clearAuthCache: vi.fn(async () => {}), closeAllConnections: vi.fn(async () => {}), + isPersistent: () => partition.startsWith('persist:'), } state.sessions.push(value) return value @@ -19,27 +26,46 @@ vi.mock('electron', () => ({ WebContentsView: class { webContents = Object.assign(new EventEmitter(), { mainFrame: { url: 'https://platform.deepseek.com/usage' }, - session: { clearStorageData: vi.fn(async () => {}) }, + session: undefined as object | undefined, setWindowOpenHandler: vi.fn(), - loadURL: vi.fn(async (_url: string) => { if (state.loadFailure !== undefined) throw state.loadFailure }), - isDestroyed: () => false, close: vi.fn(), send: vi.fn(), + loadURL: vi.fn(async (_url: string) => { await state.loadBarrier; if (state.loadFailure !== undefined) throw state.loadFailure }), + isDestroyed: () => false, close: vi.fn(() => { this.webContents.emit('destroyed') }), send: vi.fn(), }) setVisible = vi.fn() setBounds = vi.fn() - constructor() { state.views.push(this) } + constructor(options: { webPreferences: { session: object } }) { + this.webContents.session = options.webPreferences.session + state.views.push(this) + } }, })) -afterEach(() => { state.views.length = 0; state.sessions.length = 0; state.loadFailure = undefined; vi.clearAllMocks() }) +beforeEach(() => { vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') }) +afterEach(() => { + state.views.length = 0; state.sessions.length = 0; state.loadFailure = undefined; state.loadBarrier = undefined + vi.clearAllMocks(); vi.unstubAllEnvs() +}) function setup() { const removeChildView = vi.fn() const owner = Object.assign(new EventEmitter(), { webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView }, isDestroyed: () => false, }) - const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US') - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' }) + const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US', 'darwin') + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' }) return { manager, owner, removeChildView } } + +/** Invoke the first registered header interceptor for one request. */ +function interceptHeaders(id: number, url: string, requestHeaders: Record): Record { + const browserSession = state.sessions.at(-1) as { webRequest: { onBeforeSendHeaders: ReturnType } } + const handler = browserSession.webRequest.onBeforeSendHeaders.mock.calls[0]![0] as ( + details: { id: number; url: string; requestHeaders: Record }, + callback: (value: { requestHeaders: Record }) => void, + ) => void + const callback = vi.fn() + handler({ id, url, requestHeaders }, callback) + return (callback.mock.calls[0]![0] as { requestHeaders: Record }).requestHeaders +} function view() { return state.views.at(-1) as { setVisible: ReturnType @@ -71,7 +97,7 @@ it('destroys old documents on sign-out or credential replacement', async () => { const { manager, owner } = setup() await manager.open(owner, 'usage', bounds) const first = view().webContents - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'replacement' }) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'replacement' }) expect(first.close).toHaveBeenCalledOnce() expect(() => manager.bootstrap({ sender: first, senderFrame: first.mainFrame })).toThrow() await manager.open(owner, 'top-up', bounds) @@ -107,11 +133,47 @@ it('blocks cross-origin navigation and redirects', async () => { manager.close() }) +it('injects the deployment and client identity headers only at the Platform origin', async () => { + const { manager, owner } = setup() + manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret', + requestHeaders: { cookie: 'gate=synthetic', 'x-deployment': 'harness' } }) + await manager.open(owner, 'usage', bounds) + const request = { Cookie: 'route=user', 'x-client-platform': 'stale', 'x-deployment': 'stale' } + expect(interceptHeaders(1, 'https://platform.deepseek.com/api/v0/users/current', request)).toEqual({ + 'x-deployment': 'harness', cookie: 'route=user; gate=synthetic', 'x-client-bundle-id': '', + 'x-client-platform': 'desktop-mac', 'x-client-version': '1.2.3', 'x-client-locale': 'en_US', + 'x-client-timezone-offset': String(-new Date().getTimezoneOffset() * 60), + }) + // A redirect to another origin keeps the injected headers out of the follow-up request. + expect(interceptHeaders(1, 'https://login.example.com/authorize', request)).toEqual({}) + manager.close() +}) + +it('samples the language and UTC offset on every Platform request', async () => { + let locale: 'en_US' | 'zh_CN' = 'en_US' + const owner = Object.assign(new EventEmitter(), { + webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, isDestroyed: () => false, + }) + const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'darwin') + manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret' }) + await manager.open(owner, 'usage', bounds) + const offset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(480) + expect(interceptHeaders(1, 'https://platform.deepseek.com/usage', {})).toMatchObject({ + 'x-client-locale': 'en_US', 'x-client-timezone-offset': '-28800', + }) + locale = 'zh_CN' + offset.mockReturnValue(-300) + expect(interceptHeaders(1, 'https://platform.deepseek.com/usage', {})).toMatchObject({ + 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '18000', + }) + offset.mockRestore() + manager.close() +}) + it.each([null, {}, { ...bounds, width: NaN }, { ...bounds, x: -1 }, { ...bounds, y: Infinity }])('rejects malformed IPC rectangles', (value) => { expect(() => platformBounds(value)).toThrow() }) - it('opens HTTPS payment links in the system browser without an embedded child window', async () => { const { manager, owner } = setup() await manager.open(owner, 'top-up', bounds) @@ -126,12 +188,15 @@ it('opens HTTPS payment links in the system browser without an embedded child wi manager.close() }) - it('reveals a loaded document only after loading finishes', async () => { const { manager, owner } = setup() + const loaded = Promise.withResolvers() + state.loadBarrier = loaded.promise const loading = manager.open(owner, 'usage', bounds) + await vi.waitFor(() => { expect(state.views).toHaveLength(1) }) const active = view() expect(active.setVisible.mock.calls).toEqual([[false]]) + loaded.resolve(undefined) await loading expect(active.setVisible.mock.calls).toEqual([[false], [true]]) manager.close() @@ -139,17 +204,20 @@ it('reveals a loaded document only after loading finishes', async () => { it('does not reveal a document closed before its load settles', async () => { const { manager, owner } = setup() + const loaded = Promise.withResolvers() + state.loadBarrier = loaded.promise const loading = manager.open(owner, 'usage', bounds) + await vi.waitFor(() => { expect(state.views).toHaveLength(1) }) const active = view() manager.close() + loaded.resolve(undefined) await loading expect(active.setVisible.mock.calls).toEqual([[false]]) }) - it('injects deployment headers only at the Platform origin and excludes them from bootstrap', async () => { const { manager, owner } = setup() - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret', requestHeaders: { cookie: 'route=new; gate=private', 'x-private-gate': 'private', 'x-client-platform': 'desktop-mac' } }) await manager.open(owner, 'usage', bounds) const browserSession = state.sessions.at(-1) as { webRequest: { onBeforeSendHeaders: ReturnType } } @@ -162,6 +230,8 @@ it('injects deployment headers only at the Platform origin and excludes them fro intercept({ id: 1, url: `https://platform.deepseek.com${path}`, requestHeaders: { Cookie: 'route=old; browser=keep', Accept: 'application/json' } }, callback) expect(callback).toHaveBeenLastCalledWith({ requestHeaders: { cookie: 'route=new; browser=keep; gate=private', accept: 'application/json', 'x-private-gate': 'private', 'x-client-platform': 'desktop-mac', + 'x-client-bundle-id': '', 'x-client-version': '1.2.3', 'x-client-locale': 'en_US', + 'x-client-timezone-offset': String(-new Date().getTimezoneOffset() * 60), } }) } intercept({ id: 1, url: 'https://other.example/api', requestHeaders: { @@ -178,19 +248,18 @@ it('injects deployment headers only at the Platform origin and excludes them fro it.each(['usage', 'top-up'] as const)('selects the configured frontend deployment for %s', async (page) => { const { manager, owner } = setup() - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test&other=value' }) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret', embeddedPageDist: 'feat/test&other=value' }) await manager.open(owner, page, bounds) const url = new URL(view().webContents.loadURL.mock.calls[0]![0] as string) expect(url.origin).toBe('https://platform.deepseek.com') expect(url.pathname).toBe(page === 'usage' ? '/usage' : '/top_up') expect([...url.searchParams]).toEqual([['dist', 'feat/test&other=value']]) const previous = view().webContents - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'another' }) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret', embeddedPageDist: 'another' }) expect(previous.close).toHaveBeenCalledOnce() manager.close() }) - it('removes the native view when its application document reloads, without renderer cleanup', async () => { const { manager, owner, removeChildView } = setup() await manager.open(owner, 'usage', bounds) @@ -221,14 +290,18 @@ it.each(['render-process-gone', 'destroyed', 'closed'])('removes the view on own expect(owner.webContents.listenerCount('render-process-gone')).toBe(0) }) - it('does not reveal a pending view after the owner reloads or remove a replacement view', async () => { const { manager, owner } = setup() + const loaded = Promise.withResolvers() + state.loadBarrier = loaded.promise const loading = manager.open(owner, 'usage', bounds) + await vi.waitFor(() => { expect(state.views).toHaveLength(1) }) const previous = view() owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true) + state.loadBarrier = undefined await manager.open(owner, 'top-up', bounds) const current = view() + loaded.resolve(undefined) await loading expect(previous.setVisible.mock.calls).toEqual([[false]]) expect(current.setVisible).toHaveBeenLastCalledWith(true) @@ -238,12 +311,11 @@ it('does not reveal a pending view after the owner reloads or remove a replaceme expect(owner.webContents.listenerCount('did-start-navigation')).toBe(0) }) - it('bootstraps the current language and updates an open view without reloading', async () => { const { owner } = setup() let locale: 'en_US' | 'zh_CN' = 'zh_CN' - const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale) - manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' }) + const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'win32') + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' }) manager.notifyLocaleChanged() await manager.open(owner, 'usage', bounds) const sender = view().webContents @@ -261,3 +333,160 @@ it('bootstraps the current language and updates an open view without reloading', expect(manager.bootstrap({ sender: reopened, senderFrame: reopened.mainFrame }).locale).toBe('en_US') manager.close() }) + +function browserSession() { + return (state.views.at(-1) as { webContents: { session: object } }).webContents.session as { + partition: string + clearStorageData: ReturnType Promise>> + clearAuthCache: ReturnType Promise>> + closeAllConnections: ReturnType Promise>> + flushStorageData: ReturnType + webRequest: { + onBeforeSendHeaders: ReturnType + onCompleted: ReturnType + onErrorOccurred: ReturnType + } + } +} + +it('reuses persistent storage across closes, token replacement, and manager recreation', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + const first = browserSession() + expect(first.partition).toMatch(/^persist:dsh-platform-[a-f0-9]{64}$/) + expect(first.partition).not.toContain('fixture-user') + expect(first.partition).not.toContain('fixture-secret') + manager.close() + await manager.open(owner, 'top-up', bounds) + expect(browserSession().partition).toBe(first.partition) + expect(first.flushStorageData).toHaveBeenCalledOnce() + expect(first.clearStorageData).toHaveBeenCalledWith({ storages: ['cookies', 'filesystem', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] }) + expect(first.clearAuthCache).toHaveBeenCalledTimes(3) + expect(first.closeAllConnections).toHaveBeenCalledTimes(3) + expect(first.webRequest.onBeforeSendHeaders).toHaveBeenCalledWith(null) + expect(first.webRequest.onCompleted).toHaveBeenCalledWith(null) + expect(first.webRequest.onErrorOccurred).toHaveBeenCalledWith(null) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'rotated' }) + await manager.open(owner, 'usage', bounds) + expect(browserSession().partition).toBe(first.partition) + manager.close() + const restarted = setup().manager + await restarted.open(owner, 'usage', bounds) + expect(browserSession().partition).toBe(first.partition) + restarted.close() +}) + +it('isolates accounts and issuers and restores the original account partition after sign-out', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + const first = browserSession().partition + // The ID must invalidate even an otherwise identical credential snapshot. + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'other-user' as AccountUserId, token: 'fixture-secret' }) + expect(view().webContents.close).toHaveBeenCalledOnce() + await manager.open(owner, 'usage', bounds) + const second = browserSession().partition + expect(second).not.toBe(first) + manager.setSession({ origin: 'https://another.example', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' }) + await manager.open(owner, 'usage', bounds) + expect(browserSession().partition).not.toBe(first) + expect(browserSession().partition).not.toBe(second) + manager.setSession(null) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'signed-in-again' }) + await manager.open(owner, 'usage', bounds) + expect(browserSession().partition).toBe(first) + manager.close() +}) + +it('uses disposable storage when no stable account ID is available', async () => { + const { manager, owner } = setup() + manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret' }) + await manager.open(owner, 'usage', bounds) + const first = browserSession() + expect(first.partition).not.toMatch(/^persist:/) + manager.close() + await manager.open(owner, 'usage', bounds) + expect(browserSession().partition).not.toBe(first.partition) + expect(first.clearStorageData).toHaveBeenCalledWith(undefined) + manager.close() +}) + +it('waits for authentication cleanup before reopening and ignores superseded opens', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + const cleared = Promise.withResolvers() + browserSession().clearStorageData.mockReturnValueOnce(cleared.promise) + manager.close() + const obsolete = manager.open(owner, 'usage', bounds) + const current = manager.open(owner, 'top-up', bounds) + expect(state.views).toHaveLength(1) + cleared.resolve(undefined) + await Promise.all([obsolete, current]) + expect(state.views).toHaveLength(2) + expect(view().webContents.loadURL).toHaveBeenCalledWith('https://platform.deepseek.com/top_up') + manager.close() +}) + +it('does not open a view when authentication cleanup fails', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + browserSession().clearAuthCache.mockRejectedValue(new Error('authentication cleanup failed')) + manager.close() + await expect(manager.open(owner, 'usage', bounds)).rejects.toThrow('Platform storage cleanup failed') + expect(state.views).toHaveLength(1) + browserSession().clearAuthCache.mockResolvedValue(undefined) + await manager.open(owner, 'usage', bounds) + expect(state.views).toHaveLength(2) + await manager.dispose() +}) + +it.each(['did-start-navigation', 'closed', 'destroyed'])('cancels allocation when owner %s during storage cleanup', async (event) => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + const cleared = Promise.withResolvers() + browserSession().clearStorageData.mockReturnValueOnce(cleared.promise) + const opening = manager.open(owner, 'usage', bounds) + if (event === 'closed') owner.emit(event) + else owner.webContents.emit(event, {}, 'dsh-app://app/', false, true) + cleared.resolve(undefined) + await opening + expect(state.views).toHaveLength(1) + expect(owner.listenerCount('closed')).toBe(0) + await manager.dispose() +}) + +it('keeps failed account cleanup isolated from other accounts', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + browserSession().clearAuthCache.mockRejectedValue(new Error('failed')) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'second' as AccountUserId, token: 'second' }) + await manager.open(owner, 'usage', bounds) + expect(state.views).toHaveLength(2) + await expect(manager.dispose()).rejects.toThrow('Platform storage cleanup failed') +}) + +it('awaits cleanup on disposal and rejects subsequent opens', async () => { + const { manager, owner } = setup() + await manager.open(owner, 'usage', bounds) + const cleared = Promise.withResolvers() + browserSession().clearStorageData.mockReturnValueOnce(cleared.promise) + const finished = vi.fn() + const disposing = manager.dispose().then(finished) + await Promise.resolve() + expect(finished).not.toHaveBeenCalled() + cleared.resolve(undefined) + await disposing + expect(view().webContents.close).toHaveBeenCalledWith({ waitForBeforeUnload: false }) + await expect(manager.open(owner, 'usage', bounds)).rejects.toThrow('Platform view disposed') +}) + +it('keeps an open temporary document when its credential gains a stable identity', async () => { + const { manager, owner } = setup() + manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret' }) + await manager.open(owner, 'usage', bounds) + manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' }) + expect(view().webContents.close).not.toHaveBeenCalled() + expect(browserSession().partition).not.toMatch(/^persist:/) + await manager.open(owner, 'usage', bounds) + expect(browserSession().partition).toMatch(/^persist:/) + await manager.dispose() +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 16c94456a4..9ead080f9b 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { JSDOM } from 'jsdom' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' @@ -15,7 +16,26 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) +beforeEach(() => { vi.stubGlobal('process', { ...process, isMainFrame: true }) }) +afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) + +it('reads only native login API-key presence through the onboarding bridge', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + electron.ipcRenderer.invoke.mockResolvedValueOnce(true) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshOnboarding')?.[1] as { hasApiKey(): Promise } + expect(await api.hasApiKey()).toBe(true) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.onboardingApiKey) +}) + +it('exposes onboarding size activation to the application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshOnboarding')?.[1] as { setActive(active: boolean): void } + api.setActive(true) + api.setActive(false) + expect(electron.ipcRenderer.send.mock.calls).toEqual([[DESKTOP_IPC.onboardingActive, true], [DESKTOP_IPC.onboardingActive, false]]) +}) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -121,7 +141,7 @@ it('moves welcome-entry focus to the document without changing keyboard tab orde }) it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { - vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.stubGlobal('process', { ...process, platform }) for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { vi.resetModules() vi.mocked(installMandatoryUpdateOverlay).mockClear() @@ -130,3 +150,92 @@ it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only i expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) } }) + +it('exposes constrained shortcut operations and releases configuration subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await api.shortcuts.get([]) + expect(api.shortcuts).not.toHaveProperty('reload') + await api.shortcuts.recording(true) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, []) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true) + const listener = vi.fn() + const off = api.shortcuts.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsChanged)![1] as + (event: unknown, state: unknown) => void + handler({}, { status: 'ready' }) + expect(listener).toHaveBeenCalledWith({ status: 'ready' }) + off(); expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsChanged, handler) +}) + +it('withholds the product API from same-origin child frames', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + vi.stubGlobal('process', { ...process, isMainFrame: false }) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('forwards only the focused product iframe and releases native input subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'iframe', frameName: 'preview', revision: 'current' } + handler({}, input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.name = 'preview' + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + handler({}, { ...input, frameName: 'stale' }) + handler({}, { ...input, frameName: '' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledTimes(1) + const menu = { kind: 'menu', commandId: 'page.close', revision: 'current' } + handler({}, menu) + expect(listener).toHaveBeenLastCalledWith(menu) + off() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler) +}) + +it('forwards browser guest input only for the focused live webview lease', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'webview', frameName: 'guest', revision: 'current' } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + handler({}, { ...input, frameName: '' }) + handler({}, { ...input, frameName: 'old' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + const other = document.createElement('input') + document.body.append(other) + other.focus() + handler({}, input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledOnce() + off() +}) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 781fd7317e..83003d0738 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' import type { BrowserWindow } from 'electron' import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts' +import { DesktopUpdateOverlays } from '../src/update-overlay.ts' import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts' const fixture = await vi.hoisted(async () => { @@ -23,6 +24,7 @@ const fixture = await vi.hoisted(async () => { constructor(readonly options: unknown) { super(); windows.push(this) } getContentBounds() { return { x: 10, y: 20, width: 900, height: 650 } } isDestroyed() { return this.destroyed } + isVisible() { return true } destroy() { this.destroyed = true; this.emit('closed') } setMenu() {} } @@ -45,7 +47,7 @@ afterEach(() => { function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) { const parent = new fixture.FakeWindow({}) - dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale) + dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays()) const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, { message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }), }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 9f4a79878d..c9fbcf21f3 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,14 +1,99 @@ import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { createUpdateOverlay } from '../src/update-overlay.ts' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron' +import { DesktopUpdateOverlays } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) +afterEach(() => { vi.restoreAllMocks() }) + +function visibilityFixture(visible = true) { + const visibility = { visible } + const parent: ParentFixture & Pick = Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: (): boolean => false, + isVisible: () => visibility.visible, + }) + const window = Object.assign(new EventEmitter(), { + destroyed: false, + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed, + }) + native.create.mockReturnValue(window) + new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false) + return { parent, window, visibility } +} + +it('restores a ready overlay each time its parent is shown and releases visibility ownership on close', async () => { + const { parent, window, visibility } = visibilityFixture() + window.emit('ready-to-show') + expect(window.show).toHaveBeenCalledOnce() + for (let index = 0; index < 2; index++) { + visibility.visible = false + parent.emit('hide') + visibility.visible = true + parent.emit('show') + await Promise.resolve() + } + expect(window.show).toHaveBeenCalledTimes(3) + await Promise.resolve() + expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled() + window.destroyed = true + window.emit('closed') + expect(parent.listenerCount('show')).toBe(0) + expect(parent.webContents.removeInsertedCSS).toHaveBeenCalledWith('blur') + parent.emit('show') + expect(window.show).toHaveBeenCalledTimes(3) +}) + +it('waits for both a visible parent and a ready document, in either order', async () => { + for (const readyFirst of [true, false]) { + const { parent, window, visibility } = visibilityFixture(false) + if (readyFirst) window.emit('ready-to-show') + else { visibility.visible = true; parent.emit('show') } + expect(window.show).not.toHaveBeenCalled() + if (readyFirst) { visibility.visible = true; parent.emit('show') } + else window.emit('ready-to-show') + await Promise.resolve() + expect(window.show).toHaveBeenCalledOnce() + window.destroyed = true + window.emit('closed') + } +}) + +it('does not show or retain blur when closed before its document and CSS insertion settle', async () => { + const { parent, window, visibility } = visibilityFixture(false) + window.destroyed = true + window.emit('closed') + window.emit('ready-to-show') + visibility.visible = true + parent.emit('show') + await Promise.resolve() + expect(window.show).not.toHaveBeenCalled() + expect(parent.listenerCount('show')).toBe(0) + expect(parent.webContents.removeInsertedCSS).toHaveBeenCalledWith('blur') +}) + +it('releases a macOS overlay after its parent has already been destroyed', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const { parent, window } = visibilityFixture() + await Promise.resolve() + vi.spyOn(parent, 'isDestroyed').mockReturnValue(true) + Object.defineProperty(parent, 'webContents', { get() { throw new Error('Object has been destroyed') } }) + window.destroyed = true + expect(() => window.emit('closed')).not.toThrow() + for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0) +}) + +type ParentFixture = EventEmitter & Pick & { + webContents: EventEmitter & Pick +} + it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const parent = Object.assign(new EventEmitter(), { + const parent: ParentFixture = Object.assign(new EventEmitter(), { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), isDestroyed: () => false, @@ -19,7 +104,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { - createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) + new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) @@ -30,3 +115,44 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu expect(parent.webContents.listenerCount('before-input-event')).toBe(0) } finally { platform.mockRestore() } }) + +it('blocks each parent until its last owned overlay closes and invalidates each transition', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + onTestFinished(() => { platform.mockRestore() }) + const parent = (): ParentFixture => Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const firstParent = parent() as BrowserWindow + const secondParent = parent() as BrowserWindow + const overlays = new DesktopUpdateOverlays() + const isolated = new DesktopUpdateOverlays() + native.create.mockImplementation(() => { + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + onTestFinished(() => { window.emit('closed') }) + return window + }) + expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false }) + const first = overlays.create(firstParent, 'owned', 'Update required', false) + const second = overlays.create(firstParent, 'owned', 'Confirm installation', false) + const other = overlays.create(secondParent, 'owned', 'Update required', false) + expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true }) + expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true }) + expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false }) + first.emit('closed') + expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true }) + expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1) + second.emit('closed') + expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false }) + expect(firstParent.listenerCount('move')).toBe(0) + expect(firstParent.listenerCount('resize')).toBe(0) + expect(firstParent.listenerCount('focus')).toBe(0) + expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0) + expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true }) + other.emit('closed') + expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false }) +}) diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index 9b3dd0f2ed..8640662582 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopHostProcess } from '../src/host-process.ts' import { DesktopProjectManager } from '../src/project-manager.ts' import { resolveDesktopPaths } from '../src/paths.ts' +import { desktopClientMetadata } from '../src/client-metadata.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { prepareDevelopmentProject } from '../scripts/development-project.ts' @@ -27,7 +28,15 @@ async function mockPlatform() { let init: Record = {} let failExchange = false const server = createServer((req, res) => { - if (req.headers['x-client-platform'] !== (process.platform === 'win32' ? 'desktop-win' : 'desktop-mac')) { res.writeHead(400).end(); return } + // The real Host composition identifies every Platform request with the five client headers. + const expected = { + 'x-client-bundle-id': '', 'x-client-platform': process.platform === 'win32' ? 'desktop-win' : 'desktop-mac', + 'x-client-version': '1.2.3', 'x-client-locale': 'en_US', + 'x-client-timezone-offset': String(-new Date().getTimezoneOffset() * 60), + } + for (const [name, value] of Object.entries(expected)) { + if (req.headers[name] !== value) { res.writeHead(400).end(); return } + } if (req.headers.cookie !== 'test_gate=synthetic') { res.writeHead(403).end(); return } if (req.url === '/auth-api/v0/users/logout' && req.method === 'POST') { if (req.headers['x-dsh-auth-token'] !== 'dsh_mock_composition_test') { res.writeHead(401).end(); return } @@ -75,6 +84,7 @@ async function mockPlatform() { describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { it('persists explicit API keys and browser account login independently across Host restarts', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3') const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-')) let host: DesktopHostProcess | undefined const platform = await mockPlatform() @@ -147,7 +157,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { expect(await status()).toMatchObject({ hasApiKey: true, localePreference: 'zh' }) await restart() expect(await status()).toMatchObject({ hasApiKey: true }) - await backend!.account.start('en') + await backend!.account.start(desktopClientMetadata('en')) await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser') expect(new URL(platform.callback()).origin).toBe(hostOrigin) platform.failExchange(true) @@ -156,7 +166,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { expect(await backend!.account.state()).toMatchObject({ status: 'signed-out', attempt: { phase: 'failed' } }) expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false }) platform.failExchange(false) - await backend!.account.start('en') + await backend!.account.start(desktopClientMetadata('en')) await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser') const response = await fetch(platform.callback(), { redirect: 'manual' }) expect(response.status).toBe(302) @@ -165,9 +175,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { await restart() expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: true }) // A failed remote logout must not block local sign-out through the real Host composition. - await backend!.account.signOut() + await backend!.account.signOut(desktopClientMetadata('en')) expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false }) - await backend!.account.start('en') + await backend!.account.start(desktopClientMetadata('en')) await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser') const waiting = await backend!.account.state() const late = platform.callback() diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index ef5f2f7fc2..0629083a8a 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -57,7 +57,7 @@ vi.mock('electron', () => ({ getVersion: () => '1.0.0', setAboutPanelOptions: vi.fn(), getAppPath: () => '/development-app', - getPath: (name: string) => `/development-${name}`, + getPath: (name: string) => name === 'userData' ? '/desktop-user-data' : `/development-${name}`, setAppLogsPath: vi.fn(), getPreferredSystemLanguages: () => ['en-US'], on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) }, diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 37a5be420d..370ee0a448 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -22,6 +22,12 @@ }, { "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" + }, + { + "path": "../../packages/client/shortcuts/tsconfig.host.json" + }, + { + "path": "../../packages/util/atomic-write" } ], "exclude": [ diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6db212aee7..6856126691 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -1,11 +1,42 @@ import { defineConfig } from 'tsdown' import { build } from 'vite' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { repositoryClientBuildEnvironment, resolveClientBuildEnvironment } from '../../scripts/client-build-environment.ts' +import { packagedImportsPlugin } from './scripts/desktop-bundle-imports.mjs' + +// This config runs after the workspace tsdown pass, not inside it: the main bundle inlines +// workspace devDependencies from their lib/ output, which the concurrent workspace pass does +// not order ahead of this package (root package.json build:lib:host). +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + dependencies: Record +} +/** electron-builder ships the manifest `dependencies` next to the main bundle; Electron provides `electron` and Node. */ +const mainProcessImports = { packages: new Set(['electron', ...Object.keys(manifest.dependencies)]), nodeBuiltins: true } +/** + * The `require` polyfill of a sandboxed preload resolves only these modules + * (Electron: Process Sandboxing, "Preload scripts"). + */ +const preloadImports = { packages: new Set(['electron', 'events', 'timers', 'url']), nodeBuiltins: false } + +const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url)) + +/** Use the client environment this build already received, otherwise the repository's own version and commit. */ +const clientEnvironment = resolveClientBuildEnvironment(process.env.DSH_CLIENT_VERSION === undefined + ? repositoryClientBuildEnvironment(REPOSITORY_ROOT, process.env) + : process.env) +const clientVersion = clientEnvironment.DSH_CLIENT_VERSION +if (clientVersion === undefined) throw new Error('desktop build: the client environment carries no DSH_CLIENT_VERSION') + +/** Inline the one public build value the Node entry reads; every other variable stays a runtime lookup. */ +const clientVersionDefine = { 'process.env.DSH_CLIENT_VERSION': JSON.stringify(clientVersion) } export default defineConfig([ { entry: ['lib/types/main.js'], + plugins: [packagedImportsPlugin(mainProcessImports)], + define: clientVersionDefine, onSuccess: async () => { await build({ configFile: false, @@ -49,6 +80,7 @@ export default defineConfig([ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, + plugins: [packagedImportsPlugin(preloadImports)], outDir: 'lib', format: 'cjs' as const, codeSplitting: false, diff --git a/apps/web/package.json b/apps/web/package.json index f8c064f3db..d161811059 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "vite": "^6.0.0", "vitest": "^4.1.8", "ws": "8.21.0", + "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-workspace-changes": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 1a84cd5f63..5f4786acb7 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,4 +1,5 @@ /** Browser entry for the Web client. */ +import '@deepseek-ai/dsh-client-ui-theme/brand-font.css' import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web' interface DesktopBootGlobal { diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index d1bc84a488..8ffee0001a 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -30,7 +30,7 @@ const PROMPT = `Write a file named notes.txt in the workspace containing exactly /** Draft used to measure the composer's own text cap: enough lines to pass it. */ const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n') -describe('web e2e: approval takeover keeps its actions reachable', () => { +describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] as const)('web e2e: approval takeover through %s', (method) => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -119,7 +119,11 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { await page.setViewportSize(original) } - await panel.getByRole('button', { name: 'Allow once' }).click() + if (method === 'button') await panel.getByRole('button', { name: 'Allow once' }).click() + else { + await scroll.focus() + await page.keyboard.press('Enter') + } const sessionId = await settled if (MODE === 'record') { diff --git a/apps/web/tests/bonus-notice.e2e.ts b/apps/web/tests/bonus-notice.e2e.ts new file mode 100644 index 0000000000..04e879156f --- /dev/null +++ b/apps/web/tests/bonus-notice.e2e.ts @@ -0,0 +1,572 @@ +// Real-composition acceptance for the user-side bonus notice. The scaffold boots the +// shipped Web Loader tree, so the real account provider issues the bonus HTTP requests +// and the real client renders the card in a real Chromium. The loopback Platform double +// is the observation point: the scenario asserts what the product sent and what the user +// could read on screen. +import { createServer, type Server } from 'node:http' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Locator, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { openSettings, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/bonus-notice', import.meta.url)) +const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/bonus-notice/cordis.patch.yml', import.meta.url)) +const MODE = webSnapshotMode() + +const TOKEN = 'dsh_bonus_notice_test' +const ACCOUNT_ID = 'bonus-user' +const ORDER_FIRST = '22222222-2222-4222-8222-222222222222' +const ORDER_LATER = '33333333-3333-4333-8333-333333333333' +const ORDER_EN = '44444444-4444-4444-8444-444444444444' +const ORDER_RETRY = '55555555-5555-4555-8555-555555555555' +const ORDER_REPEAT = '66666666-6666-4666-8666-666666666666' +const ORDER_TOPUP = '77777777-7777-4777-8777-777777777777' + +const CLAIM_DAYS = 30 + +/** Eligibility window the double reports; neither the card nor the golden renders a date. */ +function expiresAt(): string { + return new Date(Date.now() + CLAIM_DAYS * 86_400_000).toISOString() +} + +interface Granted { + readonly orderId: string + readonly amount: string + readonly zh_CN: string + readonly en_US: string +} + +interface GetRecord { + readonly locale: string | undefined + readonly query: string +} + +interface AckRecord { + readonly method: string + readonly path: string + readonly locale: string | undefined + readonly orderId: string | null + readonly body: string + readonly query: string + readonly contentType: string | undefined + /** Monotonic arrival time, so the backoff gap cannot be distorted by a wall-clock jump. */ + readonly at: number +} + +/** Loopback Platform double: auth gate, profile, wallets, unnotified bonuses, and the acknowledgement. */ +async function mockPlatform() { + const unnotified: Granted[] = [] + const gets: GetRecord[] = [] + /** Reads already answered; a request the client is still awaiting is not evidence of a delivered response. */ + const served: GetRecord[] = [] + const acks: AckRecord[] = [] + /** Wallet reads, one per balance refresh the settings panel performs. */ + const summaries: GetRecord[] = [] + let normalBalance = '12.34' + let bonusBalance = '5.00' + /** Order whose acknowledgement attempts the double fails, and how many failures it still owes. */ + let ackFailure: { orderId: string; remaining: number } | undefined + /** Whether an acknowledged order stays unnotified, as a backend that has not applied the acknowledgement would serve it. */ + let keepUnnotified = false + /** Refuse the wallet read, which is how a Platform outage or an unparsable payload reaches the client. */ + let failSummary = false + /** Reply held back while the test controls when a read can answer. */ + let heldReply: (() => void) | undefined + const grant = (orderId: string, amount: string): void => { + unnotified.unshift({ + orderId, amount, + zh_CN: `已赠送您 ${amount} 元 DSH 体验赠金。`, + en_US: `You received a CNY ${amount} DSH trial credit.`, + }) + } + /** @param value - granted bonus. @param locale - request language. @returns its wire fields. */ + const wire = (value: Granted, locale: string | undefined): unknown => ({ + order_id: value.orderId, campaign: 'dsh_login_bonus', amount: value.amount, currency: 'CNY', + granted_at: new Date().toISOString(), expires_at: expiresAt(), + msg: locale === 'en_US' ? value.en_US : value.zh_CN, + }) + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + const header = req.headers['x-client-locale'] + const locale = typeof header === 'string' ? header : undefined + const reply = (payload: unknown): void => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(payload)) + } + if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return } + if (url.pathname === '/auth-api/v0/users/current') { + // `email` is required by the provider's profile schema even when the account has none. + reply({ code: 0, data: { biz_code: 0, biz_data: { + id: ACCOUNT_ID, email: '', id_profile: { name: 'Bonus User', picture: null }, + } } }) + return + } + if (url.pathname === '/api/v0/users/get_user_summary') { + summaries.push({ locale, query: url.search }) + if (failSummary) { res.writeHead(500, { 'content-type': 'text/plain' }).end('Internal Server Error'); return } + reply({ code: 0, data: { biz_code: 0, biz_data: { + normal_wallets: [{ currency: 'CNY', balance: normalBalance }], + bonus_wallets: [{ currency: 'CNY', balance: bonusBalance }], + } } }) + return + } + if (url.pathname === '/api/v0/users/get_unnotified_bonuses') { + const record = { locale, query: url.search } + gets.push(record) + const payload = { code: 0, data: { biz_code: 0, biz_data: unnotified.map(value => wire(value, locale)) } } + const answer = (): void => { served.push(record); reply(payload) } + if (heldReply === undefined) { answer(); return } + heldReply = answer + return + } + if (url.pathname === '/api/v0/users/ack_bonus_notified' && req.method === 'POST') { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk: string) => { body += chunk }) + req.on('end', () => { + const payload: unknown = JSON.parse(body) + const orderId = typeof payload === 'object' && payload !== null && 'order_id' in payload + && typeof payload.order_id === 'string' ? payload.order_id : null + acks.push({ method: req.method ?? '', path: url.pathname, locale, orderId, body, + query: url.search, contentType: req.headers['content-type'], at: performance.now() }) + if (ackFailure !== undefined && ackFailure.orderId === orderId && ackFailure.remaining > 0) { + ackFailure.remaining-- + // A transient gateway failure: the client retries it after its backoff. + res.writeHead(502, { 'content-type': 'text/plain' }).end('Internal Server Error') + return + } + if (orderId === null) { + reply({ code: 0, msg: '', data: { biz_code: 1, biz_msg: 'BONUS_ORDER_NOT_FOUND', biz_data: null } }); return + } + const index = unnotified.findIndex(value => value.orderId === orderId) + if (index >= 0 && !keepUnnotified) unnotified.splice(index, 1) + reply({ code: 0, msg: '', data: { biz_code: 0, biz_msg: '', biz_data: null } }) + }) + return + } + if (url.pathname === '/auth-api/v0/users/logout') { reply({ code: 0, data: { biz_code: 0, biz_data: null } }); return } + res.writeHead(404).end() + }) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('bonus notice: missing mock listener') + return { + origin: `http://127.0.0.1:${String(address.port)}`, + grant, gets, served, acks, summaries, + /** @param value - recharge balance the settings page reads next. */ + setNormalBalance: (value: string): void => { normalBalance = value }, + /** @param value - bonus balance the settings page reads next. */ + setBonusBalance: (value: string): void => { bonusBalance = value }, + /** @param value - whether the wallet read fails. */ + setFailSummary: (value: boolean): void => { failSummary = value }, + /** Drop every unnotified grant, so a later scenario starts from a server with nothing to show. */ + clearUnnotified: (): void => { unnotified.length = 0 }, + /** @param value - whether acknowledged orders keep arriving in the unnotified read. */ + setKeepUnnotified: (value: boolean): void => { keepUnnotified = value }, + /** @param orderId - order to refuse. @param count - its acknowledgement attempts that fail before one succeeds. */ + failNextAcks: (orderId: string, count: number): void => { ackFailure = { orderId, remaining: count } }, + /** Hold the next unnotified-bonus read so the test can observe the in-flight state. */ + holdNextGet: (): void => { heldReply = () => undefined }, + releaseGet: (): void => { const held = heldReply; heldReply = undefined; held?.() }, + close: () => new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }), + } +} + +/** @param page - page under test. @returns every rendered bonus notice card. */ +function noticeCards(page: Page): Locator { + return page.locator('aside').filter({ hasText: '赠金已到账' }) +} + +/** @param card - a rendered notice card. @returns its non-empty visible lines joined for the golden. */ +async function cardText(card: Locator): Promise { + return (await card.innerText()).split('\n').map(line => line.trim()).filter(line => line !== '').join(' / ') +} + +/** Wait for the card carrying this copy, then return the text the user could read from it. */ +async function shownNotice(page: Page, copy: string): Promise { + const card = noticeCards(page).filter({ hasText: copy }) + await card.waitFor({ state: 'visible', timeout: 30_000 }) + return cardText(card) +} + +/** Wait for the double to answer this many acknowledgements, then return the order ids in arrival order. */ +async function acked(platform: { acks: AckRecord[] }, count: number): Promise { + await expect.poll(() => platform.acks.length, { timeout: 30_000 }).toBe(count) + return platform.acks.map(item => String(item.orderId)).join(',') +} + +/** + * Native Platform bridge calls recorded in one page. `setBounds` is resize-driven and is + * deliberately not recorded, so the calls here are the opens and closes a user caused. + * @param page - page under test. + * @returns the recorded call log in order. + */ +async function platformCalls(page: Page): Promise { + return page.evaluate(() => (globalThis as typeof globalThis & { __bonusPlatformCalls?: readonly string[] }) + .__bonusPlatformCalls ?? []) +} + +describe.skipIf(MODE === 'record')('web e2e: bonus notice', () => { + let root: string | undefined + let scaffold: WebScaffold + let browser: Browser + let platform: Awaited> + const tripwires: ReturnType[] = [] + + /** @param locale - browser language driving the requested notice locale. @returns a desktop-renderer page. */ + const openDesktopPage = async (locale: string): Promise => { + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale }) + const opened = await context.newPage() + tripwires.push(watchConsole(opened)) + await opened.addInitScript(() => { + Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) + // The embedded Platform view is a native child window owned by the Desktop shell. + // The double answers immediately and records the calls, so the scenario observes + // which page was opened and that returning destroyed the view. + const calls: string[] = [] + Object.defineProperty(globalThis, '__bonusPlatformCalls', { value: calls }) + Object.defineProperty(globalThis, 'dshPlatform', { + value: { + open: (page: string) => { calls.push(`open:${page}`); return Promise.resolve() }, + setBounds: () => Promise.resolve(), + close: () => { calls.push('close'); return Promise.resolve() }, + }, + }) + }) + return opened + } + + beforeAll(async () => { + platform = await mockPlatform() + platform.grant(ORDER_FIRST, '5.00') + root = await mkdtemp(join(tmpdir(), 'dsh-bonus-notice-')) + const home = join(root, 'home') + await mkdir(home, { recursive: true }) + // A stored grant bound to the double's origin starts the account credential-stored + // without driving the browser sign-in flow. + await writeFile(join(home, '.credentials.yaml'), + `version: 1\nrefs: {}\nrecords:\n deepseek-account-platform/default:\n kind: grant\n payload:\n version: 1\n token: ${TOKEN}\n issuer: ${platform.origin}\n`, + { mode: 0o600 }) + const overlay = join(root, 'bonus-notice.overlay.yml') + await writeFile(overlay, (await readFile(OVERLAY_TEMPLATE, 'utf8')).replaceAll('{{origin}}', platform.origin)) + scaffold = await launchWebScaffold({ harnessHome: home, extraOverlayPath: overlay }) + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await platform?.close() + if (root !== undefined) await rm(root, { recursive: true, force: true }) + }) + + it('acknowledges a notice shown under settings, refreshes once per entry, and localizes the request', async () => { + const zhFirst = '已赠送您 5.00 元 DSH 体验赠金。' + const zhLater = '已赠送您 8.00 元 DSH 体验赠金。' + const enBonus = 'You received a CNY 9.00 DSH trial credit.' + const observations: string[] = [] + const page = await openDesktopPage('zh-CN') + onTestFailed(() => saveFailureShot(page, 'web-e2e-bonus-notice')) + /** @returns every browser storage key of the started page, so a baseline can be taken before a notice shows. */ + const storageKeys = (): Promise => page.evaluate(() => Object.keys(localStorage).sort()) + + // A read the renderer never received cannot become a displayed notice: while the + // response is held there is no card and no acknowledgement. + platform.holdNextGet() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(1) + observations.push(`startup.held cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toEqual([]) + platform.releaseGet() + + // Startup signs in and reads the notice; the card shows it and only then is it acknowledged. + observations.push(`startup.notice=${await shownNotice(page, zhFirst)}`) + observations.push(`startup.ack orders=${await acked(platform, 1)}`) + const get = platform.gets[0]! + const ack = platform.acks[0]! + observations.push(`startup.get locale=${String(get.locale)} query=${get.query}`) + observations.push(`startup.ack method=${ack.method} path=${ack.path} locale=${String(ack.locale)} order_id=${String(ack.orderId)} body=${JSON.stringify(ack.body)}`) + expect(get.locale).toBe('zh_CN') + expect(get.query).toBe('') + expect(ack).toMatchObject({ + method: 'POST', path: '/api/v0/users/ack_bonus_notified', locale: 'zh_CN', orderId: ORDER_FIRST, + body: JSON.stringify({ order_id: ORDER_FIRST }), query: '', contentType: 'application/json', + }) + + // Closing the card only hides it; the acknowledgement already sent is not repeated. + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toHaveLength(1) + + // A new grant and balance arrive before settings opens; one panel entry refreshes both once. + platform.grant(ORDER_LATER, '8.00') + platform.setBonusBalance('13.00') + const getsBefore = platform.gets.length + const summariesBefore = platform.summaries.length + // Hold the panel's notice read so the test observes its in-flight state instead of + // racing a fast local response. + platform.holdNextGet() + await openSettings(page, 'zh') + const settings = page.getByRole('dialog', { name: '设置', exact: true }) + await settings.waitFor() + await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(getsBefore + 1) + // One open, one read: no control in the panel drives a second one. + expect(await settings.getByRole('button', { name: '刷新余额', exact: true }).count()).toBe(0) + observations.push(`open.held cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)} gets=${String(platform.gets.length - getsBefore)}`) + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toHaveLength(1) + expect(platform.summaries.length).toBeGreaterThan(summariesBefore) + platform.releaseGet() + // The answered notice is displayed and acknowledged while the panel stays open. + const openNotice = await shownNotice(page, zhLater) + observations.push(`open.shown notice=${openNotice}`) + observations.push(`open.shown ack orders=${await acked(platform, 2)}`) + // Hit-testing the card's own point proves the settings layer paints above it. + const paintedUnderOverlay = await noticeCards(page).filter({ hasText: zhLater }).evaluate((element) => { + const rect = element.getBoundingClientRect() + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + 20) + return hit !== null && !element.contains(hit) && hit.closest('[role="presentation"]') !== null + }) + observations.push(`open.shown painted-under-settings=${String(paintedUnderOverlay)} cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(paintedUnderOverlay).toBe(true) + await expect.poll(async () => (await settings.textContent()) ?? '', { timeout: 30_000 }).toContain('¥13.00') + // Still one read after the refresh settled. + expect(platform.gets.length).toBe(getsBefore + 1) + + const openGet = platform.served.at(-1)! + const panel = (await settings.textContent()) ?? '' + const usage = await settings.getByRole('link', { name: '查询用量', exact: true }).getAttribute('href') + observations.push(`open.get locale=${String(openGet.locale)} query=${openGet.query} served=true gets=${String(platform.gets.length - getsBefore)}`) + observations.push(`open.balance bonus-row=${String(panel.includes('赠金余额'))} amount=${String(panel.includes('¥13.00'))} dated=${String(/\d{4}-\d{2}-\d{2}/.test(panel))}`) + observations.push(`open.cards-while-open=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + observations.push(`open.usage-link=${String(usage).replace(platform.origin, '{{origin}}')}`) + expect(openGet.locale).toBe('zh_CN') + expect(openGet.query).toBe('') + expect(panel).toContain('赠金余额') + expect(panel).toContain('¥13.00') + expect(panel).not.toMatch(/\d{4}-\d{2}-\d{2}/) + expect(usage).toBe(`${platform.origin}/usage`) + expect(await noticeCards(page).count()).toBe(1) + expect(platform.acks).toHaveLength(2) + + // Closing settings repeats no acknowledgement and leaves the card on the sidebar. + const launcher = page.getByRole('button', { name: '账号菜单', exact: true }) + await page.keyboard.press('Escape') + await settings.waitFor({ state: 'detached', timeout: 30_000 }) + // The shell restores focus to the launcher once the close commits. + await expect.poll(() => launcher.evaluate(element => element === document.activeElement), { timeout: 30_000 }).toBe(true) + observations.push(`open.closed settings cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(await noticeCards(page).count()).toBe(1) + expect(platform.acks).toHaveLength(2) + // Closing the card hides it; the double already stopped serving this acknowledged order. + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toHaveLength(2) + observations.push(`open.dismissed cards=0 acks=${String(platform.acks.length)}`) + + // The next entry refreshes once more; switching sections inside one open is not an entry. + const reopenGetsBefore = platform.gets.length + const reopenSummariesBefore = platform.summaries.length + await openSettings(page, 'zh') + const reopened = page.getByRole('dialog', { name: '设置', exact: true }) + await reopened.waitFor() + await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(reopenGetsBefore + 1) + await expect.poll(() => platform.summaries.length, { timeout: 30_000 }).toBe(reopenSummariesBefore + 1) + observations.push(`reopen.open gets=${String(platform.gets.length - reopenGetsBefore)} summaries=${String(platform.summaries.length - reopenSummariesBefore)} cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toHaveLength(2) + // Arm the hold so a read triggered by navigation would be recorded and observed rather + // than racing this assertion. + const sectionGetsBefore = platform.gets.length + const sectionSummariesBefore = platform.summaries.length + platform.holdNextGet() + const nav = reopened.locator('nav') + await nav.getByRole('button', { name: '模型', exact: true }).click() + await expect.poll(async () => nav.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current'), { timeout: 30_000 }).toBe('true') + await nav.getByRole('button', { name: '账号与余额', exact: true }).click() + await expect.poll(async () => nav.getByRole('button', { name: '账号与余额', exact: true }).getAttribute('aria-current'), { timeout: 30_000 }).toBe('true') + platform.releaseGet() + const sectionPanel = (await reopened.textContent()) ?? '' + observations.push(`reopen.section-switch gets=${String(platform.gets.length - sectionGetsBefore)} summaries=${String(platform.summaries.length - sectionSummariesBefore)} amount=${String(sectionPanel.includes('¥13.00'))} cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(platform.gets.length).toBe(sectionGetsBefore) + expect(platform.summaries.length).toBe(sectionSummariesBefore) + expect(sectionPanel).toContain('¥13.00') + await page.keyboard.press('Escape') + await reopened.waitFor({ state: 'detached', timeout: 30_000 }) + await expect.poll(() => launcher.evaluate(element => element === document.activeElement), { timeout: 30_000 }).toBe(true) + observations.push(`reopen.closed cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`) + expect(await noticeCards(page).count()).toBe(0) + expect(platform.acks).toHaveLength(2) + + // Returning from the native top-up view refreshes balance and notice once each, without + // holding the panel open. + const zhTopUp = '已赠送您 11.00 元 DSH 体验赠金。' + const topUpGetsBefore = platform.gets.length + const topUpSummariesBefore = platform.summaries.length + await openSettings(page, 'zh') + const topUpSettings = page.getByRole('dialog', { name: '设置', exact: true }) + await topUpSettings.waitFor() + await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(topUpGetsBefore + 1) + await expect.poll(() => platform.summaries.length, { timeout: 30_000 }).toBe(topUpSummariesBefore + 1) + observations.push(`topup.open gets=${String(platform.gets.length - topUpGetsBefore)} summaries=${String(platform.summaries.length - topUpSummariesBefore)}`) + const callsBefore = (await platformCalls(page)).length + await topUpSettings.getByRole('link', { name: '充值', exact: true }).click() + const topUpOverlay = page.getByRole('dialog', { name: '返回 DeepSeek Harness', exact: true }) + await topUpOverlay.waitFor() + // Opening the native view reads nothing by itself. + expect(platform.gets.length).toBe(topUpGetsBefore + 1) + expect(platform.summaries.length).toBe(topUpSummariesBefore + 1) + // The user pays while the view is open; Platform reports both balances in scientific + // notation, so the refresh must render plain currency. + platform.setNormalBalance('0E-16') + platform.setBonusBalance('2.4E+1') + platform.grant(ORDER_TOPUP, '11.00') + // Holding the notice read proves the return does not wait for the refresh it starts. + platform.holdNextGet() + await topUpOverlay.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).click() + await topUpOverlay.waitFor({ state: 'detached', timeout: 30_000 }) + const topUpCalls = (await platformCalls(page)).slice(callsBefore).join(',') + observations.push(`topup.returned bridge=${topUpCalls} cards=${String(await noticeCards(page).count())}`) + expect(topUpCalls).toBe('open:top-up,close') + expect(await noticeCards(page).count()).toBe(0) + platform.releaseGet() + observations.push(`topup.notice=${await shownNotice(page, zhTopUp)}`) + const topUpAttempts = (): AckRecord[] => platform.acks.filter(item => item.orderId === ORDER_TOPUP) + await expect.poll(() => topUpAttempts().length, { timeout: 30_000 }).toBe(1) + await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(topUpGetsBefore + 2) + await expect.poll(() => platform.summaries.length, { timeout: 30_000 }).toBe(topUpSummariesBefore + 2) + const topUpPanel = (await topUpSettings.textContent()) ?? '' + const topUpAmounts = { recharge: topUpPanel.includes('¥0.00'), bonus: topUpPanel.includes('¥24.00') } + observations.push(`topup.refresh gets=${String(platform.gets.length - topUpGetsBefore)} summaries=${String(platform.summaries.length - topUpSummariesBefore)} acks=${String(topUpAttempts().length)} recharge=${String(topUpAmounts.recharge)} bonus=${String(topUpAmounts.bonus)}`) + expect(topUpAmounts).toEqual({ recharge: true, bonus: true }) + expect(await topUpSettings.getByRole('button', { name: '刷新余额', exact: true }).count()).toBe(0) + // The card survives closing the panel, and the acknowledgement is not repeated. + await page.keyboard.press('Escape') + await topUpSettings.waitFor({ state: 'detached', timeout: 30_000 }) + observations.push(`topup.closed cards=${String(await noticeCards(page).count())} acks=${String(topUpAttempts().length)}`) + expect(await noticeCards(page).count()).toBe(1) + expect(topUpAttempts()).toHaveLength(1) + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + expect(await noticeCards(page).count()).toBe(0) + + // A transient acknowledgement failure keeps the card and retries the same order after + // the backoff; the pending retry is page state, not browser storage. + const zhRetry = '已赠送您 6.00 元 DSH 体验赠金。' + platform.grant(ORDER_RETRY, '6.00') + platform.failNextAcks(ORDER_RETRY, 1) + await openSettings(page, 'zh') + const retryDialog = page.getByRole('dialog', { name: '设置', exact: true }) + await retryDialog.waitFor() + observations.push(`retry.notice=${await shownNotice(page, zhRetry)}`) + const retryAttempts = (): AckRecord[] => platform.acks.filter(item => item.orderId === ORDER_RETRY) + await expect.poll(() => retryAttempts().length, { timeout: 30_000 }).toBe(1) + // The failed attempt does not withdraw the card the user is reading. + expect(await noticeCards(page).count()).toBe(1) + await expect.poll(() => retryAttempts().length, { timeout: 30_000 }).toBe(2) + const attempts = retryAttempts() + // The first retry follows the configured first delay rather than arriving immediately. + // Only the lower bound is asserted: load can widen the observed gap, never shorten it + // below the timer's own 1s first step. + const retryGapMs = attempts[1]!.at - attempts[0]!.at + const retryBodyKept = attempts.every(item => item.body === JSON.stringify({ order_id: ORDER_RETRY }) + && item.query === '' && item.contentType === 'application/json') + observations.push(`retry.attempts=${String(attempts.length)} backoff=${String(retryGapMs >= 900)} body-kept=${String(retryBodyKept)} cards=${String(await noticeCards(page).count())}`) + expect(retryGapMs).toBeGreaterThanOrEqual(900) + expect(retryBodyKept).toBe(true) + await page.keyboard.press('Escape') + await retryDialog.waitFor({ state: 'detached', timeout: 30_000 }) + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + + // Display follows each response: the double keeps serving an acknowledged order, which + // is what makes a second display of the same order observable. + const zhRepeat = '已赠送您 7.00 元 DSH 体验赠金。' + const storedBeforeShown = await storageKeys() + platform.setKeepUnnotified(true) + platform.grant(ORDER_REPEAT, '7.00') + const repeatAttempts = (): AckRecord[] => platform.acks.filter(item => item.orderId === ORDER_REPEAT) + await openSettings(page, 'zh') + const repeatDialog = page.getByRole('dialog', { name: '设置', exact: true }) + await repeatDialog.waitFor() + observations.push(`repeat.first=${await shownNotice(page, zhRepeat)}`) + await expect.poll(() => repeatAttempts().length, { timeout: 30_000 }).toBe(1) + // The mask owns pointer input, so the panel closes before the sidebar card is dismissed. + await page.keyboard.press('Escape') + await repeatDialog.waitFor({ state: 'detached', timeout: 30_000 }) + expect(await noticeCards(page).count()).toBe(1) + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + // Displaying and acknowledging a notice writes nothing, so the key set is unchanged + // against the baseline taken before it was shown. + const shownAdded = (await storageKeys()).filter(key => !storedBeforeShown.includes(key)) + expect(shownAdded).toEqual([]) + // Restarting replays whatever the server still offers. + await page.reload({ waitUntil: 'load' }) + observations.push(`repeat.reload=${await shownNotice(page, zhRepeat)}`) + await expect.poll(() => repeatAttempts().length, { timeout: 30_000 }).toBe(2) + const reloadAdded = (await storageKeys()).filter(key => !storedBeforeShown.includes(key)) + observations.push(`repeat.shown added-storage-keys=${String(shownAdded.length)} reload-added=${String(reloadAdded.length)} acks=${String(repeatAttempts().length)} cards=${String(await noticeCards(page).count())}`) + expect(reloadAdded).toEqual([]) + await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click() + + // A failed wallet read still leaves both balance rows reaching the Platform entry. + platform.setKeepUnnotified(false) + platform.clearUnnotified() + platform.setFailSummary(true) + const failedGetsBefore = platform.gets.length + const failedSummariesBefore = platform.summaries.length + const failedUsagesBefore = (await platformCalls(page)).filter(call => call === 'open:usage').length + await openSettings(page, 'zh') + const failedSettings = page.getByRole('dialog', { name: '设置', exact: true }) + await failedSettings.waitFor() + const failedLinks = failedSettings.getByRole('link', { name: '前往开放平台查看', exact: true }) + await expect.poll(() => failedLinks.count(), { timeout: 30_000 }).toBe(2) + await expect.poll(() => platform.summaries.length, { timeout: 30_000 }).toBe(failedSummariesBefore + 1) + const failedHrefs = (await failedLinks.evaluateAll(nodes => nodes.map(node => node.getAttribute('href')))) + .map(href => String(href).replace(platform.origin, '{{origin}}')) + observations.push(`failed.links count=${String(await failedLinks.count())} hrefs=${failedHrefs.join(',')}`) + expect(failedHrefs).toEqual(['{{origin}}/usage', '{{origin}}/usage']) + // Returning from usage refreshes nothing, because only a payment changes the account. + for (const index of [0, 1]) { + await failedLinks.nth(index).click() + const usageOverlay = page.getByRole('dialog', { name: '返回 DeepSeek Harness', exact: true }) + await usageOverlay.waitFor() + await usageOverlay.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).click() + await usageOverlay.waitFor({ state: 'detached', timeout: 30_000 }) + } + const failedUsages = (await platformCalls(page)).filter(call => call === 'open:usage').length - failedUsagesBefore + observations.push(`failed.reached usages=${String(failedUsages)} gets=${String(platform.gets.length - failedGetsBefore)} summaries=${String(platform.summaries.length - failedSummariesBefore)}`) + expect(failedUsages).toBe(2) + expect(platform.gets.length).toBe(failedGetsBefore + 1) + expect(platform.summaries.length).toBe(failedSummariesBefore + 1) + platform.setFailSummary(false) + await page.keyboard.press('Escape') + await failedSettings.waitFor({ state: 'detached', timeout: 30_000 }) + await page.close() + + // Another device in another language gets the copy the server localized for it. + platform.grant(ORDER_EN, '9.00') + const enPage = await openDesktopPage('en-US') + await enPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const enCard = enPage.locator('aside').filter({ hasText: 'Bonus credited' }).filter({ hasText: enBonus }) + await enCard.waitFor({ state: 'visible', timeout: 30_000 }) + observations.push(`en.notice=${await cardText(enCard)}`) + const enGet = platform.gets.find(entry => entry.locale === 'en_US')! + observations.push(`en.get locale=${String(enGet.locale)} query=${enGet.query}`) + expect(enGet.locale).toBe('en_US') + expect(enGet.query).toBe('') + + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'notice-flow.expected.md'), observations.join('\n'), MODE) + for (const tripwire of tripwires) { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } + await assertFixtureInventory(SNAPSHOT_DIR, ['notice-flow.expected.md']) + }, 180_000) +}) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index ef315336ea..5e0e23934a 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -717,7 +717,7 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('button', { name: 'Send message', exact: true }).click() const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])') await group.locator('[data-process-activity]').click() - await group.locator('[data-disclosure-row]').click() + await group.locator('[data-variant="think"][data-state="running"] [data-disclosure-row]').click() const body = group.locator('[data-step-process-body]') await nextPaint(world.page) expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 8c8ff2b55f..21c131a764 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -49,9 +49,6 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await trigger.click() const menu = page.getByRole('menu') await menu.waitFor() - expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ - 'Pin session', 'Rename', 'Fork session', 'Archive session', 'Export session', 'Copy session ID', - ]) expect(await menu.getByRole('separator').count()).toBe(1) await compareOrRefreshGolden( SESSION_ACTION_EXPECTED, @@ -74,9 +71,12 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ - 'Pin session', 'Rename', 'Fork session', 'Archive session', - ]) + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session'] + const remainingItems = menu.getByRole('menuitem') + expect(await remainingItems.count()).toBe(remainingNames.length) + for (const [index, name] of remainingNames.entries()) { + expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) + } expect(await menu.getByRole('separator').count()).toBe(0) expect(console.pageErrors).toEqual([]) await assertFixtureInventory(EXPECTED, [ diff --git a/apps/web/tests/composer-shortcuts.e2e.ts b/apps/web/tests/composer-shortcuts.e2e.ts new file mode 100644 index 0000000000..ce38a1b5f6 --- /dev/null +++ b/apps/web/tests/composer-shortcuts.e2e.ts @@ -0,0 +1,48 @@ +/** The shipped composer preserves drafts when an application Enter chord has extra modifiers. */ +import { chromium, type Page } from 'playwright' +import { expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' + +it('preserves the composer draft and menu for application Enter combinations', async () => { + const scaffold = await launchWebScaffold({}) + const events: SessionEvent[] = [] + const off = scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) + try { + const browser = await chromium.launch() + let failurePage: Page | undefined + try { + const page = await newEnglishPage(browser) + failurePage = page + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-shortcuts') + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + for (const draft of ['unsent draft', '/']) { + await writeComposerDraft(page, input, draft) + const markup = await input.innerHTML() + for (const chord of ['Alt+Enter', 'Meta+Alt+Enter', 'Control+Alt+Enter', 'Control+Meta+Enter', 'Meta+Shift+Enter', 'Control+Shift+Enter']) { + await input.press(chord) + expect(await input.innerHTML(), `${draft}: ${chord}`).toBe(markup) + } + await input.press('Escape') + } + await writeComposerDraft(page, input, 'first line') + await input.press('Shift+Enter') + await page.keyboard.type('second line') + expect(await input.innerText()).toBe('first line\nsecond line') + expect(events.filter(event => event.type === 'user/message')).toHaveLength(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-shortcuts') + throw error + } finally { + await browser.close() + } + } finally { + off() + await scaffold.close() + } +}) diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts index d27f430fd3..51ddce2312 100644 --- a/apps/web/tests/default-workspace.e2e.ts +++ b/apps/web/tests/default-workspace.e2e.ts @@ -34,6 +34,10 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { await page.goto(scaffold.authenticatedUrl) const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() + // Language-neutral on disk, localized in the hero chip. The chip's + // aria-label is its fixed call to action, so the label is its text. + await expect.poll(() => page.getByRole('button', { name: 'Choose workspace', exact: true }).textContent()) + .toBe('Default workspace') expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) const initialSession = scaffold.ctx.sessions.list()[0]! expect(scaffold.ctx.sessions.list()).toHaveLength(1) @@ -47,8 +51,8 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { await input.press('Enter') const sessionId = await settled const workspace = scaffold.ctx.workspaceRegistry.list()[0]! - expect(workspace.title).toBe('Default workspace') - expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) + expect(workspace.title).toBe('default-workspace') + expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'default-workspace')) expect((await stat(workspace.path)).isDirectory()).toBe(true) expect(workspace.sessionIds).toContain(sessionId) expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) @@ -71,7 +75,8 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { onTestFinished(() => scaffold.close()) const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') await mkdir(parent, { recursive: true }) - await writeFile(join(parent, '默认工作区'), 'occupied') + // A Chinese reader gets the same fixed directory name, so the same occupant conflicts. + await writeFile(join(parent, 'default-workspace'), 'occupied') const chosen = join(scaffold.workspaceCwd, 'chosen') await mkdir(chosen) const browser = await chromium.launch() diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts new file mode 100644 index 0000000000..99d0a305fb --- /dev/null +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -0,0 +1,431 @@ +// Shipped Loader composition, real account/settings APIs, and an isolated external Platform fixture. +import { createServer, type Server } from 'node:http' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { connectDesktopWelcome } from '../../desktop/src/welcome-backend.ts' +import { chromium, type Browser, type Page } from 'playwright' +import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-settings' +import { + acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { REPO_ROOT, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' + +const MODE = webSnapshotMode() +const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url)) +const NS = 'ui-settings-account' +const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false } + +describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => { + let root: string + let server: Server + let origin: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let balanceFailure = false + let balanceReads = 0 + let walletBalance = '0E-16' + let bonusBalance = '0' + let screenshots: string | undefined + + beforeEach(async () => { + balanceFailure = false + balanceReads = 0 + walletBalance = '0E-16' + bonusBalance = '0' + screenshots = undefined + root = await mkdtemp(join(tmpdir(), 'dsh-desktop-onboarding-')) + server = createServer((request, response) => { + if (request.url !== '/auth-api/v0/users/current' && request.url !== '/api/v0/users/get_user_summary') { + response.writeHead(404).end() + return + } + const balance = request.url === '/api/v0/users/get_user_summary' + if (balance) balanceReads++ + const value = balance + ? { bonus_wallets: [{ currency: 'CNY', balance: bonusBalance }], normal_wallets: [{ currency: 'CNY', balance: walletBalance }] } + : { id: 'onboarding-fixture-user', email: 'f***@example.invalid', id_profile: { name: 'Onboarding Fixture' } } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ code: 0, data: { biz_code: balance && balanceFailure ? 17 : 0, biz_data: value } })) + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture server address unavailable') + origin = `http://127.0.0.1:${address.port}` + const overlay = join(root, 'account.patch.yml') + await writeFile(overlay, `- id: deepseek-account\n config:\n platformOrigin: ${origin}\n allowLoopbackHttp: true\n`) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, extraOverlayPath: overlay }) + await scaffold.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), async () => ({ + kind: 'grant', payload: { version: 1, issuer: origin, token: 'onboarding-fixture-token' }, + })) + browser = await chromium.launch() + }) + + afterEach(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { + try { + if (server?.listening) await new Promise((resolve, reject) => { + server.close((error) => { if (error) reject(error); else resolve() }) + }) + } finally { + if (root !== undefined) await rm(root, { recursive: true, force: true }) + } + } + } + }) + + async function desktopPage(locale = ZH_BROWSER_LOCALE): Promise { + const opened = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale, reducedMotion: 'reduce' }) + const welcome = await connectDesktopWelcome(scaffold.authenticatedUrl, (input, init) => scaffold.hostFetch(input, init)) + await opened.exposeFunction('__onboardingHasApiKey', async () => (await welcome.read()).hasApiKey) + await opened.addInitScript(() => { + Object.defineProperty(globalThis, 'dshOnboarding', { value: { + hasApiKey: () => (globalThis as typeof globalThis & { __onboardingHasApiKey(): Promise }).__onboardingHasApiKey(), + setActive: () => {}, + } }) + Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) + Object.defineProperty(globalThis, 'dshPlatform', { value: { + open: async () => {}, setBounds: async () => {}, close: async () => {}, + } }) + }) + return opened + } + + async function snapshot(name: string): Promise { + await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd) + await mkdir(EXPECTED, { recursive: true }) + await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE) + if (MODE === 'refresh' && screenshots === undefined) { + const artifacts = join(REPO_ROOT, '.artifacts') + await mkdir(artifacts, { recursive: true }) + screenshots = await mkdtemp(join(artifacts, 'desktop-onboarding-')) + console.log(`Onboarding screenshots: ${screenshots}`) + } + for (const viewport of [ + { width: 880, height: 600 }, { width: 960, height: 600 }, { width: 1000, height: 780 }, { width: 1200, height: 920 }, + { width: 1440, height: 920 }, { width: 2200, height: 1200 }, + ]) { + await page.setViewportSize(viewport) + await page.evaluate(async () => { await document.fonts.ready }) + const geometry = await page.locator('[data-desktop-onboarding]').evaluate(region => ({ + scrollWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + clippedControls: [...region.querySelectorAll('h1, button, label')].filter((element) => { + const box = element.getBoundingClientRect() + const outside = box.left < 0 || box.top < 0 || box.right > window.innerWidth + 1 || box.bottom > window.innerHeight + 1 + return box.width > 0 && box.height > 0 && outside + }).map(element => element.textContent?.trim() || element.getAttribute('aria-label')), + })) + expect(geometry.scrollWidth, `${name} at ${viewport.width}: document width`).toBeLessThanOrEqual(geometry.viewportWidth) + expect(geometry.clippedControls, `${name} at ${viewport.width}: clipped controls`).toEqual([]) + const primary = page.locator('[class*="primaryAction"] > button') + if (await primary.count()) { + expect(await primary.evaluate((button) => { + const box = button.getBoundingClientRect() + return [2, box.height / 2, box.height - 2] + .every(y => button.contains(document.elementFromPoint(box.x + box.width / 2, box.y + y))) + }), `${name}: primary action receives clicks across its height`).toBe(true) + } + if (name.includes('-en-')) { + const headingFont = await page.locator('h1').evaluate(element => ({ + font: getComputedStyle(element).fontFamily, weight: getComputedStyle(element).fontWeight, + })) + expect(headingFont.font).toContain('Montserrat') + expect(headingFont.weight).toBe('300') + const brandWeights = await page.locator('h1 em').evaluateAll(elements => elements.map(element => getComputedStyle(element).fontWeight)) + expect(brandWeights.every(weight => weight === '500')).toBe(true) + expect(await page.evaluate(async () => (await document.fonts.load('500 28px Montserrat')).length)).toBeGreaterThan(0) + const copyFonts = await page.locator('[class*="heroDescription"], [class*="subtitle"], [class*="cardTitle"], [class*="cardDescription"]') + .evaluateAll(elements => elements.map(element => getComputedStyle(element).fontFamily)) + expect(copyFonts.every(font => font.includes('Montserrat'))).toBe(true) + const buttonFonts = await page.locator('[class*="navigation"] button').evaluateAll(elements => + elements.map(element => getComputedStyle(element).fontFamily)) + expect(buttonFonts.every(font => font.includes('Montserrat'))).toBe(true) + expect(await page.evaluate(async () => (await document.fonts.load('300 28px Montserrat')).length)).toBeGreaterThan(0) + } + if ((name === 'purpose' || name === 'process') && viewport.height > 760) { + const card = await page.locator('[class*="cardTitle"]').first().locator('..').boundingBox() + const action = await page.locator('[class*="primaryAction"]').boundingBox() + expect(card).not.toBeNull() + expect(action).not.toBeNull() + expect(action!.y - (card!.y + card!.height)).toBeCloseTo(name === 'process' ? 101.5 : 100, 0) + if (viewport.width === 1440 && viewport.height === 920) { + expect(card!.y).toBeCloseTo(name === 'purpose' ? 396.5 : 398, 0) + expect(card!.height).toBeCloseTo(222, 0) + } + } + if (name === 'process' && viewport.width === 960) { + const card = await page.getByRole('radio').first().boundingBox() + const action = await page.getByRole('button', { name: '进入应用', exact: true }).boundingBox() + expect(card?.x).toBeCloseTo(40, 0) + expect(card?.y).toBeCloseTo(244, 0) + expect(card?.height).toBeCloseTo(187, 0) + expect(action?.width).toBeCloseTo(240, 0) + expect(action!.y - (card!.y + card!.height)).toBeCloseTo(40, 0) + } + if (name === 'welcome' || name === 'credit') { + const illustrations = await page.locator('[class*="illustration"] > img:visible').evaluateAll(elements => elements.map((element) => { + const image = element as HTMLImageElement + const box = image.getBoundingClientRect() + return { + width: box.width, ratio: box.width / box.height, exportedRatio: image.naturalWidth / image.naturalHeight, + loaded: image.complete && image.naturalWidth > 0, + } + })) + expect(illustrations).toHaveLength(1) + for (const illustration of illustrations) { + expect(illustration.loaded).toBe(true) + expect(illustration.width).toBeGreaterThan(0) + expect(illustration.ratio).toBeCloseTo(illustration.exportedRatio, 2) + } + + } + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, `${name}-${viewport.width}x${viewport.height}.png`) }) + } + await page.setViewportSize({ width: 1280, height: 840 }) + } + + it('keeps Web unchanged, resumes App progress, and returns to credit even when balance refresh fails', async () => { + const web = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale: ZH_BROWSER_LOCALE }) + try { + await web.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await web.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await web.locator('[data-desktop-onboarding]').count()).toBe(0) + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('welcome') + } finally { await web.close() } + + page = await desktopPage() + tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'desktop-onboarding')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '开始设置', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '跳过', exact: true }).count()).toBe(0) + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(1) + await snapshot('welcome') + for (const platform of ['darwin', 'win32']) { + await page.evaluate((value) => { document.documentElement.dataset.platform = value }, platform) + expect(await page.locator('[data-desktop-onboarding]').evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe('none') + expect(await page.getByRole('button', { name: '开始设置', exact: true }).evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe(platform === 'darwin' ? 'no-drag' : 'none') + } + await page.evaluate(() => { delete document.documentElement.dataset.platform }) + await page.getByRole('button', { name: '开始设置', exact: true }).click() + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '下一步', exact: true }) + .evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(255, 255, 255)') + await snapshot('credit') + const laterButton = page.getByRole('button', { name: '下一步', exact: true }) + await laterButton.hover() + const laterHover = await laterButton.evaluate(element => ({ + fill: getComputedStyle(element).backgroundColor, overlay: getComputedStyle(element).backgroundImage, + })) + expect(laterHover.fill).toBe('rgb(255, 255, 255)') + expect(laterHover.overlay).toContain('rgba(38, 49, 72, 0.06)') + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, 'credit-later-hover.png') }) + const warningsBefore = tripwire.warnings.length + const [balanceResponse] = await Promise.all([ + page.waitForResponse('**/api/account/getBalance'), + page.reload({ waitUntil: 'load' }), + ]) + await balanceResponse.finished() + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '开始设置', exact: true }).count()).toBe(0) + await page.getByRole('button', { name: '下一步', exact: true }).click() + await page.getByRole('heading', { name: '暂时跳过充值?', exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').evaluate(element => getComputedStyle(element).filter)).toBe('blur(2px)') + expect(await page.getByRole('dialog').evaluate(element => getComputedStyle(element.previousElementSibling!).backdropFilter)).toBe('none') + await page.getByRole('button', { name: '前往充值', exact: true }).click() + await page.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).waitFor() + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('credit') + balanceFailure = true + const readsBefore = balanceReads + await page.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).click() + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + await expect.poll(() => balanceReads).toBeGreaterThan(readsBefore) + await expect.poll(() => page.getByRole('button', { name: '下一步', exact: true }).isEnabled()).toBe(true) + await page.getByRole('button', { name: '下一步', exact: true }).click() + await page.getByRole('checkbox', { name: '办公与创作', exact: true }).waitFor() + await snapshot('purpose') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const development = page.getByRole('checkbox', { name: '代码与开发', exact: true }) + const description = page.locator('[class*="purposeCards"] > div').last().locator('[class*="cardDescription"]') + for (const selected of [true, false]) { + const box = await description.boundingBox() + if (box === null) throw new Error('development description is not visible') + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + await expect.poll(() => development.isChecked()).toBe(selected) + await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + } + const title = await page.getByText('代码与开发', { exact: true }).boundingBox() + if (title === null) throw new Error('development card title is not visible') + await page.mouse.click(title.x + title.width / 2, title.y + title.height / 2) + await expect.poll(() => development.isChecked()).toBe(true) + const office = page.getByRole('checkbox', { name: '办公与创作', exact: true }) + await office.locator('..').click() + await expect.poll(() => office.isChecked()).toBe(true) + const icons = page.locator('[class*="purposeCards"] > div').first().locator('[class*="fileIcons"] > svg') + const iconMotion = await icons.evaluateAll(elements => elements.map(e => ({ + name: getComputedStyle(e).animationName, delay: getComputedStyle(e).animationDelay, + origin: getComputedStyle(e).transformOrigin, + }))) + expect(iconMotion.every(icon => icon.name.includes('iconEnter') && icon.origin.startsWith('0px'))).toBe(true) + expect(iconMotion.map(icon => icon.delay)).toEqual(['0s', '0.03s', '0.05s', '0.062s', '0.07s']) + const officeCard = page.locator('[class*="purposeCards"] > div').first() + await office.focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + const focusStyles = await officeCard.evaluate((card) => { + const checkbox = card.querySelector('input')! + return { + drag: getComputedStyle(card).getPropertyValue('-webkit-app-region'), + card: getComputedStyle(card).outlineStyle, + checkbox: getComputedStyle(checkbox).outlineStyle, + indicator: getComputedStyle(checkbox.nextElementSibling!).outlineStyle, + } + }) + expect(focusStyles).toEqual({ drag: 'no-drag', card: 'solid', checkbox: 'none', indicator: 'none' }) + await expect.poll(() => officeCard.evaluate(card => getComputedStyle(card, '::after').opacity)).toBe('1') + await page.emulateMedia({ reducedMotion: 'reduce' }) + const purposeBorders = await page.locator('[class*="purposeCards"] > div').evaluateAll(cards => cards.map(card => ({ + gradient: getComputedStyle(card, '::after').backgroundImage, + opacity: getComputedStyle(card, '::after').opacity, + }))) + expect(purposeBorders[0]?.gradient).toContain('conic-gradient(') + expect(purposeBorders[0]?.gradient).toContain('rgb(42, 47, 182)') + expect(purposeBorders[1]?.gradient).toContain('rgb(57, 100, 254)') + expect(purposeBorders.map(border => border.opacity)).toEqual(['1', '1']) + const back = page.getByRole('button', { name: '上一步', exact: true }) + const restingBackColor = await back.evaluate(element => getComputedStyle(element).color) + await back.hover() + const hoverBack = await back.evaluate(element => ({ + text: getComputedStyle(element).color, + arrow: getComputedStyle(element.querySelector('span')!).backgroundColor, + })) + expect(hoverBack.text).not.toBe(restingBackColor) + expect(hoverBack.arrow).toBe(hoverBack.text) + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, 'purpose-selected-back-hover.png') }) + await page.getByRole('button', { name: '继续', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await page.getByRole('button', { name: '上一步', exact: true }).click() + await office.waitFor() + await page.emulateMedia({ reducedMotion: 'no-preference' }) + expect(await icons.evaluateAll(elements => elements.map(e => getComputedStyle(e).animationName))).toEqual(Array(5).fill('none')) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.getByRole('button', { name: '继续', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await page.getByRole('radio', { name: /聚焦结果/ }).focus() + for (const [name, process] of [['关键细节', 'standard'], ['完整过程', 'detailed']] as const) { + const [saved] = await Promise.all([ + page.waitForResponse((response) => { + if (!response.url().endsWith('/api/settings/mutate')) return false + const request = response.request().postDataJSON() as { + payload: { args: { ns: string; ops: { op: string; path: string[]; value?: unknown }[] } } + } + return request.payload.args.ns === NS && request.payload.args.ops.some(op => + op.op === 'set' && op.path[0] === 'process' && op.value === process) + }), + page.keyboard.press('ArrowRight'), + ]) + await saved.finished() + const selected = page.getByRole('radio', { name: new RegExp(name) }) + await expect.poll(() => selected.getAttribute('aria-checked')).toBe('true') + await expect.poll(() => page.getByRole('button', { name: '进入应用', exact: true }).isEnabled()).toBe(true) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ process }) + expect(await selected.evaluate(element => document.activeElement === element)).toBe(true) + } + await page.getByRole('button', { name: '进入应用', exact: true }).waitFor() + const processBorders = await page.getByRole('radio').evaluateAll(cards => cards.map(card => getComputedStyle(card, '::after').backgroundImage)) + expect(processBorders.every(border => border.startsWith('conic-gradient('))).toBe(true) + expect(new Set(processBorders).size).toBe(3) + await snapshot('process') + await page.getByRole('button', { name: '进入应用', exact: true }).click() + await page.locator('[data-desktop-onboarding]').waitFor({ state: 'detached' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ step: 'done', process: 'detailed', usage: 'detailed', developerTools: true }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ transcriptView: 'detailed', performanceUsage: 'detailed' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: true }) + const persisted = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') + expect(persisted).toContain('id: ui-settings-account') + expect(persisted).toContain('process: detailed') + expect(persisted).toContain('transcriptView: detailed') + const warningsAfter = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsAfter) + await page.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + await page.close() + }) + + it('keeps funded credit actions stable and rechecks on reload in English dark mode', async () => { + bonusBalance = '12.50' + await scaffold.ctx.settings.mutate('ui-theme', [{ op: 'set', path: ['preference'], value: 'dark' }]) + page = await desktopPage('en-US') + const response = page.waitForResponse('**/api/account/getBalance') + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await (await response).finished() + await snapshot('welcome-en-dark') + await page.getByRole('button', { name: 'Get started', exact: true }).click() + await snapshot('credit-funded-en-dark') + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('checkbox', { name: 'Office & creative work', exact: true }).waitFor() + await snapshot('purpose-en-dark') + await page.getByRole('button', { name: 'Back', exact: true }).click() + await page.getByRole('button', { name: 'Continue', exact: true }).waitFor() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await page.getByRole('button', { name: 'Get started', exact: true }).waitFor() + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).not.toHaveProperty('creditFunded') + bonusBalance = '0' + const recheck = page.waitForResponse('**/api/account/getBalance') + await page.reload({ waitUntil: 'load' }) + await (await recheck).finished() + await page.getByRole('button', { name: 'Get started', exact: true }).click() + await page.getByRole('heading', { name: 'Add credits', exact: true }).waitFor() + expect(await page.getByRole('button', { name: 'Next', exact: true }) + .evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)') + await snapshot('credit-en-dark') + expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml') + await page.getByRole('button', { name: 'Next', exact: true }).click() + await page.getByRole('button', { name: 'Got it', exact: true }).click() + await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click() + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await snapshot('process-en-dark') + await page.close() + }) + + it('applies API-key defaults without opening App onboarding or repeating after account login', async () => { + await scaffold.ctx.credentials.deleteRecord(credentialKey('deepseek-account-platform', 'default')) + await scaffold.ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'fixture-api-key') + await scaffold.ctx.settings.mutate(NS, Object.entries(INITIAL).map(([field, value]) => ({ op: 'set', path: [field], value }))) + page = await desktopPage() + // Completion follows client activation, credential metadata, and three ordered Host writes. + const completed = page.waitForResponse((response) => { + if (!response.url().endsWith('/api/settings/mutate')) return false + const request = response.request().postDataJSON() as { payload: { args: { ns: string } } } + return request.payload.args.ns === NS + }) + const [response] = await Promise.all([completed, page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })]) + await response.finished() + expect(await response.json()).toMatchObject({ result: { ok: true } }) + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('done') + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ completion: 'api-key', process: 'standard', usage: 'detailed', developerTools: true }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ transcriptView: 'standard', performanceUsage: 'detailed' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: true }) + await scaffold.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), async () => ({ + kind: 'grant', payload: { version: 1, issuer: origin, token: 'onboarding-fixture-token' }, + })) + await page.reload({ waitUntil: 'load' }) + await page.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + await page.close() + }) +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 50088b05f7..68dd75f3d8 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -186,15 +186,23 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S const blankViewport = page.viewportSize()! try { await blankColumn.locator('[data-sidebar-right-toggle]').click() + await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) await page.setViewportSize({ width: 767, height: blankViewport.height }) + await expect.poll(() => columns(page)).toEqual([56, 711, 0]) await page.locator('[data-sidebar-right-expand]').click() + // The fullscreen shell keeps its viewport box while its docked content closes. + await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1) + await expect.poll(() => blankColumn.locator('[data-dockkit-host="dock"]:not([hidden])').evaluate(element => getComputedStyle(element).transform)) + .toBe('none') await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox()) .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height }) await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() await blankColumn.locator('[data-sidebar-right-toggle]').click() + await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) } finally { await page.setViewportSize(blankViewport) } + await expect.poll(() => columns(page)).toEqual([280, blankViewport.width - 280, 0]) await page.locator('[data-sidebar-right-expand]').click() await blankColumn.locator('[data-dockkit-add-tab]').click() await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]') diff --git a/apps/web/tests/expected/bonus-notice/notice-flow.expected.md b/apps/web/tests/expected/bonus-notice/notice-flow.expected.md new file mode 100644 index 0000000000..f2b5141aec --- /dev/null +++ b/apps/web/tests/expected/bonus-notice/notice-flow.expected.md @@ -0,0 +1,32 @@ +startup.held cards=0 acks=0 +startup.notice=赠金已到账 / 已赠送您 5.00 元 DSH 体验赠金。 +startup.ack orders=22222222-2222-4222-8222-222222222222 +startup.get locale=zh_CN query= +startup.ack method=POST path=/api/v0/users/ack_bonus_notified locale=zh_CN order_id=22222222-2222-4222-8222-222222222222 body="{\"order_id\":\"22222222-2222-4222-8222-222222222222\"}" +open.held cards=0 acks=1 gets=1 +open.shown notice=赠金已到账 / 已赠送您 8.00 元 DSH 体验赠金。 +open.shown ack orders=22222222-2222-4222-8222-222222222222,33333333-3333-4333-8333-333333333333 +open.shown painted-under-settings=true cards=1 acks=2 +open.get locale=zh_CN query= served=true gets=1 +open.balance bonus-row=true amount=true dated=false +open.cards-while-open=1 acks=2 +open.usage-link={{origin}}/usage +open.closed settings cards=1 acks=2 +open.dismissed cards=0 acks=2 +reopen.open gets=1 summaries=1 cards=0 acks=2 +reopen.section-switch gets=0 summaries=0 amount=true cards=0 acks=2 +reopen.closed cards=0 acks=2 +topup.open gets=1 summaries=1 +topup.returned bridge=open:top-up,close cards=0 +topup.notice=赠金已到账 / 已赠送您 11.00 元 DSH 体验赠金。 +topup.refresh gets=2 summaries=2 acks=1 recharge=true bonus=true +topup.closed cards=1 acks=1 +retry.notice=赠金已到账 / 已赠送您 6.00 元 DSH 体验赠金。 +retry.attempts=2 backoff=true body-kept=true cards=1 +repeat.first=赠金已到账 / 已赠送您 7.00 元 DSH 体验赠金。 +repeat.reload=赠金已到账 / 已赠送您 7.00 元 DSH 体验赠金。 +repeat.shown added-storage-keys=0 reload-added=0 acks=2 cards=1 +failed.links count=2 hrefs={{origin}}/usage,{{origin}}/usage +failed.reached usages=2 gets=1 summaries=1 +en.notice=Bonus credited / You received a CNY 9.00 DSH trial credit. +en.get locale=en_US query= diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 58ef7cfca5..4e10073523 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" [checked] + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index 1a1d3524fc..eadbe2289d 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" [checked] + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md new file mode 100644 index 0000000000..82f3bcf136 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md @@ -0,0 +1,7 @@ +- region "Add credits": + - heading "Add credits" [level=1] + - paragraph: DeepSeek Harness charges credits based on the tokens used by models and tools. Add credits in advance to keep your tasks running without interruption. Your balance is only used while the agent is working on a task. + - button "Add credits" + - button "Next" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md new file mode 100644 index 0000000000..0beb2890c9 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md @@ -0,0 +1,7 @@ +- region "Add credits": + - heading "Add credits" [level=1] + - paragraph: DeepSeek Harness charges credits based on the tokens used by models and tools. Add credits in advance to keep your tasks running without interruption. Your balance is only used while the agent is working on a task. + - button "Continue" + - button "Add credits" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/credit.expected.md b/apps/web/tests/expected/desktop-onboarding/credit.expected.md new file mode 100644 index 0000000000..0775ae3ff3 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit.expected.md @@ -0,0 +1,7 @@ +- region "准备可用额度": + - heading "准备可用额度" [level=1] + - paragraph: DeepSeek Harness 执行任务时,会根据模型与工具使用的 token 量扣除额度。请提前添加额度,避免任务在执行过程中被迫中断。你的余额只会在 Agent 实际执行任务时消耗。 + - button "去充值" + - button "下一步" + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md new file mode 100644 index 0000000000..c8c5a79aa6 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md @@ -0,0 +1,9 @@ +- region "How much detail would you like to see?": + - heading "How much detail would you like to see?" [level=1] + - paragraph: This only changes how progress is shown, not what DeepSeek Harness can do. + - radiogroup "How much detail would you like to see?": + - radio "Results only See just the results in a clean, simple interface" + - radio "Key details Focus on results, with only the most important steps and actions shown" + - radio "Full details See the full process for easier debugging and troubleshooting" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/process.expected.md b/apps/web/tests/expected/desktop-onboarding/process.expected.md new file mode 100644 index 0000000000..a2c726dd07 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/process.expected.md @@ -0,0 +1,10 @@ +- region "你希望看到多少工作过程?": + - heading "你希望看到多少工作过程?" [level=1] + - paragraph: 这只会影响工作过程的展示方式,不会影响 DeepSeek Harness 的工作能力。 + - radiogroup "你希望看到多少工作过程?": + - radio "聚焦结果 只看结果,保持界面简洁明了" + - radio "关键细节 结果优先,仅展示重要步骤和关键操作" + - radio "完整过程 查看完整工作过程,方便调试和排查问题" [checked] + - button "进入应用" + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md new file mode 100644 index 0000000000..4a9d6caf0a --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md @@ -0,0 +1,12 @@ +- region "What would you like DeepSeek Harness to help with?": + - heading "What would you like DeepSeek Harness to help with?" [level=1]: + - text: What would you like + - emphasis: DeepSeek Harness + - text: to help with? + - paragraph: We’ll tailor the interface and tools to fit the way you work. + - checkbox "Office & creative work" + - text: Office & creative work Edit documents, organize data, create presentations, and more + - checkbox "Coding & development" + - text: Coding & development Edit code, debug issues, run commands, manage project files, and more + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/purpose.expected.md b/apps/web/tests/expected/desktop-onboarding/purpose.expected.md new file mode 100644 index 0000000000..6230a763b2 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/purpose.expected.md @@ -0,0 +1,12 @@ +- region "你希望 DeepSeek Harness 帮你做什么?": + - heading "你希望 DeepSeek Harness 帮你做什么?" [level=1]: + - text: 你希望 + - emphasis: DeepSeek Harness + - text: 帮你做什么? + - paragraph: 我们会根据你的选择调整界面和工具,以更适合你的工作方式。 + - checkbox "办公与创作" + - text: 办公与创作 编辑文档、整理数据和制作演示文稿等 + - checkbox "代码与开发" + - text: 代码与开发 修改代码、调试问题、运行命令和管理项目文件等 + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md new file mode 100644 index 0000000000..609856d7ec --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md @@ -0,0 +1,6 @@ +- region "Welcome to DeepSeek Harness": + - heading "Welcome to DeepSeek Harness" [level=1]: + - text: Welcome to + - emphasis: DeepSeek Harness + - paragraph: DeepSeek Harness works in a local folder and uses tools to read and write files on your computer. It can help you research and organize information, create documents and spreadsheets, write code, troubleshoot issues, and more. + - button "Get started" diff --git a/apps/web/tests/expected/desktop-onboarding/welcome.expected.md b/apps/web/tests/expected/desktop-onboarding/welcome.expected.md new file mode 100644 index 0000000000..65fe7c298b --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/welcome.expected.md @@ -0,0 +1,6 @@ +- region "欢迎使用 DeepSeek Harness": + - heading "欢迎使用 DeepSeek Harness" [level=1]: + - text: 欢迎使用 + - emphasis: DeepSeek Harness + - paragraph: DeepSeek Harness 会以本地文件夹作为工作区,通过调用各种工具,读写本机文件,完成搜索整理资料、制作文档表格、编写代码、排查问题等各种任务。 + - button "开始设置" diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 98f72fecb8..436c32ff6e 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -5,7 +5,7 @@ - text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}} - button "Copy" - status: Worked -- button "Took {{duration}}" [disabled] +- button "Took {{duration}}" - paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md index c83912b6b8..ff8c008df0 100644 --- a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md @@ -1,6 +1,6 @@ - group "从哪个 npm 源下载插件": - - radio "默认安装源(registry.npmjs.org)" - - text: 默认安装源(registry.npmjs.org) + - radio "npm 官方源(registry.npmjs.org)" + - text: npm 官方源(registry.npmjs.org) - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - text: 中国大陆镜像源(registry.npmmirror.com) - radio "自定义地址" diff --git a/apps/web/tests/expected/plugin-install-registry/installed.expected.md b/apps/web/tests/expected/plugin-install-registry/installed.expected.md index 5c87f3edb9..414c580b00 100644 --- a/apps/web/tests/expected/plugin-install-registry/installed.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/installed.expected.md @@ -9,7 +9,7 @@ - text: 失败 $ pnpm add mirrored-package --registry=https://registry.npmmirror.com/ 退出码 1 - button "复制" - text: "ERR_PNPM_META_FETCH_FAIL GET https://registry.npmmirror.com/mirrored-package: socket hang up" - - paragraph: 第 2 次 · 默认安装源 + - paragraph: 第 2 次 · npm 官方源 - text: 已完成 $ pnpm add mirrored-package - button "复制" - text: Installed from the registry pnpm names diff --git a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md index 49612970b4..9c219b83e0 100644 --- a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md @@ -1,6 +1,6 @@ - group "从哪个 npm 源下载插件": - - radio "默认安装源(registry.npmjs.org)" [checked] - - text: 默认安装源(registry.npmjs.org) + - radio "npm 官方源(registry.npmjs.org)" [checked] + - text: npm 官方源(registry.npmjs.org) - radio "中国大陆镜像源(registry.npmmirror.com)" - text: 中国大陆镜像源(registry.npmmirror.com) - radio "自定义地址" diff --git a/apps/web/tests/expected/plugin-install-registry/picker.expected.md b/apps/web/tests/expected/plugin-install-registry/picker.expected.md index c83912b6b8..ff8c008df0 100644 --- a/apps/web/tests/expected/plugin-install-registry/picker.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/picker.expected.md @@ -1,6 +1,6 @@ - group "从哪个 npm 源下载插件": - - radio "默认安装源(registry.npmjs.org)" - - text: 默认安装源(registry.npmjs.org) + - radio "npm 官方源(registry.npmjs.org)" + - text: npm 官方源(registry.npmjs.org) - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - text: 中国大陆镜像源(registry.npmmirror.com) - radio "自定义地址" diff --git a/apps/web/tests/expected/session-archive-active/dialog.expected.md b/apps/web/tests/expected/session-archive-active/dialog.expected.md index a9faf5c4dc..2a6f4d638f 100644 --- a/apps/web/tests/expected/session-archive-active/dialog.expected.md +++ b/apps/web/tests/expected/session-archive-active/dialog.expected.md @@ -1,7 +1,7 @@ - dialog "Stop and archive this session?": - heading "Stop and archive this session?" [level=2] - button "Close" - - paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from “Show archived” in the sidebar, and the stopped work will not resume on its own. + - paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own. - list "Work that will be stopped": - listitem: The turn in progress - listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\"" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 6d08031062..2ad9903a2e 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 6d08031062..2ad9903a2e 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 8a6184330c..a4a9349ffb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md new file mode 100644 index 0000000000..3bf739802b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session A B": A B diff --git a/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md new file mode 100644 index 0000000000..256f346bd7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md @@ -0,0 +1,5 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: Already used by “Open reference menu” diff --git a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md new file mode 100644 index 0000000000..0881936876 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌘ C": ⌘ C diff --git a/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md new file mode 100644 index 0000000000..dbfac7af1a --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md @@ -0,0 +1,28 @@ +# Modal layers + +{ + "settingsInitialFocus": "General", + "settingsAfterEnter": { + "focused": "Settings", + "foreground": "Settings" + }, + "dialogsAfterSettingsToggle": 0, + "dialogsAfterReferenceToggle": 0, + "referenceBlocksSettings": { + "focused": "Keyboard shortcuts", + "foreground": "Keyboard shortcuts" + }, + "referenceOnTop": { + "focused": "Keyboard shortcuts", + "foreground": "Keyboard shortcuts" + }, + "settingsAfterReferenceToggle": { + "focused": "Settings", + "foreground": "Settings" + }, + "settingsAfterClose": { + "focused": "Settings", + "foreground": "Settings" + }, + "closedWindows": 0 +} diff --git a/apps/web/tests/expected/shortcuts-desktop/recording.expected.md b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md new file mode 100644 index 0000000000..4f4e54a0c5 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md @@ -0,0 +1,4 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" diff --git a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md new file mode 100644 index 0000000000..fd3ec59c13 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session Ctrl + C": Ctrl + C diff --git a/apps/web/tests/expected/shortcuts-panels/browser.expected.md b/apps/web/tests/expected/shortcuts-panels/browser.expected.md new file mode 100644 index 0000000000..40c2378f41 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/browser.expected.md @@ -0,0 +1,18 @@ +- tablist: + - tab "Browser Close": + - text: Browser + - button "Close" + - tab "Browser Close" [selected]: + - text: Browser + - button "Close" + - button "New tab" + - button "Split" + - button "Fullscreen" + - button "Collapse right sidebar" +- button "Back" [disabled] +- button "Forward" [disabled] +- button "Reload" [disabled] +- textbox "Enter an HTTP(S) address" +- button "Open in system browser" [disabled] +- button "Disable sandbox restrictions" +- text: Enter an HTTP(S) address to start browsing diff --git a/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md new file mode 100644 index 0000000000..c255700433 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md @@ -0,0 +1,9 @@ +- tablist: + - tab "Start" [selected] + - button "Split" + - button "Exit fullscreen" + - button "Collapse right sidebar" +- button "Workspace files Browse files in this session's workspace" +- button "New terminal Run commands in the Session workspace" +- button "Choose shell" +- button "Browser Browse web pages" diff --git a/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md new file mode 100644 index 0000000000..63ee48e589 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md @@ -0,0 +1,23 @@ +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: + - button "t6-refresh.txt" diff --git a/apps/web/tests/expected/shortcuts-panels/panels.expected.md b/apps/web/tests/expected/shortcuts-panels/panels.expected.md new file mode 100644 index 0000000000..7b6537870e --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/panels.expected.md @@ -0,0 +1,25 @@ +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close": + - text: bash + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Exit fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: Empty directory diff --git a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md new file mode 100644 index 0000000000..c8ab6426ab --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md @@ -0,0 +1,2 @@ +- menu: + - menuitem "Close" diff --git a/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md new file mode 100644 index 0000000000..074a26e764 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md @@ -0,0 +1 @@ +- alert: This session has no completed turn diff --git a/apps/web/tests/expected/shortcuts-workspace/fork.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md new file mode 100644 index 0000000000..a906aa0e5b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md @@ -0,0 +1,9 @@ +- text: Workspaces +- button "Search sessions" +- textbox "Search session names" +- button "View options" +- button "Add workspace" +- tree "Sessions": + - treeitem "Ungrouped" [expanded] + - treeitem "T4 source (1) now" + - treeitem "T4 source 1min" [selected] diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md new file mode 100644 index 0000000000..7bddffee98 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -0,0 +1,132 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts" + - region "Application": + - list: + - listitem: + - text: Open keyboard shortcuts + - button "Remove shortcut for Open keyboard shortcuts" + - button "Edit shortcut for Open keyboard shortcuts" + - button "Open keyboard shortcuts ⌘ /": ⌘ / + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: Toggle left sidebar + - button "Remove shortcut for Toggle left sidebar" + - button "Edit shortcut for Toggle left sidebar" + - button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: Add workspace + - button "Remove shortcut for Add workspace" + - button "Edit shortcut for Add workspace" + - button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings ⌘ ,": ⌘ , + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal ⌃ `"': "⌃ `" + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split ⌘ \\": ⌘ \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: Refresh current page + - button "Edit shortcut for Refresh current page" + - button "Refresh current page No shortcut": No shortcut + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: New line + - button "New line ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - region "Approval area": + - heading "Approval area" [level=3] + - list: + - listitem: + - text: Allow once + - button "Allow once Enter" [disabled]: Enter + - listitem: + - text: Reject + - button "Reject Esc" [disabled]: Esc + - contentinfo: + - button "Restore all defaults" [disabled] + - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md new file mode 100644 index 0000000000..88405f3950 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "Pin session" + - menuitem "Rename" + - menuitem "Fork session" + - menuitem "Archive session" diff --git a/apps/web/tests/expected/shortcuts/edit-stale.expected.md b/apps/web/tests/expected/shortcuts/edit-stale.expected.md new file mode 100644 index 0000000000..0293a87c04 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/edit-stale.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" [disabled] + - button "Press a shortcut" + - text: Shortcut configuration or available commands changed. Review the latest bindings before saving. Shortcut configuration or available commands changed. Review the latest bindings before saving. + - button "I have reviewed the latest configuration" diff --git a/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md new file mode 100644 index 0000000000..f359af0fc7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md @@ -0,0 +1 @@ +- alert: Already used by “Open keyboard shortcuts” diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md new file mode 100644 index 0000000000..0661c89929 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md @@ -0,0 +1,10 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts": abc + - status: No matching shortcuts + - contentinfo: + - button "Restore all defaults" + - text: 1 customized diff --git a/apps/web/tests/expected/shortcuts/en-US-inline.expected.md b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md new file mode 100644 index 0000000000..5361718dc6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: Use Ctrl+/, Ctrl+,, Ctrl+Alt+key, or Ctrl+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page. diff --git a/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md new file mode 100644 index 0000000000..5ad9adc331 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md @@ -0,0 +1,6 @@ +- dialog "Restore all default shortcuts?": + - heading "Restore all default shortcuts?" [level=2] + - button "Close confirmation" + - paragraph: Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected. + - button "Cancel" + - button "Restore default" diff --git a/apps/web/tests/expected/shortcuts/en-US-saved.expected.md b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md new file mode 100644 index 0000000000..be3933029d --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md @@ -0,0 +1 @@ +- alert: Modified diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md new file mode 100644 index 0000000000..4460a24ce0 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -0,0 +1,133 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts" + - region "Application": + - list: + - listitem: + - text: Open keyboard shortcuts + - button "Remove shortcut for Open keyboard shortcuts" + - button "Edit shortcut for Open keyboard shortcuts" + - button "Open keyboard shortcuts Ctrl + /": Ctrl + / + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session Ctrl + Alt + N": Ctrl + Alt + N + - listitem: + - text: Toggle left sidebar + - button "Remove shortcut for Toggle left sidebar" + - button "Edit shortcut for Toggle left sidebar" + - button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K + - listitem: + - text: Add workspace + - button "Remove shortcut for Add workspace" + - button "Edit shortcut for Add workspace" + - button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session Ctrl + Shift + R": Ctrl + Shift + R + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session Ctrl + Shift + F": Ctrl + Shift + F + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings Ctrl + ,": Ctrl + , + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal Ctrl + `"': "Ctrl + `" + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split Ctrl + \\": Ctrl + \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter + - listitem: + - text: Refresh current page + - button "Remove shortcut for Refresh current page" + - button "Edit shortcut for Refresh current page" + - button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: New line + - button "New line Shift + Enter" [disabled]: Shift + Enter + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - region "Approval area": + - heading "Approval area" [level=3] + - list: + - listitem: + - text: Allow once + - button "Allow once Enter" [disabled]: Enter + - listitem: + - text: Reject + - button "Reject Esc" [disabled]: Esc + - contentinfo: + - button "Restore all defaults" [disabled] + - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md new file mode 100644 index 0000000000..91f6c75f5f --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md @@ -0,0 +1 @@ +- alert: 已被「快捷键速查」占用 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md new file mode 100644 index 0000000000..8bc73cf849 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md @@ -0,0 +1,10 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键": abc + - status: 没有匹配的快捷键 + - contentinfo: + - button "恢复全部默认" + - text: 1 项已自定义 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md new file mode 100644 index 0000000000..72256d0b99 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md @@ -0,0 +1,5 @@ +- group "展开/收起左侧栏": + - button "恢复默认" + - button "移除" + - button "按下快捷键" + - text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md new file mode 100644 index 0000000000..2974c54bb9 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md @@ -0,0 +1,6 @@ +- dialog "恢复全部默认快捷键?": + - heading "恢复全部默认快捷键?" [level=2] + - button "关闭确认" + - paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。 + - button "取消" + - button "恢复默认" diff --git a/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md new file mode 100644 index 0000000000..bc0fb851da --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md @@ -0,0 +1 @@ +- alert: 已修改 diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md new file mode 100644 index 0000000000..8fa16c3e99 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -0,0 +1,132 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键" + - region "应用操作": + - list: + - listitem: + - text: 快捷键速查 + - button "移除快捷键速查快捷键" + - button "修改快捷键速查快捷键" + - button "快捷键速查 ⌘ /": ⌘ / + - listitem: + - text: 新会话 + - button "移除新会话快捷键" + - button "修改新会话快捷键" + - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: 展开/收起左侧栏 + - button "移除展开/收起左侧栏快捷键" + - button "修改展开/收起左侧栏快捷键" + - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: 搜索会话 + - button "移除搜索会话快捷键" + - button "修改搜索会话快捷键" + - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: 添加工作区 + - button "移除添加工作区快捷键" + - button "修改添加工作区快捷键" + - button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O + - listitem: + - text: 重命名会话 + - button "移除重命名会话快捷键" + - button "修改重命名会话快捷键" + - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: 分叉会话 + - button "移除分叉会话快捷键" + - button "修改分叉会话快捷键" + - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: 归档会话 + - button "移除归档会话快捷键" + - button "修改归档会话快捷键" + - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: 打开设置 + - button "移除打开设置快捷键" + - button "修改打开设置快捷键" + - button "打开设置 ⌘ ,": ⌘ , + - listitem: + - text: 展开/收起右侧栏 + - button "移除展开/收起右侧栏快捷键" + - button "修改展开/收起右侧栏快捷键" + - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: 工作区文件 + - button "移除工作区文件快捷键" + - button "修改工作区文件快捷键" + - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: 新建终端 + - button "移除新建终端快捷键" + - button "修改新建终端快捷键" + - 'button "新建终端 ⌃ `"': "⌃ `" + - listitem: + - text: 分栏 + - button "移除分栏快捷键" + - button "修改分栏快捷键" + - button "分栏 ⌘ \\": ⌘ \ + - listitem: + - text: 面板全屏/退出全屏 + - button "移除面板全屏/退出全屏快捷键" + - button "修改面板全屏/退出全屏快捷键" + - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: 刷新当前页面 + - button "修改刷新当前页面快捷键" + - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - listitem: + - text: 关闭当前页面/窗口 + - button "移除关闭当前页面/窗口快捷键" + - button "修改关闭当前页面/窗口快捷键" + - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: 停止生成 + - button "停止生成 Esc Esc" [disabled]: Esc Esc + - region "消息输入": + - heading "消息输入" [level=3] + - list: + - listitem: + - text: 使用互补的 Queue/Steer 方式 + - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: 打开引用菜单 + - button "打开引用菜单 @" [disabled]: "@" + - listitem: + - text: 换行 + - button "换行 ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: 发送消息 + - button "发送消息 Enter" [disabled]: Enter + - listitem: + - text: 打开命令菜单 + - button "打开命令菜单 /" [disabled]: / + - region "菜单与弹层": + - heading "菜单与弹层" [level=3] + - list: + - listitem: + - text: 关闭菜单或顶层弹窗 + - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc + - listitem: + - text: 移动菜单选择 + - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: 选择菜单项 + - button "选择菜单项 Enter" [disabled]: Enter + - region "审批区域": + - heading "审批区域" [level=3] + - list: + - listitem: + - text: 允许一次 + - button "允许一次 Enter" [disabled]: Enter + - listitem: + - text: 拒绝 + - button "拒绝 Esc" [disabled]: Esc + - contentinfo: + - button "恢复全部默认" [disabled] + - text: 0 项已自定义 diff --git a/apps/web/tests/expected/workspace-management/grouping-options.expected.md b/apps/web/tests/expected/workspace-management/grouping-options.expected.md index 2a91c7813d..d669656e3b 100644 --- a/apps/web/tests/expected/workspace-management/grouping-options.expected.md +++ b/apps/web/tests/expected/workspace-management/grouping-options.expected.md @@ -9,5 +9,6 @@ - menuitem "Last updated" - separator - text: Filter sessions - - menuitem "Show archived" + - menuitem "Hide archived" + - menuitem "All conversations (show archived)" - menuitem "Archived only" diff --git a/apps/web/tests/fixtures/bonus-notice/cordis.patch.yml b/apps/web/tests/fixtures/bonus-notice/cordis.patch.yml new file mode 100644 index 0000000000..11e43d1493 --- /dev/null +++ b/apps/web/tests/fixtures/bonus-notice/cordis.patch.yml @@ -0,0 +1,13 @@ +# The bonus notice scenario points the shipped account provider at a loopback +# Platform double. The scaffold substitutes {{origin}} after the listener binds, +# so no port is ever committed here. No notice timing is overridden: every read +# comes from the product's own startup and settings-refresh paths. +- id: deepseek-account + config: + platformOrigin: "{{origin}}" + allowLoopbackHttp: true +# This scenario exercises the account launcher after initial setup. +- id: ui-settings-account + config: + step: done + completion: completed diff --git a/apps/web/tests/focus-rings.e2e.ts b/apps/web/tests/focus-rings.e2e.ts index ae96380d0f..2c350fb586 100644 --- a/apps/web/tests/focus-rings.e2e.ts +++ b/apps/web/tests/focus-rings.e2e.ts @@ -137,10 +137,14 @@ async function compileFixture(): Promise<{ script: string; css: string }> { import cardCss from ${source('ui-primitives/src/HoverCard.module.css')} import pillCss from ${source('ui-primitives/src/Pill.module.css')} import trajectoryCss from ${source('ui-trajectory/src/client/TrajectoryTable.module.css')} + import onboardingCss from ${source('ui-settings-account/src/client/DesktopOnboarding.module.css')} function Fixture() { const [checked, setChecked] = useState(false) const [open, setOpen] = useState(false) return
+
+ +
@@ -255,6 +259,23 @@ describe('source-compiled supplementary focus paint', () => { return page } + it.each(['light', 'dark'])('keeps ancestor card rings silent after pointer input in %s mode', async (theme) => { + const page = await openFixture() + await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), theme === 'dark') + const checkbox = page.getByRole('checkbox', { name: 'Onboarding purpose' }) + const card = page.locator('[data-onboarding-card]') + await checkbox.click() + await page.keyboard.press('Shift') + expect((await paint(checkbox)).active).toBe(true) + expect((await paint(card)).outlineStyle).toBe('none') + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + const ring = await paint(card) + expect((await paint(checkbox)).active).toBe(true) + expect(ring.outlineStyle).toBe('solid') + expect(ring.outline).toBe(ring.brand) + }) + it('keeps Shift/Escape silent while Tab, Home, End and paging reveal a brand outline', async () => { const page = await openFixture() const toggle = page.getByRole('switch', { name: 'Toggle' }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 6592322148..e3c91a03a4 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -187,7 +187,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const stopButton = page.getByRole('button', { name: 'Stop generating' }) await stopButton.hover() - await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor() await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts index c59c7f238a..33104d40c8 100644 --- a/apps/web/tests/onboarding-native.e2e.ts +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -43,9 +43,11 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti const credentials = await readFile(credentialPath, 'utf8') await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) - await welcome.waitFor() - await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() - await welcome.waitFor({ state: 'detached' }) + if (!desktop) { + await welcome.waitFor() + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached' }) + } for (const reload of [false, true]) { if (reload) { diff --git a/apps/web/tests/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts index 1926b6f4e2..af8beaf304 100644 --- a/apps/web/tests/plugin-install-github.e2e.ts +++ b/apps/web/tests/plugin-install-github.e2e.ts @@ -62,7 +62,7 @@ it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and const spec = 'https://github.com/example/dsh-plugin.git' const title = failure === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' let dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) - await dialog.getByRole('button', { name: '安装源 默认安装源', exact: true }).waitFor() + await dialog.getByRole('button', { name: '安装源 npm 官方源', exact: true }).waitFor() await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) expect(await page.getByText(title, { exact: true }).count()).toBe(0) await dialog.getByRole('button', { name: '安装', exact: true }).click() diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index ca1ef80e6d..043dd70764 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -70,7 +70,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding const dialog = page.getByRole('dialog') // Folded, the registry control names pnpm's own registry; unfolded, its options float from it and offer the configured // mirror and a typed address. - const registryToggle = dialog.getByRole('button', { name: winner === MIRROR ? '安装源 中国大陆镜像源' : '安装源 默认安装源', exact: true }) + const registryToggle = dialog.getByRole('button', { name: winner === MIRROR ? '安装源 中国大陆镜像源' : '安装源 npm 官方源', exact: true }) await registryToggle.waitFor() expect(await page.getByRole('radio').count()).toBe(0) await registryToggle.click() @@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) } else { - expect(await options.getByRole('radio', { name: '默认安装源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true) + expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) await mirror.click() @@ -116,7 +116,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor() await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor() - await dialog.getByText('第 2 次 · 默认安装源', { exact: true }).waitFor() + await dialog.getByText('第 2 次 · npm 官方源', { exact: true }).waitFor() expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'mirrored-package': '2.0.0' } }) const installed = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) .split(process.execPath).join('{{node}}') diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index aaf4c1f7d5..73d7bcd40c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -271,6 +271,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.descriptionFontSize).toBe('10px') expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) + await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: 'attached' }) const row = page.locator('[data-presented-files-row]') await scrollIntoView(row) for (const card of await row.getByRole('button').all()) { diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 3e92ed1e34..5d718f3efe 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -437,8 +437,18 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessions = page.getByRole('tree', { name: 'Sessions' }) const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE }) await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1) + // The fixture's DSH_HOME carries two active Schedule tasks for this + // Session, so the catalog the Schedule client half reads over the tunnel + // must reach the row mark and the header entry. A composition without the + // schedule overlay, or a Host that dropped the seeded storage, shows + // neither. + await showcase.locator('[data-session-schedule-mark]').waitFor({ timeout: 15_000 }) await showcase.click() await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) + await expect.poll( + async () => page.locator('[data-schedule-reminder-entry]').getAttribute('aria-label'), + { timeout: 15_000 }, + ).toBe('2 reminders') expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) // Complete Turns can fold while earlier history is still unloaded. diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 9892033837..7b4500890a 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -80,7 +80,7 @@ describe('web e2e: queue row actions', () => { await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true) } - it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => { + it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) const readyFile = join(overrideDir, '.hang-ready') const overridePath = join(overrideDir, 'replay.override.json') @@ -290,8 +290,13 @@ describe('web e2e: queue row actions', () => { const stopButton = page.getByRole('button', { name: 'Stop generating' }) await stopButton.hover() - await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() - await stopButton.click() + await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor() + if (method === 'button') await stopButton.click() + else { + await input.focus() + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + } await firstSettled await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) .toBe(0) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 9fde2ea346..1508c9a0d9 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -812,7 +812,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ path: workspaceCwd, title: 'Workspace' })) + const initial = await ctx.workspaceRegistry.initializeDefault(async () => workspaceCwd) if (initial !== undefined) await ctx.workspaceRegistry.delete(initial.id) } const boundPort = ctx.get('webServer')?.port diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 9ce1560f7b..8a18e865ee 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -9,14 +9,15 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { bundlePatchPaths, composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' -import { ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MessageId, ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { appendDelivery } from '../../../packages/schedule/schedule/src/delivery-history.ts' +import type { ScheduleTask } from '@deepseek-ai/dsh-schedule' import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, createEveryScheduleRecord, foldScheduleEvents, - resolveEveryOccurrence, type EveryScheduleRecord, } from '@deepseek-ai/dsh-schedule' import { @@ -73,13 +74,17 @@ const WEB_PATCHES = bundlePatchPaths(WEB_BUNDLE, (JSON.parse(readFileSync(join(W const CATALOG_NOW = Date.parse('2099-08-25T12:00:00.000Z') const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e') const CATALOG_TITLE = 'Active schedule catalog' -const REMINDER_TRIGGER_NAME = /^\d+ reminders?$/ -const ACTIVE_SCHEDULE_LABEL = 'Has active scheduled task' -const CATALOG_IDS = { - after: ScheduleId('catalog-after'), - at: ScheduleId('catalog-at'), - every: ScheduleId('catalog-every'), -} as const +/** + * Task name seeded for each recorded catalog task. + * + * The recorded `schedule/change` payloads predate titles, so the Host task rows + * this lane asserts are seeded from these names rather than from the fixture. + */ +const CATALOG_TITLES: Readonly> = { + 'catalog-after': 'Review overdue deployment', + 'catalog-at': 'Join release review', + 'catalog-every': 'Check exact cadence', +} /** Emit one complete assistant text response. */ function textResponse(text: string): StreamChunk[] { @@ -90,6 +95,15 @@ function textResponse(text: string): StreamChunk[] { ] } +/** + * Wait until the Session header carries no reminder entry. The entry stays + * mounted while a read is loading or failed, so its absence proves a successful + * read that found no active reminder for this Session. + */ +async function expectNoReminderEntry(page: Page): Promise { + await expect.poll(() => page.locator('[data-schedule-reminder-entry]').count(), { timeout: 15_000 }).toBe(0) +} + /** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ class ReminderAdapter extends LlmAdapter { override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } @@ -154,7 +168,7 @@ class BrowserZoneAtAdapter extends LlmAdapter { const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000 this.selectedAt = localAt(target, AT_BROWSER_ZONE) this.scheduledAt = new Date(target).toISOString() - const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, at: this.selectedAt }) + const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, title: AT_PROMPT, at: this.selectedAt }) const callId = ToolCallId('schedule-at-browser-zone') yield { type: 'block-start', index: 0, blockType: 'tool-call' } yield { @@ -241,25 +255,6 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { - const workspace = page.locator('[role="treeitem"]').first() - await workspace.waitFor({ timeout: 15_000 }) - const deadline = Date.now() + 5_000 - while (await workspace.getAttribute('aria-expanded') !== 'true') { - if (Date.now() >= deadline) throw new Error('workspace item did not expand') - await workspace.click() - await new Promise(resolve => setTimeout(resolve, 50)) - } - const row = page.getByRole('treeitem', { name: new RegExp(title) }) - await row.waitFor({ timeout: 15_000 }) - await row.click() - // The current crumb renders as plain text, not a button. - await page.getByRole('navigation', { name: 'Session hierarchy' }) - .getByText(title, { exact: true }) - .waitFor({ timeout: 15_000 }) -} - describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { let scaffold: WebScaffold let afterHandle: AgentHandle @@ -324,19 +319,23 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-after-create'), name: 'schedule_create', - arguments: { prompt: AFTER_PROMPT, after_seconds: 1 }, + arguments: { prompt: AFTER_PROMPT, title: AFTER_PROMPT, after_seconds: 1 }, agent: afterHandle.agent, }) if (afterCreated.isError) { throw new Error(`Schedule After create failed: ${JSON.stringify(afterCreated.value)}`) } - expect(afterCreated.value).toMatchObject({ - id: 'schedule-1', + const createdView: unknown = afterCreated.value + if (typeof createdView !== 'object' || createdView === null || !('id' in createdView) || typeof createdView.id !== 'string') { + throw new Error(`Schedule After create returned no task id: ${JSON.stringify(createdView)}`) + } + expect(createdView.id).toMatch(/^schedule-/) + expect(createdView).toMatchObject({ kind: 'after', prompt: AFTER_PROMPT, afterSeconds: 1, state: 'scheduled', - deliveryMode: 'session-local', + deliveryMode: 'host', }) afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000) await afterHandle.agent.whenIdle() @@ -359,22 +358,25 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { EVERY_PROMPTS[0], EVERY_INTERVAL_SECONDS, seededAt - EVERY_FIXTURE_AGE_MS, + 'Every primary', ), createEveryScheduleRecord( ScheduleId('schedule-every-secondary'), EVERY_PROMPTS[1], EVERY_INTERVAL_SECONDS, seededAt - EVERY_FIXTURE_AGE_MS, + 'Every secondary', ), ] + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') for (const record of everyRecords) { - everyHandle.agent.session.append('schedule/change', { - version: 1, - operation: 'create', - schedule: record, - }) + await domain.table('tasks').put(record.id, { sessionId: everyHandle.agent.id, record, status: 'active' }) } - await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true) + const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, { + prompt: 'Unused future scheduling wake', title: 'Future wake', after_seconds: 86_400, + }) + await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id }) await workspace.attachSession(everyHandle.agent.id) const everyListed = await scaffold.ctx.tools.execute({ signal: AbortSignal.timeout(10_000), @@ -461,52 +463,87 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await captureStableAria(page, selector, scaffold.workspaceCwd), MODE, ) - expect(await page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count()).toBe(0) + await expectNoReminderEntry(page) + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => ( + task.sessionId === afterHandle.agent.id && task.status === 'inactive' && task.lastDelivery !== undefined + ))).toBe(true) + const delivered = (await scaffold.ctx.schedule.catalog()).find(task => task.sessionId === afterHandle.agent.id) + if (delivered?.lastDelivery === undefined) throw new Error('delivered one-shot receipt was not retained') + expect(await scaffold.ctx.schedule.list({ sessionId: afterHandle.agent.id })).toEqual([]) + // Archiving the Session shown in the main pane returns the app to its + // workspace-selection view and unmounts the Tasks page with it, so the + // linked-Session assertions run with another Session in the main pane. + await page.getByRole('treeitem', { name: /Explicit local-time reminder/ }).click() + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'Inactive', exact: true }).click() + await manager.getByRole('list', { name: 'Task catalog' }) + .getByRole('button', { name: AFTER_PROMPT, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + expect(await detail.getByRole('tab', { name: 'Rules', exact: true }).getAttribute('aria-selected')).toBe('true') + expect(await detail.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) + await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }).waitFor() + const receipt = detail.getByRole('region', { name: 'Saved delivery record' }) + await receipt.waitFor() + expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) + // The row identifies its occurrence by the scheduled instant it renders. + expect(await receipt.locator(`time[datetime="${delivered.lastDelivery.scheduledAt}"]`).count()).toBe(1) + expect(await receipt.textContent()).toContain(AFTER_PROMPT) + expect(await detail.getByText('Next scheduled time', { exact: true }).count()).toBe(0) + // The linked-session row renders on the Rules view only. + await detail.getByRole('tab', { name: 'Rules', exact: true }).click() + const linkedSession = detail.getByRole('button', { name: /^Linked session/ }) + await scaffold.ctx.workspaceRegistry.archiveSession(afterHandle.agent.id) + try { + await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(true) + expect(await detail.getByText('The original session is archived.', { exact: true }).count()).toBe(1) + expect(await detail.getByText('Scheduled After follow-up', { exact: true }).count()).toBe(1) + const moreActions = detail.getByRole('button', { name: 'Automation task actions', exact: true }) + await moreActions.click() + const deleteAction = page.getByRole('menuitem', { name: 'Delete task', exact: true }) + await deleteAction.waitFor({ timeout: 15_000 }) + expect(await deleteAction.isDisabled()).toBe(false) + await page.keyboard.press('Escape') + // Dismissing the menu leaves the detail it was opened from: the Escape + // must not reach the page's own handler and close the whole panel. + await expect.poll(() => page.getByRole('menu').count(), { timeout: 5_000 }).toBe(0) + await detail.waitFor({ timeout: 5_000 }) + expect((await scaffold.ctx.schedule.catalog()).find(task => task.id === delivered.id)).toEqual(delivered) + } finally { + await scaffold.ctx.workspaceRegistry.unarchiveSession(afterHandle.agent.id) + } + await linkedSession.waitFor({ timeout: 15_000 }) + await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(false) + await linkedSession.click() + await expectNoReminderEntry(page) }, 60_000) - it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => { + it('batches one latest occurrence per overdue Every record and advances both schedules', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every')) - const ids = new Set(everyRecords.map(record => record.id)) - const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( - event.type === 'schedule/change' - && event.data.operation === 'dispatch' - && ids.has(event.data.id) - )) - expect(dispatches).toHaveLength(2) - const acceptedAt = dispatches.map((event) => { - if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch' - || !('acceptedAt' in event.data)) throw new Error('expected Every dispatch') - return event.data.acceptedAt - }) - expect(new Set(acceptedAt).size).toBe(1) - const decision = acceptedAt[0] - if (decision === undefined) throw new Error('missing Every decision time') - - const batch = everyHandle.agent.session.snapshotEvents().find(event => ( + const messages = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'user/message' && event.data.source.kind === 'schedule' - && event.data.content.some(block => block.type === 'text' - && block.text.startsWith('[SCHEDULE REMINDER BATCH]')) )) - if (batch?.type !== 'user/message') throw new Error('missing Every batch message') - const batchBlock = batch.data.content.find(block => block.type === 'text') - if (batchBlock?.type !== 'text') throw new Error('missing Every batch text') + expect(messages).toHaveLength(1) + const texts = messages.map((event) => { + if (event.type !== 'user/message') throw new Error('expected reminder message') + return event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + }) for (const record of everyRecords) { - const occurrenceAt = resolveEveryOccurrence(record, Date.parse(decision)).occurrenceAt - expect(batchBlock.text).toContain(JSON.stringify({ - schedule_id: record.id, - occurrence_at: occurrenceAt, - reminder_prompt: record.prompt, - }).slice(1, -1)) + expect(texts.filter(text => text.includes(record.id))).toHaveLength(1) + expect(texts.some(text => text.includes(record.prompt))).toBe(true) } expect(everyAdapter.requests).toHaveLength(1) - const reminderRequest = everyAdapter.requests[0] - if (reminderRequest === undefined) throw new Error('model did not receive the Every batch') - expect(requestText(reminderRequest)).toContain(batchBlock.text) - expectReminderFraming(reminderRequest) - const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active + for (const request of everyAdapter.requests) expectReminderFraming(request) + const active = await scaffold.ctx.schedule.list({ sessionId: everyHandle.agent.id }) expect(active).toHaveLength(2) - expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true) + for (const record of active) { + const original = everyRecords.find(candidate => candidate.id === record.id) + if (original === undefined) throw new Error('unexpected recurring record') + expect(Date.parse(record.scheduledAt) - Date.parse(original.scheduledAt)) + .toBe(EVERY_INTERVAL_SECONDS * 1_000) + } const session = page.getByRole('treeitem', { name: /Fixed-rate reminder batch/ }) await session.click() @@ -523,6 +560,66 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { ) await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0) + + const original = everyRecords[0] + const first = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 }) + if ('code' in first || first.records.length !== 1) throw new Error('expected one actual recurring receipt') + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: original.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + const receipts = detail.getByRole('region', { name: 'Saved delivery record' }) + const recordsPanel = detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }) + await expect.poll(() => receipts.count()).toBe(1) + expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) + + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') + const latest = first.records[0]! + // Make the next occurrence due through the fixture's existing stored-time input. + await domain.table('tasks').put(original.id, { + sessionId: everyHandle.agent.id, + record: { ...original, scheduledAt: new Date(Date.now() - 1_000).toISOString() }, + status: 'active', + lastDelivery: { scheduledAt: latest.scheduledAt, deliveredAt: latest.deliveredAt, messageId: latest.messageId }, + deliveryHistory: { records: [...first.records].reverse(), earlierRecordsUnavailable: first.earlierRecordsUnavailable }, + }) + const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, { prompt: 'Future history refresh wake', title: 'History refresh wake', after_seconds: 86_400 }) + await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id }) + await expect.poll(() => receipts.count(), { timeout: 15_000 }).toBe(2) + const saved = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 }) + if ('code' in saved) throw new Error('recurring delivery history was unavailable') + expect(saved.records).toHaveLength(2) + expect(saved.records[1]).toEqual(latest) + expect(saved.records[0]?.messageId).not.toBe(latest.messageId) + for (const record of saved.records) { + // Each saved occurrence renders its own scheduled instant. + expect(await recordsPanel.locator(`time[datetime="${record.scheduledAt}"]`).count()).toBe(1) + } + expect(await detail.getByRole('tab', { name: 'Delivery records', exact: true }).getAttribute('aria-selected')).toBe('true') + await detail.getByRole('tab', { name: 'Rules', exact: true }).click() + // The interval row states its quantity in the friendly unit the stored + // seconds select: 3600 seconds is one whole hour, so the row shows 1 and + // doubling the interval is two of those hours. + const interval = detail.getByLabel('Repeat every', { exact: true }) + expect(await interval.inputValue()).toBe('1') + await interval.fill('2') + // Run-time rows edit a local draft; the save bar is the only commit path. + await detail.getByText('Unsaved changes', { exact: true }).waitFor({ timeout: 15_000 }) + await detail.getByRole('button', { name: 'Save changes', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === original.id), { timeout: 15_000 }) + .toMatchObject({ + id: original.id, sessionId: everyHandle.agent.id, prompt: original.prompt, + kind: 'every', everySeconds: EVERY_INTERVAL_SECONDS * 2, status: 'active', + }) + expect(await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })).toEqual(saved) + await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await expect.poll(() => receipts.count()).toBe(2) + // The linked-session row renders on the Rules view only. + await detail.getByRole('tab', { name: 'Rules', exact: true }).click() + await detail.getByRole('button', { name: 'Linked session Fixed-rate reminder batch: open original session', exact: true }).click() + await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 }) }, 60_000) it('uses request-local browser context to create an explicit local At reminder', async () => { @@ -556,26 +653,21 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { event.type === 'tool/call' && event.data.name === 'schedule_create' )) if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call') - expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt }) - const created = atHandle.agent.session.snapshotEvents().find(event => ( - event.type === 'schedule/change' - && event.data.operation === 'create' - && event.data.schedule.kind === 'at' + expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, title: AT_PROMPT, at: selectedAt }) + const result = atHandle.agent.session.snapshotEvents().find(event => ( + event.type === 'tool/result' && event.data.message.toolCallId === toolCall.data.callId )) - if (created?.type !== 'schedule/change' || created.data.operation !== 'create') { - throw new Error('explicit local At call did not create a durable record') - } - const schedule = created.data.schedule - expect(schedule).toMatchObject({ - kind: 'at', - prompt: AT_PROMPT, - scheduledAt, - }) - expect(atHandle.agent.session.snapshotEvents().filter(event => ( - event.type === 'schedule/change' - && event.data.operation === 'dispatch' - && event.data.id === schedule.id - ))).toHaveLength(1) + if (result?.type !== 'tool/result') throw new Error('missing schedule_create result') + expect(result.data.message.isError).not.toBe(true) + const resultText = result.data.message.content.find(block => block.type === 'text') + if (resultText?.type !== 'text') throw new Error('missing schedule_create response fields') + expect(JSON.parse(resultText.text)).toMatchObject({ kind: 'at', prompt: AT_PROMPT, scheduledAt }) + const reminders = atHandle.agent.session.snapshotEvents().filter(event => ( + event.type === 'user/message' + && event.data.source.kind === 'schedule' + )) + expect(reminders).toHaveLength(1) + expect(await scaffold.ctx.schedule.list({ sessionId: atHandle.agent.id })).toEqual([]) expect(atAdapter.requests).toHaveLength(4) const reminderRequest = atAdapter.requests[3] if (reminderRequest === undefined) throw new Error('model did not receive the At reminder') @@ -594,7 +686,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await captureStableAria(page, selector, scaffold.workspaceCwd), MODE, ) - expect(await page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count()).toBe(0) + await expectNoReminderEntry(page) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) @@ -620,18 +712,21 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { extraOverlayPath: OVERLAY, }) 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 + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') + // The fixture holds the bytes its writer produced before titles existed, so + // each recorded task is seeded under the name this catalog lane asserts. + const seeded = records.map(record => ({ ...record, title: CATALOG_TITLES[record.id] })) + for (const record of seeded) { + await domain.table('tasks').put(record.id, { sessionId: CATALOG_SESSION_ID, record, status: 'active' }) + } + expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual(seeded) + expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined() + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) await workspace.attachSession(CATALOG_SESSION_ID) - // Seed the zero-I/O list view before the Session is opened. - const catalogReader = await scaffold.ctx.sessionPersistence.open(CATALOG_SESSION_ID, 'read') - try { - const catalogEvents = [...(await catalogReader.read()).events] - scaffold.ctx.sessionProjectionCache.coldSnapshot(catalogReader.header, catalogReader.inheritedEventCount, catalogEvents) - } finally { - await catalogReader.close() - } - browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 900, height: 900 }, @@ -649,15 +744,6 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await openSidebar.click() await page.getByRole('button', { name: 'Collapse sidebar' }).waitFor({ timeout: 10_000 }) } - const workspaceRow = page.locator('[role="treeitem"]').first() - await workspaceRow.waitFor({ timeout: 15_000 }) - const expansionDeadline = Date.now() + 5_000 - while (await workspaceRow.getAttribute('aria-expanded') !== 'true') { - if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand') - await workspaceRow.click() - await new Promise(resolve => setTimeout(resolve, 50)) - } - await page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }).waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { @@ -668,7 +754,86 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { if (failures.length > 1) throw new AggregateError(failures, 'Schedule catalog teardown failed') }) - it('replays the overlay-only catalog and sidebar marker, then removes both live', async () => { + it('manages cross-Session tasks from the sidebar without activating their Sessions', async () => { + const otherSessionId = SessionId('schedule-manager-other-session') + await seedSession(scaffold, await readFile(CATALOG_FIXTURE, 'utf8'), otherSessionId, 'standard') + const other = await scaffold.ctx.schedule.create(otherSessionId, { + prompt: 'Inspect the second workspace deployment', title: 'Second workspace deployment', at: '2099-08-25T12:08:00.000Z', + }) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor() + const tasks = manager.getByRole('list', { name: 'Task catalog' }) + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4) + expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined() + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + + // The filter row carries the status group alone, whose first chip is the + // unrestricted state; the repeating task is reached by name instead of a + // retired type chip. + const statuses = manager.getByRole('group', { name: 'Task status' }) + expect(await statuses.getByRole('button').count()).toBe(3) + await manager.getByRole('button', { name: 'All', exact: true }).click() + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4) + expect(await tasks.textContent()).toContain('Check exact cadence') + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('second workspace') + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(1) + await tasks.getByRole('button', { name: other.title, exact: true }).click() + const details = manager.getByRole('complementary', { name: 'Task details' }) + expect(await details.textContent()).toContain(otherSessionId) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + await details.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await details.getByText('No delivery record available', { exact: true }).waitFor() + expect(await details.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + await details.getByRole('tab', { name: 'Rules', exact: true }).click() + + await page.setViewportSize({ width: 600, height: 340 }) + // The linked Session is a seat in the detail's tab strip, above the rule's + // scroll region, so it stays fully visible however far that region scrolls. + const contextRow = details.locator('[class*="detailContext"]') + const linkedSession = contextRow.getByRole('button', { name: 'Linked session: open original session', exact: true }) + expect(await contextRow.getByRole('button').count()).toBe(1) + expect(await linkedSession.textContent()).toContain(otherSessionId) + expect(await contextRow.evaluate(element => element.closest('[class*="detailScroll"]') === null)).toBe(true) + await details.locator('[class*="detailScroll"]').evaluate((element) => { element.scrollTop = element.scrollHeight }) + const linkedBounds = await linkedSession.boundingBox() + expect(linkedBounds).not.toBeNull() + expect(linkedBounds!.y).toBeGreaterThanOrEqual(0) + expect(linkedBounds!.y + linkedBounds!.height).toBeLessThanOrEqual(340) + expect(await details.getByRole('button', { name: 'Automation task actions', exact: true }).isVisible()).toBe(true) + await page.setViewportSize({ width: 900, height: 900 }) + + const moreActions = details.getByRole('button', { name: 'Automation task actions', exact: true }) + await moreActions.click() + await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Delete this task?' }) + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)).toBe(true) + await moreActions.click() + await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() + await confirmation.getByRole('button', { name: 'Confirm deletion', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)) + .toBe(false) + // A confirmed deletion closes the surface that asked for it and reports the + // outcome through the app-wide toast, so the notice outlives the panel that + // raised it. The Host removed the row with its saved records, so neither the + // rule view nor the records view can read the task again. + await expect.poll(() => details.count()).toBe(0) + await page.getByText('Task deleted.', { exact: true }).waitFor({ timeout: 15_000 }) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('Check exact cadence') + await tasks.getByRole('button', { name: 'Check exact cadence', exact: true }).click() + // This Session's catalog row carries no title before the Session is opened, + // so its link shows the Session id and is named without one. + await manager.getByRole('button', { name: 'Linked session: open original session', exact: true }).click() + await liveAgent(scaffold, CATALOG_SESSION_ID) + await page.getByRole('button', { name: '3 reminders', exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('reads stored tasks without a live Session and deletes them through the catalog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const base = composeEntries([ loadOverlayPatches('Schedule catalog base roster', BASE_PATCH), @@ -688,34 +853,13 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { disabled: false, }) - const catalogRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await page.getByRole('button', { name: 'Search sessions' }).click() - const search = page.getByPlaceholder('Search session names', { exact: false }) - await search.fill(CATALOG_TITLE) - const result = page.getByRole('tree', { name: 'Search results' }) - .getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - await result.waitFor({ timeout: 15_000 }) - expect(await result.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - expect(await result.getByRole('button').count()).toBe(0) - - await page.getByRole('button', { name: 'Clear search' }).click() - await catalogRow.waitFor({ timeout: 15_000 }) - - await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'In one list' }).click() - const flatRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - await flatRow.waitFor({ timeout: 15_000 }) - expect(await flatRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click() - await catalogRow.waitFor({ timeout: 15_000 }) - expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await openSession(page, CATALOG_TITLE) - const parentAgent = await liveAgent(scaffold, CATALOG_SESSION_ID) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click() + await manager.getByRole('button', { name: `Linked session ${CATALOG_TITLE}: open original session`, exact: true }).click() + await liveAgent(scaffold, CATALOG_SESSION_ID) + await page.getByRole('navigation', { name: 'Session hierarchy' }) + .getByText(CATALOG_TITLE, { exact: true }).waitFor({ timeout: 15_000 }) const trigger = page.getByRole('button', { name: '3 reminders' }) await trigger.waitFor({ timeout: 15_000 }) @@ -755,13 +899,16 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) const cadenceRow = catalog.getByRole('listitem').filter({ hasText: 'Check exact cadence' }) - const longPrompt = longRow.locator(':scope > span').nth(1) + // The row's opener is a block box, so its own width is the one a stored name + // can widen: this lane's 19-character seeded name renders inside it without + // pushing the popover past its own width, and a longer stored name wraps + // through the same `overflow-wrap: anywhere` declaration the stylesheet + // states for the title. + const longPrompt = longRow.getByRole('button', { name: 'Open reminder details: Join release review' }) const promptLayout = await longPrompt.evaluate(element => ({ - height: element.getBoundingClientRect().height, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, })) - expect(promptLayout.height).toBeGreaterThan(18) expect(promptLayout.scrollWidth).toBeLessThanOrEqual(promptLayout.clientWidth) const [longRowLayout, cadenceRowLayout] = await Promise.all([ longRow.evaluate((element) => { @@ -785,11 +932,23 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { } expect(longRowLayout.bottom).toBeLessThanOrEqual(cadenceRowLayout.top) expect(cadenceRowLayout.bottom).toBeGreaterThan(cadenceRowLayout.top) + // The popover caps its height at min(420px, calc(100vh - 140px)) + // (ScheduleCatalogAction.module.css). Each of the three rows keeps its 48px + // min-height plus the 6px list padding and two 1px gaps, so the list needs + // at least 152px; a 260px viewport leaves a 120px budget, which must clamp + // the list and make the clip scroll. + const clampedViewportHeight = 260 + const clampedHeightCap = Math.min(420, clampedViewportHeight - 140) + const naturalHeight = await catalog.evaluate(element => element.clientHeight) + expect(naturalHeight).toBeGreaterThan(clampedHeightCap) + await page.setViewportSize({ width: 900, height: clampedViewportHeight }) const scrollLayout = await catalog.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) + expect(scrollLayout.clientHeight).toBeLessThan(naturalHeight) expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) + await page.setViewportSize({ width: 900, height: 900 }) const evidenceDir = join(REPO_ROOT, '.artifacts') await mkdir(evidenceDir, { recursive: true }) @@ -812,19 +971,44 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { MODE, ) - const sessionRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - expect(await sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - for (const id of Object.values(CATALOG_IDS)) { - parentAgent.session.append('schedule/change', { version: 1, operation: 'delete', id }) + // The next-run line states the target as the reader's local clock stamp + // followed by the distance to it in parentheses: the stamp is the instant in + // this device's zone, and only the parenthesized distance ticks. + const nextRunLine = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) + .locator('[class*="nextRun"]') + await expect.poll(() => nextRunLine.count(), { timeout: 15_000 }).toBeGreaterThan(0) + const metadataText = await nextRunLine.first().textContent() ?? '' + const nextRun = await nextRunLine.first().locator('time').getAttribute('dateTime') + if (nextRun === null) throw new Error('the next-run line names no instant') + const nextRunAbsolute = new Intl.DateTimeFormat('en', { + month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', + hour12: false, timeZone: AT_BROWSER_ZONE, + }).format(new Date(nextRun)) + expect(metadataText).toContain(`Next run ${nextRunAbsolute}`) + expect(metadataText).toMatch(/Next run [^)]+\((?:in \d+ \w+|Due now|\d+ .*overdue)\)/) + // The stamp is a local clock time, not a bare duration. + expect(metadataText).toMatch(/\d{1,2}:\d{2}/) + // The delete glyph is an aria-hidden SVG, so the ARIA golden cannot prove it + // still renders; the DOM has to. + const deleteButton = catalog.getByRole('button', { name: /^Delete reminder:/ }).first() + await expect.poll(() => deleteButton.locator('svg').count(), { timeout: 15_000 }).toBe(1) + + for (let remaining = 3; remaining > 0; remaining--) { + await catalog.getByRole('button', { name: /^Delete reminder:/ }).first().click() + await expect.poll(() => catalog.getByRole('button', { name: /^Delete reminder:/ }).count(), { timeout: 15_000 }) + .toBe(remaining - 1) } - await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true) - await expect.poll(() => page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count(), { - timeout: 15_000, - }).toBe(0) - expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0) - await expect.poll(() => sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count(), { - timeout: 15_000, - }).toBe(0) + await expectNoReminderEntry(page) + expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual([]) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expectNoReminderEntry(page) + // This authored pin owns its sidecars through the snapshot manifest's + // `header.pin: true` contract, which the session-snapshot corpus enforces + // (`scripts/session-snapshot-corpus.corpus.ts`). The sidecars have no replay + // comparison here: this scenario seeds its fixture and then runs further + // turns, so `assertReplaySession` (enabled only by `replayFixture`) has no + // matching recording to compare against. await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [ 'catalog.expected.md', 'session.v3.jsonl', @@ -834,4 +1018,168 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) + + it('shows the pruning footer only for confirmed cleanup after the final saved page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-retention')) + const record = await scaffold.ctx.schedule.create(CATALOG_SESSION_ID, { + prompt: 'Review retained records', title: 'History retention review', at: '2099-09-01T12:00:00.000Z', + }) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'All', exact: true }).click() + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(record.title) + await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: record.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + const recordsTab = detail.getByRole('tab', { name: 'Delivery records', exact: true }) + const rulesTab = detail.getByRole('tab', { name: 'Rules', exact: true }) + const notice = detail.getByText('Earlier delivery records have been cleared', { exact: true }) + await recordsTab.click() + await detail.getByText('No delivery record available', { exact: true }).waitFor() + expect(await notice.count()).toBe(0) + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') + const instant = new Date().toISOString() + const receipt = { scheduledAt: instant, deliveredAt: instant, messageId: MessageId('retention-legacy') } + await rulesTab.click() + await domain.table('tasks').put(record.id, { + sessionId: CATALOG_SESSION_ID, record, status: 'inactive', lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: true }, + }) + await recordsTab.click() + const saved = detail.getByRole('region', { name: 'Saved delivery record' }) + await expect.poll(() => saved.count()).toBe(1) + expect(await notice.count()).toBe(0) + await rulesTab.click() + let task: ScheduleTask = { + sessionId: CATALOG_SESSION_ID, record, status: 'inactive', + deliveryHistory: { records: [], earlierRecordsUnavailable: false }, + } + for (let index = 0; index < 23; index++) { + const time = new Date(Date.now() - (index === 0 ? 40 * 86_400_000 : (23 - index) * 3_600_000)).toISOString() + task = { ...task, ...appendDelivery(task, { + scheduledAt: time, deliveredAt: time, messageId: MessageId(`retention-${index}`), + }, { days: 30, records: 200 }) } + } + expect(task.deliveryHistory?.records).toHaveLength(22) + expect(task.deliveryHistory?.earlierRecordsPruned).toBe(true) + await domain.table('tasks').put(record.id, task) + await recordsTab.click() + await expect.poll(() => saved.count()).toBe(20) + expect(await notice.count()).toBe(0) + await detail.getByRole('button', { name: 'Load more', exact: true }).click() + await notice.waitFor() + expect(await saved.count()).toBe(22) + const info = detail.getByRole('button', { name: 'Retention rules', exact: true }) + await info.click() + const bounds = detail.getByText('Each task keeps up to 200 delivery records from the last 30 days.', { exact: true }) + await bounds.waitFor() + expect(await detail.locator('footer').innerText()).toMatchInlineSnapshot(` + "Earlier delivery records have been cleared + + Each task keeps up to 200 delivery records from the last 30 days. + + When a new record is saved, older records outside these limits are cleared automatically. Clearing records does not stop the task." + `) + await info.click() + expect(await bounds.count()).toBe(0) + const panelBox = await detail.boundingBox() + const footerBox = await detail.locator('footer').boundingBox() + expect(panelBox).not.toBeNull() + expect(footerBox).not.toBeNull() + expect(Math.abs(footerBox!.x - panelBox!.x)).toBeLessThan(2) + expect(Math.abs(footerBox!.y + footerBox!.height - panelBox!.y - panelBox!.height)).toBeLessThan(2) + expect(await detail.locator('footer').evaluate(element => getComputedStyle(element).borderTopWidth)).toBe('0px') + expect(tripwire.pageErrors).toEqual([]) + } finally { + await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: record.id }) + } + }, 60_000) + + it('shows a tool-created daily rule as repeating with its original time zone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-daily')) + const agent = await liveAgent(scaffold, CATALOG_SESSION_ID) + const timeZone = 'America/New_York' + const clock = new Intl.DateTimeFormat('en-GB-u-nu-latn', { + timeZone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }) + const time = clock.format(new Date(Date.now() + 3_600_000)) + const prompt = 'Inspect the daily calendar rule' + const result = await scaffold.ctx.tools.execute({ + signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'), + name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent, + }) + const created = (await scaffold.ctx.schedule.catalog()).find(task => ( + task.sessionId === CATALOG_SESSION_ID && task.prompt === prompt + )) + if (created === undefined) throw new Error('daily tool did not create a stored task') + try { + expect(result.isError).toBe(false) + expect(result.value).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone }) + expect(created).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone, status: 'active' }) + expect(clock.format(new Date(created.scheduledAt))).toBe(time) + await page.clock.setFixedTime(new Date()) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'All', exact: true }).click() + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(prompt) + const tasks = manager.getByRole('list', { name: 'Task catalog' }) + const row = tasks.getByRole('button', { name: prompt, exact: true }) + await row.waitFor() + // The frequency line names the stored zone by its localized label: the + // current UTC offset and the ICU generic name of that zone. Both parts + // come from the same ICU data the app reads, so the assertion follows the + // runtime's own zone names. + const zoneName = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longGeneric' }) + .formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value + const zoneOffset = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longOffset' }) + .formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value?.replace(/^GMT/, 'UTC') + const frequency = `Daily at ${time.replace(/:00$/, '')} (${zoneOffset} · ${zoneName})` + expect(await row.textContent()).toContain(frequency) + await row.click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + expect(await detail.getByText(frequency, { exact: true }).count()).toBe(1) + expect(await detail.getByText('Once', { exact: true }).count()).toBe(0) + const runTime = detail.getByRole('region', { name: 'Run time', exact: true }) + const timeField = runTime.getByRole('button', { name: 'Time', exact: true }) + // The Time zone row is a menu trigger button; the zone is chosen, never typed. + const zoneField = runTime.getByRole('button', { name: /Time zone/ }) + await zoneField.click() + // Zone rows lead with the current offset and the localized zone name, so + // the browser's own zone is the row marked as the system one. + await page.getByRole('menuitem', { name: /China Standard Time \(system\)$/ }).click() + // The Time row opens three clock columns; a pick stages the clock itself, + // and the stored rule keeps the millisecond precision the picker writes. + await timeField.click() + expect(await timeField.getAttribute('aria-expanded')).toBe('true') + await page.getByRole('listbox', { name: 'Hour', exact: true }).getByRole('option', { name: '12', exact: true }).click() + await page.getByRole('listbox', { name: 'Minute', exact: true }).getByRole('option', { name: '34', exact: true }).click() + await page.getByRole('listbox', { name: 'Second', exact: true }).getByRole('option', { name: '56', exact: true }).click() + await page.keyboard.press('Escape') + expect(await timeField.textContent()).toBe('12:34:56') + await expect.poll(() => detail.getByText('Unsaved changes', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // The staged draft reaches the Host only through the save bar's Save action. + await detail.getByRole('button', { name: 'Save changes', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id), { timeout: 15_000 }) + .toMatchObject({ time: '12:34:56.000', timeZone: 'Asia/Shanghai' }) + const saved = (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id) + expect(saved).toMatchObject({ + id: created.id, sessionId: CATALOG_SESSION_ID, prompt, status: 'active', + kind: 'daily', time: '12:34:56.000', timeZone: 'Asia/Shanghai', + }) + // A stored zone equal to the browser zone is omitted from the frequency line. + await expect.poll(() => detail.getByText('Daily at 12:34:56', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await scaffold.ctx.schedule.history({ sessionId: CATALOG_SESSION_ID, id: created.id, limit: 20 })) + .toEqual({ id: created.id, records: [], earlierRecordsUnavailable: false, + earlierRecordsPruned: false, retention: { days: 30, records: 200 } }) + // The created rule stays active, so the ended-status filter hides every + // row; the retired type chips no longer exist. + await manager.getByRole('button', { name: 'Inactive', exact: true }).click() + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: created.id }) + await page.clock.setFixedTime(new Date(CATALOG_NOW)) + } + }, 60_000) }) diff --git a/apps/web/tests/session-archive-active.e2e.ts b/apps/web/tests/session-archive-active.e2e.ts index 5f3dbaf380..d192d3d207 100644 --- a/apps/web/tests/session-archive-active.e2e.ts +++ b/apps/web/tests/session-archive-active.e2e.ts @@ -218,7 +218,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i it('the restored Session continues the conversation from a regular log', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-session-archive-active-restore')) await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click() + await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click() await expect.poll(() => sessionRow().count(), { timeout: 10_000 }).toBe(1) await clickHoverAction(sessionRow(), 'Session actions for ') await page.getByRole('menuitem', { name: 'Unarchive session' }).click() diff --git a/apps/web/tests/session-unarchive.e2e.ts b/apps/web/tests/session-unarchive.e2e.ts index 068f5c0b6b..4db30e55a7 100644 --- a/apps/web/tests/session-unarchive.e2e.ts +++ b/apps/web/tests/session-unarchive.e2e.ts @@ -102,7 +102,7 @@ describe('web e2e: archived sessions are restored from the sidebar filter', () = .toContain(SessionId(SEED_ID)) await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click() + await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click() await ungroupedSection() await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1) await clickHoverAction(sessionRow, `Session actions for ${title}`) @@ -111,8 +111,10 @@ describe('web e2e: archived sessions are restored from the sidebar filter', () = () => [...scaffold.ctx.workspaceRegistry.archivedSessionIds], { timeout: 10_000 }, ).toEqual([]) + // Back to the default filter: the restored row must be an ordinary row + // again, visible without any archived rows in the view. await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click() + await page.getByRole('menuitem', { name: 'Hide archived', exact: true }).click() await ungroupedSection() await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1) diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index b2c1a85fb0..142777bfcd 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -3,6 +3,7 @@ import { chromium, type Browser } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' +import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol' let scaffold: WebScaffold let browser: Browser @@ -22,11 +23,17 @@ describe('navigation without a selected Session', () => { const page = await newEnglishPage(browser) try { if (platform !== 'web') { - await page.addInitScript((value) => { + await page.addInitScript(({ value, snapshot }) => { + Object.assign(window, { dshDesktop: { protocolVersion: 1, + keyboard: { subscribe: () => () => {}, closeWindow: async () => {} }, + shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }), + subscribe: () => () => {}, recording: async () => {}, + edit: async () => ({ status: 'not-ready', snapshot }) }, + } }) const mark = () => { document.documentElement.setAttribute('data-platform', value) } if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) else mark() - }, platform) + }, { value: platform, snapshot: initialShortcutConfig() }) } const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts new file mode 100644 index 0000000000..6c37845f6f --- /dev/null +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -0,0 +1,372 @@ +/** Real application composition and Desktop persistence with a substituted Electron preload transport. */ +import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutCommandId, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { desktopKeybindings } from '../../desktop/src/keybindings.ts' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' +import { writeComposerDraft } from './support.ts' + +declare global { + interface Window { + desktopShortcutsGet: DesktopShortcutsApi['get'] + desktopShortcutsEdit: DesktopShortcutsApi['edit'] + shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean; closedWindows: number } + } +} + +const expected = fileURLToPath(new URL('./expected/shortcuts-desktop', import.meta.url)) +const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const mode = webSnapshotMode() + +it.each([ + { platform: 'macos', marker: 'darwin', primary: 'Meta' }, + { platform: 'windows', marker: 'win32', primary: 'Control' }, +] as const)('records, persists and prioritizes $platform Desktop single keys and chords over recorded history', async ({ platform, marker, primary }) => { + const userData = await mkdtemp(join(tmpdir(), 'dsh-shortcuts-desktop-')) + let snapshot = initialShortcutConfig() + const persistence = desktopKeybindings(userData, platform, (value) => { snapshot = value }) + try { + const scaffold = await launchWebScaffold({ developerTools: false }) + try { + await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('desktop-shortcuts-source')) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await page.exposeFunction('desktopShortcutsGet', async (definitions: unknown) => { + persistence.setDefinitions(parseShortcutDefinitions(definitions)) + return persistence.readCurrent() + }) + await page.exposeFunction('desktopShortcutsEdit', (edit: unknown, revision: ShortcutRevision) => persistence.edit(parseShortcutEdit(edit), revision)) + // Only the Electron transport is substituted; preference storage and all Client plugins are real. + await page.addInitScript((device) => { + const mark = () => { document.documentElement.dataset.platform = device } + if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark) + else mark() + const listeners = new Set<(input: DesktopShortcutInput) => void>() + const fixture = { recording: false, closedWindows: 0, + deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) }, + } + Object.assign(window, { shortcutFixture: fixture, dshDesktop: { protocolVersion: 1, + shortcuts: { + get: window.desktopShortcutsGet, edit: window.desktopShortcutsEdit, + recording: async (active: boolean) => { fixture.recording = active }, subscribe: () => () => {}, + }, + keyboard: { subscribe: (listener: (input: DesktopShortcutInput) => void) => { + listeners.add(listener); return () => { listeners.delete(listener) } + }, closeWindow: async () => { fixture.closedWindows++ } }, + } }) + }, marker) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => { + window.shortcutFixture.deliver(input) + }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code, + control: platform === 'windows', meta: platform === 'macos', alt, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + const openReference = () => deliverPrimary('Slash') + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + const composer = page.locator('[data-composer-input]').first() + await composer.focus() + await page.keyboard.insertText('Desktop focus draft') + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + for (const opening of [{ code: 'KeyP', alt: false }, { code: 'KeyB', alt: true }]) { + await composer.focus() + await deliverPrimary(opening.code, opening.alt) + await panel.waitFor() + const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => getComputedStyle(pane).outlineStyle)).toBe('none') + await deliverPrimary('KeyW') + await expect.poll(() => page.evaluate(() => ({ + closedWindows: window.shortcutFixture.closedWindows, + openPanels: document.querySelectorAll('[data-sidebar-right-open]').length, + }))).toEqual({ closedWindows: 0, openPanels: 0 }) + expect(focusedAfterOpening).toBe(true) + expect(await composer.innerText()).toBe('Desktop focus draft') + } + await composer.focus() + const selectDraft = () => composer.evaluate((element) => { + const text = element.querySelector('[data-lexical-text]')!.firstChild! + document.getSelection()!.setBaseAndExtent(text, 0, text, 7) + }) + await selectDraft() + await deliverPrimary('KeyP') + await files.waitFor() + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') + await composer.focus() + await selectDraft() + await deliverPrimary('KeyP') + expect(await files.count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') + await composer.click() + const pane = panel.locator('[data-dockkit-pane]') + await pane.click({ position: { x: 20, y: 2 } }) + expect(await pane.evaluate(element => ({ + focused: document.activeElement === element, outline: getComputedStyle(element).outlineStyle, + }))).toEqual({ focused: true, outline: 'none' }) + await page.keyboard.press('Tab') + expect(await files.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true) + expect(await files.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') + await deliverPrimary('KeyW') + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0) + expect(await composer.innerText()).toBe('Desktop focus draft') + await writeComposerDraft(page, composer, '') + await expect.poll(() => composer.textContent()).toBe('') + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + const automaticFocusStyle = () => page.evaluate(() => { + const style = getComputedStyle(document.activeElement!) + return { outline: style.outlineStyle, shadow: style.boxShadow } + }) + const noFocusRing = { outline: 'none', shadow: 'none' } + const modalState = () => page.evaluate(() => { + const name = (node: Element | null) => node?.getAttribute('aria-label') + ?? document.getElementById(node?.getAttribute('aria-labelledby') ?? '')?.textContent ?? null + return { + focused: name(document.activeElement?.closest('[role="dialog"]') ?? null), + foreground: name(document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.closest('[role="dialog"]') ?? null), + } + }) + const closeModal = async (name: 'Settings' | 'Keyboard shortcuts', source: 'keyboard' | 'menu' = 'keyboard') => { + if (source === 'menu') { + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'menu', commandId: 'page.close' as ShortcutCommandId, revision: snapshot.revision, + } satisfies DesktopShortcutInput) + } else { + await deliverPrimary('KeyW') + } + await page.getByRole('dialog', { name, exact: true }).waitFor({ state: 'hidden' }) + expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0) + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + } + await deliverPrimary('Comma') + await settings.waitFor() + const selectedCategory = settings.locator('nav button[aria-current="true"]') + expect(await selectedCategory.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const settingsShadow = await settings.evaluate(node => getComputedStyle(node).boxShadow) + const settingsInitialFocus = await selectedCategory.innerText() + await page.keyboard.press('Enter') + expect(await settings.isVisible()).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const settingsAfterEnter = await modalState() + expect(settingsAfterEnter).toEqual({ focused: 'Settings', foreground: 'Settings' }) + await page.keyboard.press('Tab') + expect((await automaticFocusStyle()).outline).not.toBe('none') + expect(await page.evaluate(() => Number.parseFloat(getComputedStyle(document.activeElement!).outlineWidth))).toBeGreaterThan(0) + await deliverPrimary('Comma') + await settings.waitFor({ state: 'hidden' }) + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + const dialogsAfterSettingsToggle = await page.getByRole('dialog').count() + expect(dialogsAfterSettingsToggle).toBe(0) + await deliverPrimary('Comma') + await settings.waitFor() + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await closeModal('Settings') + expect(await dialog.isVisible()).toBe(false) + await openReference() + await dialog.waitFor() + expect(await dialog.getByRole('searchbox').evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const referenceShadow = await dialog.evaluate(node => getComputedStyle(node).boxShadow) + await deliverPrimary('Slash') + await dialog.waitFor({ state: 'hidden' }) + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + const dialogsAfterReferenceToggle = await page.getByRole('dialog').count() + expect(dialogsAfterReferenceToggle).toBe(0) + await openReference() + await dialog.waitFor() + await deliverPrimary('Comma') + expect(await settings.isVisible()).toBe(false) + expect(await dialog.isVisible()).toBe(true) + const referenceBlocksSettings = await modalState() + expect(referenceBlocksSettings).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await dialog.evaluate(node => getComputedStyle(node).boxShadow)).toBe(referenceShadow) + await closeModal('Keyboard shortcuts') + expect(await settings.isVisible()).toBe(false) + await deliverPrimary('Comma') + await settings.waitFor() + await openReference() + await dialog.waitFor() + const referenceOnTop = await modalState() + expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow) + await deliverPrimary('Comma') + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await settings.isVisible()).toBe(true) + expect(await modalState()).toEqual(referenceOnTop) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await deliverPrimary('Slash') + await dialog.waitFor({ state: 'hidden' }) + expect(await settings.isVisible()).toBe(true) + const settingsAfterReferenceToggle = await modalState() + expect(settingsAfterReferenceToggle).toEqual({ focused: 'Settings', foreground: 'Settings' }) + expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await openReference() + await dialog.waitFor() + await closeModal('Keyboard shortcuts', 'menu') + expect(await settings.isVisible()).toBe(true) + const settingsAfterClose = await modalState() + expect(settingsAfterClose).toEqual({ focused: 'Settings', foreground: 'Settings' }) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow) + await closeModal('Settings') + const closedWindows = await page.evaluate(() => window.shortcutFixture.closedWindows) + await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'), + `# Modal layers\n\n${JSON.stringify({ settingsInitialFocus, settingsAfterEnter, dialogsAfterSettingsToggle, dialogsAfterReferenceToggle, referenceBlocksSettings, referenceOnTop, settingsAfterReferenceToggle, settingsAfterClose, closedWindows }, null, 2)}`, mode) + await openReference() + await dialog.waitFor() + const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)) + const heights = await rowHeights() + expect(new Set(heights)).toEqual(new Set([42])) + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.shortcutFixture.recording)).toBe(true) + expect(await rowHeights()).toEqual(heights) + const firstRecorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + expect(await firstRecorder.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await dialog.getByRole('button', { name: 'Cancel recording', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(expected, 'recording.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode) + await page.keyboard.press(`${primary}+C`) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual({ ...noFocusRing, shadow: referenceShadow }) + const saved: unknown = JSON.parse(await readFile(join(userData, 'keybindings.json'), 'utf8')) + expect(saved).toMatchObject({ schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'session.new': { code: 'KeyC', modifiers: [platform === 'macos' ? 'meta' : 'control'] }, + } } }) + await page.reload({ waitUntil: 'load' }) + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.getByRole('searchbox').fill('New Session') + const row = dialog.getByRole('listitem') + await expect.poll(() => row.count()).toBe(1) + await compareOrRefreshGolden(join(expected, `${platform}-copy-override.expected.md`), await row.ariaSnapshot(), mode) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await composer.focus() + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyC', + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.getByRole('searchbox').fill('New Session') + for (const code of ['a', 'F1', 'ArrowLeft', 'Tab']) { + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + await expect.poll(() => recorder.isEnabled()).toBe(true) + await recorder.focus() + await page.keyboard.press(code) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ + code: code === 'a' ? 'KeyA' : code, modifiers: [], + }) + } + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + await expect.poll(() => recorder.isEnabled()).toBe(true) + await recorder.focus() + const accepted = snapshot.document + for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + const keys = key.split('+') + for (const held of keys) await page.keyboard.down(held) + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false') + expect(await recorder.textContent()).not.toBe('Press a shortcut') + for (const held of keys.toReversed()) await page.keyboard.up(held) + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('true') + if (key === `${primary}+Slash`) { + await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor() + } + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + expect(snapshot.document).toEqual(accepted) + expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))) + .toEqual([42]) + } + await compareOrRefreshGolden(join(expected, 'fixed-conflict.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode) + const preferencePath = join(userData, 'keybindings.json') + const savedPreferencePath = join(userData, 'keybindings.saved.json') + await rename(preferencePath, savedPreferencePath) + try { + // A directory prevents atomic file replacement on every supported filesystem. + await mkdir(preferencePath) + await page.keyboard.press('j') + await dialog.getByText('Could not save. Your previous shortcuts and current draft are preserved. Please retry.', { exact: true }).waitFor() + expect(snapshot.document).toEqual(accepted) + expect(await recorder.getAttribute('aria-invalid')).toBe('true') + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + } finally { + await rm(preferencePath, { recursive: true, force: true }) + await rename(savedPreferencePath, preferencePath) + } + await page.keyboard.down('b') + await page.keyboard.down('a') + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false') + expect(await recorder.textContent()).toContain('A') + expect(await recorder.textContent()).toContain('B') + await page.keyboard.up('a') + await page.keyboard.up('b') + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ code: 'KeyA', secondCode: 'KeyB', modifiers: [] }) + await compareOrRefreshGolden(join(expected, 'chord.expected.md'), await dialog.getByRole('listitem').ariaSnapshot(), mode) + await page.reload({ waitUntil: 'load' }) + await page.getByText('DONE', { exact: true }).waitFor() + await composer.focus() + await page.keyboard.press('a'); await page.keyboard.press('b') + await expect.poll(() => composer.textContent()).toBe('ab') + await expect.poll(() => page.getByText('DONE', { exact: true }).isVisible()).toBe(true) + await page.keyboard.down('a') + await expect.poll(() => composer.textContent()).toBe('aba') + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', secondCode: 'KeyB', + control: false, meta: false, alt: false, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + await page.keyboard.up('a') + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.waitFor() + await dialog.getByRole('searchbox').focus() + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: true, repeat: false, + } satisfies DesktopShortcutInput) + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + expect(await dialog.isVisible()).toBe(true) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { await browser.close() } + } finally { await scaffold.close() } + } finally { + persistence.dispose() + await rm(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/web/tests/shortcuts-fixed.e2e.ts b/apps/web/tests/shortcuts-fixed.e2e.ts new file mode 100644 index 0000000000..02e76a4566 --- /dev/null +++ b/apps/web/tests/shortcuts-fixed.e2e.ts @@ -0,0 +1,65 @@ +/** Fixed approval keys through the real browser, Remote waterfall and recorded model command. */ +import { access, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterEach, describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-user-approval' +import { fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/approval-composer/session.v3.jsonl', import.meta.url)) + +describe('web e2e: fixed approval rejection keys', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + afterEach(async () => { + const results = await Promise.allSettled([browser?.close(), scaffold?.close()]) + browser = undefined + scaffold = undefined + const failed = results.filter(result => result.status === 'rejected') + if (failed.length > 0) throw new AggregateError(failed.map((result): unknown => result.reason), 'fixed shortcut teardown failed') + }) + + it.skipIf(webSnapshotMode() === 'record').each(['reject-button-enter', 'container-escape'] as const)( + 'rejects through %s without running the requested write', async (method) => { + const events: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.locator('[aria-label^="Access mode"]').click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await page.locator('[aria-label="Access mode, current: Read Only"]').waitFor() + const input = page.locator('[data-composer-input]').first() + const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) + const settled = scaffold.whenTurnSettled() + await input.fill(prompts[0]!) + await input.press('Enter') + const panel = page.locator('[data-approval-key]') + await panel.waitFor() + if (method === 'reject-button-enter') { + await panel.getByRole('button', { name: 'Reject', exact: true }).focus() + await page.keyboard.press('Enter') + } else { + await panel.locator('[data-approval-scroll]').focus() + await page.keyboard.press('Escape') + } + await settled + expect(events.filter(event => event.type === 'approval/decided').map(event => event.data)) + .toMatchObject([{ outcome: 'rejected' }]) + expect(events.filter(event => event.type === 'turn/end').map(event => event.data.reason.kind)) + .toEqual(['completed']) + await expect(access(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await panel.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, + ) +}) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts new file mode 100644 index 0000000000..9f3bd76b10 --- /dev/null +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -0,0 +1,287 @@ +/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */ +import { writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url)) +const mode = webSnapshotMode() + +/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */ +async function bind(page: Page, primary: string, label: string, previous?: string): Promise { + await page.keyboard.press(`${primary}+/`) + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await dialog.waitFor() + if (label === 'Split' || label === 'Toggle panel fullscreen') { + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) }) + expect(await row.getByText(label, { exact: true }).count()).toBe(1) + expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0) + expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0) + } + if (previous !== undefined) { + const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }) + await remove.click() + await remove.waitFor({ state: 'hidden' }) + } + await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() + const recorder = dialog.getByRole('group', { name: label, exact: true }) + await recorder.waitFor() + await page.keyboard.press(`${primary}+Shift+,`) + await recorder.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'detached' }) +} + +// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run. +describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { + let browser: Browser + beforeAll(async () => { + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close() }) + + it.each([ + { platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,' }, + { platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,' }, + ].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $platform bindings', async ({ platform, primary, aria, closeFirst }) => { + const scaffold: WebScaffold = await launchWebScaffold({ + extraOverlayPath: [ + fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)), + fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + ], + }) + const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } }) + try { + // Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) + if (agent === undefined) throw new Error('The workspace did not create a Session') + // A settled fixture activates the ordinary Session UI; this scenario issues no model requests. + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Panel actions are ready.', { exact: true }).waitFor() + const composer = page.locator('[data-composer-input]') + await composer.click() + await page.keyboard.insertText('T5 draft remains unsent') + await page.keyboard.press(`${primary}+Alt+Enter`) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + const eventCount = agent.session.seq + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const paneAppearance = () => panel.locator('[data-dockkit-pane]').evaluateAll(panes => panes.map((pane) => { + const style = getComputedStyle(pane) + return { outline: style.outlineStyle, shadow: style.boxShadow } + })) + const plainPane = { outline: 'none', shadow: 'none' } + + for (const { label, previous } of [ + { label: 'Browser', previous: undefined }, + { label: 'New terminal', previous: 'Browser' }, + ]) { + await bind(page, primary, label, previous) + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + const openedPage = label === 'Browser' ? panel.getByPlaceholder('Enter an HTTP(S) address') + : panel.locator('[data-sidebar-terminal]') + await openedPage.waitFor() + const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => pane.contains(document.activeElement)) + expect(await paneAppearance()).toEqual([plainPane]) + await page.keyboard.press(`${primary}+Alt+W`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count(), { message: label }).toBe(0) + expect(focusedAfterOpening, label).toBe(true) + expect(await composer.innerText()).toBe('T5 draft remains unsent') + } + + await bind(page, primary, 'Toggle right sidebar', 'New terminal') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + await panel.waitFor() + expect(await paneAppearance()).toEqual([plainPane]) + expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria) + await panel.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.keyboard.press(`${primary}+Shift+,`) + await panel.waitFor() + + const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first() + await terminalGuide.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.mouse.move(10, 10) + await terminalGuide.focus() + expect(await page.getByRole('tooltip').count()).toBe(0) + + await bind(page, primary, 'Browser', 'Toggle right sidebar') + const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]') + await browserGuide.hover() + await browserGuide.focus() + expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria) + expect(await page.getByRole('tooltip').count()).toBe(0) + await composer.click() + const selectedDraft = await composer.evaluate((element) => { + const text = element.querySelector('[data-lexical-text]')!.firstChild! + const selection = document.getSelection()! + selection.setBaseAndExtent(text, 0, text, text.textContent!.length) + return selection.toString() + }) + await page.keyboard.press(`${primary}+Shift+,`) + await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor() + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe(selectedDraft) + await panel.getByPlaceholder('Enter an HTTP(S) address').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) + await page.keyboard.press(`${primary}+Alt+W`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) + await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(expected, 'browser.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await panel.locator('[data-dockkit-tab-close]').last().click() + await panel.locator('[data-dockkit-tab-close]').click() + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await bind(page, primary, 'Workspace files', 'Browser') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor() + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) + await page.keyboard.press(`${primary}+Shift+,`) + expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) + + await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0) + const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await filesTab.focus() + await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor() + expect(await paneAppearance()).toEqual([plainPane]) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + + await bind(page, primary, 'Split', 'Toggle panel fullscreen') + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + const left = panel.locator('[data-dockkit-pane][data-dockkit-column="0"]') + const right = panel.locator('[data-dockkit-pane][data-dockkit-column="1"]') + expect(await right.evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane, plainPane]) + const disabled = right.locator('[data-dockkit-split-button]') + expect(await disabled.isDisabled()).toBe(true) + await disabled.locator('..').focus() + const splitKeys = primary === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,' + await page.getByRole('tooltip', { name: `Two panes is the limit ${splitKeys}`, exact: true }).waitFor() + + await bind(page, primary, 'Workspace files', 'Split') + await right.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + + await bind(page, primary, 'New terminal', 'Workspace files') + await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await right.locator('[data-sidebar-terminal]').waitFor() + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(3) + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + expect(await left.getByRole('tab').count()).toBe(1) + await compareOrRefreshGolden(join(expected, 'panels.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await bind(page, primary, 'Refresh current page', 'New terminal') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(3) + await left.getByRole('tab', { name: /Files/ }).click() + await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n') + await left.getByRole('tab', { name: /Files/ }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await left.getByText('t6-refresh.txt', { exact: true }).waitFor() + expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria) + await bind(page, primary, 'Close current page or window', 'Refresh current page') + await right.getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(2) + expect(await left.getByRole('tab').count()).toBe(1) + await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click() + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(2) + await compareOrRefreshGolden(join(expected, 'page-actions.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + const menuTab = left.getByRole('tab', { name: /Files/ }) + await menuTab.locator('[data-dockkit-tab-close]').focus() + await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor() + for (let attempt = 0; attempt < 2; attempt++) { + await menuTab.click({ button: 'right' }) + await page.getByRole('menu').waitFor() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull() + if (attempt === 0) { + await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'), + await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode) + } else { + await page.getByRole('menuitem').focus() + } + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'detached' }) + expect(await left.getByRole('tab').count()).toBe(1) + expect(await right.getByRole('tab').count()).toBe(2) + } + expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true) + expect(await menuTab.evaluate(tab => getComputedStyle(tab).outlineStyle)).toBe('none') + await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await panel.locator('[data-dockkit-split-button]').click() + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + await (closeFirst === 'left' ? left : right).getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) + await compareOrRefreshGolden(join(expected, 'guide-close.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { try { await context.close() } finally { await scaffold.close() } } + }) +}) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts new file mode 100644 index 0000000000..f269d80e55 --- /dev/null +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -0,0 +1,319 @@ +/** Workspace keyboard commands over the real Web composition and a recorded Session. */ +import { mkdir, readFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace } from './support.ts' + +const root = fileURLToPath(new URL('./expected/shortcuts-workspace', import.meta.url)) +const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const sourceId = SessionId('shortcuts-workspace-source') +const mode = webSnapshotMode() + +describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let previous: string | undefined + const launched: { app: string; path: string }[] = [] + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false, openInAppEnvironment: createLaunchEnvironmentSnapshot([]) }) + await seedSession(scaffold, await readFile(seed, 'utf8'), sourceId) + await mkdir(join(scaffold.workspaceCwd, 'keyboard-project')) + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript(() => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) + localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode')) + }) + page = await context.newPage() + // OS application discovery and launch are the external boundary; the command and HTTP carrier stay real. + await page.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: ['finder', 'vscode'] } })) + await page.route('**/open-in-app/open', async (route) => { + launched.push(route.request().postDataJSON() as { app: string; path: string }) + await route.fulfill({ json: { ok: true } }) + }) + const startup = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor({ timeout: 10_000 }).catch((error: unknown) => { throw new Error(JSON.stringify(startup), { cause: error }) }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + }) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + async function bind(label: string): Promise { + await page.keyboard.press('Meta+Slash') + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.waitFor() + if (['Rename session', 'Fork session', 'Archive session'].includes(label)) { + const row = reference.getByRole('listitem').filter({ has: page.getByText(label, { exact: true }) }) + expect(await row.count()).toBe(1) + expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0) + } + if (previous !== undefined) { + await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click() + await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0) + } + await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => reference.getByRole('group').count()).toBe(0) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + previous = label + } + + it('runs seven owner operations with saved bindings and preserves the completed fork prefix', async () => { + const console = watchConsole(page) + await bind('Search sessions') + await page.keyboard.press('Meta+Shift+Comma') + const search = page.getByPlaceholder('Search session names', { exact: true }) + await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true) + expect(await page.getByRole('button', { name: 'Search sessions', exact: true }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,') + await page.keyboard.press('Escape') + + await bind('Rename session') + await page.keyboard.press('Meta+Shift+Comma') + const rename = page.getByRole('dialog', { name: 'Rename session', exact: true }) + await rename.getByLabel('Session name').fill('T4 source') + await rename.getByRole('button', { name: 'Rename', exact: true }).click() + await rename.waitFor({ state: 'hidden' }) + await expect.poll(async () => (await readPersistedEvents(scaffold, sourceId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source')).toBe(true) + + await bind('Fork session') + const original = await readPersistedEvents(scaffold, sourceId) + const lastEnd = original.findLastIndex(event => event.type === 'turn/end') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)! + const childId = child.session.header.id + await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true) + const copied = await readPersistedEvents(scaffold, childId) + expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1)) + const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) }) + const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) }) + await childRow.waitFor() + expect(await sourceRow.getAttribute('aria-selected')).toBe('true') + expect(await childRow.getAttribute('aria-selected')).toBe('false') + await compareOrRefreshGolden(join(root, 'fork.expected.md'), + await captureStableAria(page, '[data-slot="sidebar.workspaces"]', scaffold.workspaceCwd), mode) + + await childRow.click() + await expect.poll(() => childRow.getAttribute('aria-selected')).toBe('true') + await bind('Archive session') + await sourceRow.hover() + await page.getByRole('button', { name: 'Session actions for T4 source', exact: true }).click() + await page.getByRole('menu').waitFor() + await compareOrRefreshGolden(join(root, 'session-menu.expected.md'), + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), mode) + expect(await page.getByRole('menuitem', { name: /^Archive session/ }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,') + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds).toContain(childId) + expect(scaffold.ctx.workspaceRegistry.archivedSessionIds).not.toContain(sourceId) + expect((await readPersistedEvents(scaffold, sourceId)).length).toBeGreaterThan(0) + + await bind('Add workspace') + await page.keyboard.press('Meta+Shift+Comma') + const picker = page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }) + await picker.waitFor() + await page.keyboard.press('Meta+Shift+Comma') + expect(await page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }).count()).toBe(1) + await picker.getByRole('button', { name: 'Edit path', exact: true }).click() + await picker.getByRole('textbox', { name: 'Edit path', exact: true }).fill(join(scaffold.workspaceCwd, 'keyboard-project')) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: 'Open', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project'))).toBeDefined() + const workspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project')))! + + const workspaceLabel = basename(workspace.path) + const workspaceRow = page.locator('[role="treeitem"][aria-expanded]') + .filter({ has: page.getByText(workspaceLabel, { exact: true }) }) + const details = page.getByRole('button', { name: `Copy: ${workspace.path}`, exact: true }) + await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() + await details.waitFor() + await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover() + await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor() + await expect.poll(() => details.count()).toBe(0) + await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() + await details.waitFor() + await page.mouse.move(700, 100) + + await bind('New Session') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session') + expect(workspace.sessionIds.length).toBe(1) + + await bind('Open locally') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }]) + expect(console.pageErrors).toEqual([]) + }) +}) + +it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries from the completed prefix', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) + const page = await context.newPage() + const tripwire = watchConsole(page) + const forkWarnings: string[] = [] + page.on('console', (message) => { if (message.text().startsWith('session fork rejected:')) forkWarnings.push(message.text()) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) + if (source === undefined) throw new Error('The workspace did not create a Session') + source.session.append('turn/start', { turn: 1 }) + const user = source.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + source.session.append('session/title', { title: 'First turn', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + await scaffold.ctx.sessions.flush(source.session) + await page.getByText('The first turn has not ended.', { exact: true }).waitFor() + const selected = page.getByRole('treeitem', { selected: true }) + await selected.filter({ hasText: 'First turn' }).waitFor() + await page.keyboard.press('Meta+Slash') + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.getByRole('button', { name: 'Edit shortcut for Fork session', exact: true }).click() + await page.keyboard.press('Meta+Shift+Comma') + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + await page.keyboard.press('Meta+Shift+Comma') + const unavailable = page.getByRole('alert').filter({ hasText: 'This session has no completed turn' }) + await unavailable.waitFor() + await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'), + await unavailable.ariaSnapshot(), mode) + expect(await selected.textContent()).toContain('First turn') + expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0) + expect(forkWarnings).toEqual([]) + + source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const completed = source.session.snapshotEvents() + source.session.append('turn/start', { turn: 2 }) + source.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'The second turn has not ended.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(source.session) + await page.getByText('The second turn has not ended.', { exact: true }).waitFor() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)! + const childRow = page.getByRole('treeitem').filter({ has: page.getByText('First turn (1)', { exact: true }) }) + await childRow.waitFor() + expect(await childRow.getAttribute('aria-selected')).toBe('false') + expect(await selected.getByText('First turn', { exact: true }).count()).toBe(1) + expect((await readPersistedEvents(scaffold, child.id)).slice(0, completed.length)).toEqual(completed) + await page.getByText('The second turn has not ended.', { exact: true }).waitFor() + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } +}) + +it.skipIf(mode === 'record').each([ + { platform: 'Win32', primary: 'Control', file: '../shortcuts/en-US', custom: 'Control+Alt+Shift+Meta+J' }, + { platform: 'MacIntel', primary: 'Meta', file: 'macos-defaults', custom: 'Meta+Alt+Shift+J' }, +])('runs $platform defaults over recorded history and restores them after a multi-modifier binding', async ({ platform, primary, file, custom }) => { + const scaffold = await launchWebScaffold({ developerTools: false }) + let browser: Browser | undefined + try { + await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('shortcut-defaults-source')) + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await page.keyboard.press(`${primary}+Slash`) + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.waitFor() + await compareOrRefreshGolden(join(root, `${file}.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + await page.keyboard.press(`${primary}+Alt+K`) + const search = page.getByPlaceholder('Search session names', { exact: true }) + await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true) + const searchAppearance = await search.evaluate((element) => { + const input = getComputedStyle(element) + const surface = getComputedStyle(element.parentElement!) + return { outline: input.outlineStyle, shadow: input.boxShadow, + surfaceOutline: surface.outlineStyle, surfaceShadow: surface.boxShadow } + }) + expect(searchAppearance).toEqual({ outline: 'none', shadow: 'none', surfaceOutline: 'none', surfaceShadow: 'none' }) + await page.keyboard.press('Escape') + expect(await search.evaluate(element => getComputedStyle(element).boxShadow)).toBe(searchAppearance.shadow) + await page.keyboard.press(`${primary}+Alt+B`) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.keyboard.press(`${primary}+Alt+B`) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + await page.keyboard.press(`${primary}+Slash`) + await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await page.keyboard.press(custom) + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await page.keyboard.press(custom) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.keyboard.press(custom) + await page.keyboard.press(`${primary}+Slash`) + await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await reference.getByRole('button', { name: 'Restore default', exact: true }).click() + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')).toBe(primary === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + if (platform === 'MacIntel') { + await page.keyboard.press('Meta+Slash') + await reference.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = reference.getByRole('button', { name: 'Press a shortcut', exact: true }) + // Reproduce the macOS Dead-key payload through the real recorder and command owners. + expect(await recorder.evaluate((element) => { + const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true }) + element.dispatchEvent(event) + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft', bubbles: true })) + return event.defaultPrevented + })).toBe(true) + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + const composer = page.locator('[data-composer-input]').first() + await composer.focus() + expect(await composer.evaluate((element) => { + const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true }) + element.dispatchEvent(event) + return event.defaultPrevented + })).toBe(true) + } else { + await page.keyboard.press(`${primary}+Alt+N`) + } + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.locator('[data-composer-input]').first().waitFor() + expect(console.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } +}) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts new file mode 100644 index 0000000000..b25d56f07a --- /dev/null +++ b/apps/web/tests/shortcuts.e2e.ts @@ -0,0 +1,395 @@ +/** Real Web composition: keyboard routing, inline editing, and localized system feedback. */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openSettings } from './support.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts', import.meta.url)) +const mode = webSnapshotMode() + +describe('web e2e: shortcut reference', () => { + let scaffold: WebScaffold + let browser: Browser + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false }) + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it.each([ + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' }, + ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => { + const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash' + const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } }) + try { + // Branch coverage for device labels; native Windows input is separately verified on Windows. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const trigger = page.getByRole('button', { name: settings, exact: true }) + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`) + const settingsHint = `${settings} ${key === 'Meta' ? '⌘ ,' : 'Ctrl + ,'}` + await trigger.hover() + await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + await trigger.click() + await page.getByRole('button', { name: view, exact: true }).click() + const dialog = page.getByRole('dialog', { name: title, exact: true }) + await dialog.waitFor() + const referenceNode = await dialog.elementHandle() + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await dialog.locator('footer').count()).toBe(1) + const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true }) + expect(await resetAll.isDisabled()).toBe(true) + expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 }) + expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 }) + const rows = dialog.getByRole('listitem') + const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true }) + .getByRole('listitem').last() + await stopRow.getByText(locale === 'zh-CN' ? '停止生成' : 'Stop generating', { exact: true }).waitFor() + const stopButtons = stopRow.getByRole('button') + expect(await stopButtons.count()).toBe(1) + expect(await stopButtons.isDisabled()).toBe(true) + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42 + && row.scrollWidth <= row.clientWidth))).toBe(true) + const searchInput = page.getByRole('searchbox', { name: search }) + expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => ({ + outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow, + }))).toEqual({ outline: 'none', shadow: 'none' }) + const searchSurfaceStyle = () => dialog.getByRole('search').evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderColor, outline: style.outlineStyle, shadow: style.boxShadow } + }) + const focusedSearchSurface = await searchSurfaceStyle() + expect(focusedSearchSurface).toMatchObject({ outline: 'none', shadow: 'none' }) + const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow) + await compareOrRefreshGolden(join(expected, `${locale}.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + const fixedButtons = dialog.getByRole('button', { disabled: true }) + const fixedButton = fixedButtons.first() + expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45') + const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + await fixedButton.hover() + expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground) + const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar' + const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar' + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) }) + const editButton = page.getByRole('button', { name: editLabel, exact: true }) + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText( + locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true }, + ) }) + const badge = boundRow.getByRole('button').last() + const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + const restingColor = await badgeColor() + await boundRow.locator(':scope > span').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.hover() + expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)') + await boundRow.getByRole('button').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.click() + await boundRow.getByRole('group').waitFor() + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity) + expect(await boundActionsOpacity()).toBe('0') + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) + await page.keyboard.press('Shift+Tab') + expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await boundActionsOpacity()).toBe('0') + await row.hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1) + await editButton.click() + const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true }) + const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true }) + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + expect(await recorder.evaluate(element => ({ + focused: element === document.activeElement, + outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow, + }))).toEqual({ focused: true, outline: 'none', shadow: 'none' }) + await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await page.keyboard.press(referenceKey) + const errorToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已被「快捷键速查」占用' : 'Already used by “Open keyboard shortcuts”' }) + await errorToast.waitFor() + expect(await errorToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))) + .toEqual({ background: 'rgb(53, 54, 56)', color: 'rgb(255, 255, 255)' }) + expect(await errorToast.evaluate(element => element.closest('[role="dialog"]') === null)).toBe(true) + expect(await recorder.evaluate(element => ({ + color: getComputedStyle(element).borderColor, width: getComputedStyle(element).borderWidth, + }))) + .toEqual({ color: 'rgb(236, 19, 19)', width: '1px' }) + await compareOrRefreshGolden(join(expected, `${locale}-conflict.expected.md`), await errorToast.ariaSnapshot(), mode) + await page.keyboard.press('i') + await page.getByRole('alert').filter({ hasText: locale === 'zh-CN' + ? '请同时按下 Command、Ctrl 或 Alt 修饰键。' : 'Include Command, Ctrl, or Alt in the combination.' }).waitFor() + await page.keyboard.press(`${key}+Shift+.`) + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) + expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + await page.keyboard.press('Shift+Tab') + expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) + expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') + const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' }) + await successToast.waitFor() + await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode) + await page.emulateMedia({ colorScheme: 'dark' }) + await expect.poll(() => successToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' }) + await page.emulateMedia({ colorScheme: 'light' }) + await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)') + await page.getByRole('button', { name: removeLabel, exact: true }).click() + const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }) + await unbound.waitFor() + expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0) + await unbound.click() + await inline.waitFor() + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0) + await page.keyboard.press('Escape') + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + expect(await page.getByRole('dialog').count()).toBe(2) + for (const query of ['abc', 'sendEnter']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(0) + expect(await dialog.getByRole('status').textContent()).toBe(locale === 'zh-CN' ? '没有匹配的快捷键' : 'No matching shortcuts') + if (query === 'abc') await compareOrRefreshGolden(join(expected, `${locale}-empty.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + } + for (const query of [locale === 'zh-CN' ? '左侧栏' : 'toggle left', 'tgllft', 'toggle left sidebar']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(1) + expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar') + } + await page.keyboard.press(`${key}+Comma`) + expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar') + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) + await page.keyboard.press(referenceKey) + await dialog.waitFor({ state: 'hidden' }) + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + expect(await page.getByRole('button', { name: view, exact: true }).getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press(`${key}+Comma`) + await page.getByRole('dialog', { name: settings, exact: true }).waitFor({ state: 'hidden' }) + expect(await page.getByRole('dialog').count()).toBe(0) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(await trigger.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await trigger.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press(referenceKey) + await dialog.waitFor() + await page.keyboard.press(`${key}+Comma`) + expect(await page.getByRole('dialog', { name: settings, exact: true }).isVisible()).toBe(false) + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press(`${key}+/`) + await dialog.waitFor({ state: 'hidden' }) + expect(await page.getByRole('dialog').count()).toBe(0) + await page.keyboard.press(referenceKey) + await dialog.waitFor() + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('searchbox').inputValue()).toBe('') + await page.setViewportSize({ width: 420, height: 500 }) + const compactBounds = (await dialog.boundingBox())! + expect(compactBounds.x).toBeGreaterThanOrEqual(24) + expect(compactBounds.x + compactBounds.width).toBeLessThanOrEqual(396) + expect(compactBounds.y + compactBounds.height).toBeLessThanOrEqual(476) + await dialog.getByRole('region').last().scrollIntoViewIfNeeded() + expect(await dialog.getByRole('heading', { name: title }).isVisible()).toBe(true) + await page.keyboard.press('Escape') + // Observe preventDefault without invoking destructive browser defaults in the test runner. + expect(await page.evaluate(modifier => ['KeyN', 'KeyP', 'KeyO', 'KeyR', 'KeyW', 'KeyB'].map((code) => { + const event = new KeyboardEvent('keydown', { code, ctrlKey: modifier === 'Control', metaKey: modifier === 'Meta', bubbles: true, cancelable: true }) + document.body.dispatchEvent(event) + return event.defaultPrevented + }), key)).toEqual(Array(6).fill(false)) + await page.setViewportSize({ width: 1440, height: 1000 }) + await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en') + const viewButton = page.getByRole('button', { name: view, exact: true }) + const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}` + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + await viewButton.click() + await boundRow.getByRole('button').first().click() + await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor() + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await viewButton.evaluate(element => element === document.activeElement)).toBe(true) + expect(await viewButton.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await viewButton.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await viewButton.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull() + await viewButton.click() + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + await viewButton.click() + await page.getByRole('searchbox').fill('abc') + expect(await dialog.getByRole('listitem').count()).toBe(0) + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized') + await resetAll.click() + const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true }) + const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true }) + expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true) + expect(await cancel.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode) + await cancel.click() + expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) + expect(await resetAll.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized') + await resetAll.click() + await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await confirm.waitFor({ state: 'hidden' }) + expect(await resetAll.isDisabled()).toBe(true) + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized') + expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await page.keyboard.press(referenceKey) + const settingsCommand = locale === 'zh-CN' ? '打开设置' : 'Open settings' + await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click() + await page.keyboard.press(`${key}+Shift+Comma`) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') + await trigger.hover() + await page.getByRole('tooltip', { name: `${settings} ${key === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'}`, exact: true }).waitFor() + await trigger.click() + await page.keyboard.press('Escape') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press(referenceKey) + await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click() + await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`) + await trigger.hover() + await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true }) + expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + await page.reload({ waitUntil: 'load' }) + await sidebar.waitFor() + expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { await context.close() } + }) + + it.each([ + { platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' }, + { platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' }, + { platform: 'Linux x86_64', modifier: 'Control', defaultBinding: null }, + ])('records, rejects conflicts, persists across reload, and synchronizes stale drafts between tabs on $platform', async ({ platform, modifier, defaultBinding }) => { + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + try { + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const referenceKey = `${modifier}+Slash` + const open = async () => { + await page.keyboard.press(referenceKey) + await page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }).waitFor() + } + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + const editor = page.getByRole('group', { name: 'Toggle left sidebar', exact: true }) + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(referenceKey) + await page.getByRole('alert').filter({ hasText: 'Already used by “Open keyboard shortcuts”' }).waitFor() + expect(await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).getAttribute('aria-invalid')).toBe('true') + await page.keyboard.press(`${modifier}+Shift+.`) + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + const sidebarKey = `${modifier}+Shift+.` + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ }) + await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + const wasOpen = await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).isVisible() + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: wasOpen ? 'Open sidebar' : 'Collapse sidebar', exact: true }).waitFor() + if (wasOpen) await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + const other = await context.newPage() + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await other.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await other.keyboard.press(referenceKey) + await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor() + await page.bringToFront() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(`${modifier}+Shift+,`) + expect(await editor.getByRole('button', { name: 'Retry save', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(expected, 'edit-stale.expected.md'), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await editor.getByRole('button', { name: 'I have reviewed the latest configuration', exact: true }).click() + await editor.getByRole('button', { name: 'Retry save', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Restore default', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(defaultBinding) + const stored = await page.evaluate((): unknown => JSON.parse(localStorage.getItem('dsh.keybindings.v1')!)) + const profile = platform === 'MacIntel' ? 'macos' : platform === 'Win32' ? 'windows' : 'linux' + expect(stored).toEqual({ schemaVersion: 1, profiles: { [`web:${profile}`]: {} } }) + } finally { await context.close() } + }) + +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index af37b968e1..0f99b46c2d 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -206,6 +206,33 @@ async function tabTitles(root: Locator): Promise { return await root.locator('[data-dockkit-tab-title]').allInnerTexts() } +/** Compare the rendered glyph and text rather than their containing boxes. */ +async function expectTitleAlignment(title: Locator): Promise { + const geometry = await title.evaluate((node) => { + const icon = node.querySelector('svg') + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT) + let textBox: DOMRect | undefined + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + const range = document.createRange() + range.selectNodeContents(walker.currentNode) + const box = range.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + textBox = box + break + } + } + if (icon === null || textBox === undefined) throw new Error('tab icon or visible title text is missing') + const iconBox = icon.getBoundingClientRect() + return { + gap: textBox.left - iconBox.right, + centreOffset: Math.abs(iconBox.y + iconBox.height / 2 - textBox.y - textBox.height / 2), + } + }) + expect(geometry.gap).toBeCloseTo(5, 2) + expect(geometry.centreOffset).toBeLessThan(1) +} + /** * A rendered width, read once the frame's track transition has settled. * @@ -416,6 +443,7 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expectTitleAlignment(column.locator('[data-dockkit-tab-title]')) await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() @@ -425,6 +453,7 @@ describe('web e2e: shipped right Sidebar', () => { const addTab = column.locator('[data-dockkit-add-tab]') const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await expectTitleAlignment(filesTab.locator('[data-dockkit-tab-title]')) await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(1) @@ -934,10 +963,11 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title) const splitButtons = column.locator('[data-dockkit-split-button]') - await expect.poll(async () => await splitButtons.count()).toBe(0) + await expect.poll(async () => await splitButtons.count()).toBe(2) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) expect(await panes.count()).toBe(2) await setPanelWidth(page, 560) - await expect.poll(async () => await splitButtons.count()).toBe(0) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) const outer = column.locator('[data-dockkit-divider]').first() const before = await width(paneAt(column, 1)) @@ -956,12 +986,13 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y }) await expect.poll(ratio).toBeCloseTo(0.5, 2) expect(await panes.count()).toBe(2) - expect(await splitButtons.count()).toBe(0) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) // 5. The split's guide and the document float while Files stays docked. const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) + await expectTitleAlignment(floats.first().locator('[data-dockkit-tab-title]')) const box = await floats.first().boundingBox() if (box === null) throw new Error('float is not rendered') await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) @@ -971,6 +1002,7 @@ describe('web e2e: shipped right Sidebar', () => { const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) + await expectTitleAlignment(floats.last().locator('[data-dockkit-tab-title]')) // 6. Dock one back: the docked tree takes it, the other float stays. Dock // the TOPMOST float — floats render bottom-to-top, so the newest one @@ -1096,6 +1128,7 @@ describe('web e2e: shipped right Sidebar', () => { // depends on a sibling block's setup passes only in the right order. it('renders the shipped Chinese copy on a Chinese page', async () => { const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await zhPage.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) }) const zhTripwire = watchConsole(zhPage) onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh')) try { @@ -1121,7 +1154,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await width(column)).toBeGreaterThan(300) await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText()) - .toBe('工作区文件\n浏览会话工作区的文件') + .toBe('工作区文件\n浏览会话工作区的文件\n⌥\n⌘\nP') await shot(zhPage, '05-guide-copy-zh') expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 75ee794ebe..556fd63e12 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -78,6 +78,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) browser = await chromium.launch() const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' }) + // These snapshots use the Linux Web shortcut defaults. + await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Linux x86_64' }) }) page = await context.newPage() tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -473,9 +475,22 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(alive(original)).toBe(false) transport.reconnect() await page.context().setOffline(false) - await page.reload({ waitUntil: 'load' }) - await page.locator('[data-sidebar-right-expand]').click() + const environmentReady = Promise.withResolvers() + const environmentUrl = new URL('/api/terminal/environment', scaffold.baseUrl).href + await page.route(environmentUrl, async (route) => { + await environmentReady.promise + await route.continue() + }) const terminal = page.locator('[data-sidebar-terminal]') + try { + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-sidebar-right-expand]').click() + // The title starts discovery before the lazy body mounts; keep that request pending through mounting. + await terminal.getByRole('status').getByText('Reading terminal environment…', { exact: true }).waitFor() + } finally { + environmentReady.resolve(undefined) + await page.unrouteAll({ behavior: 'wait' }) + } let unavailableSnapshot = '' // Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample. await expect.poll(async () => { diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index 4dcaa0f759..02597218c0 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -52,7 +52,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { it('expands every recorded tool through its keyed renderer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details')) const rows = page.locator('[data-tool]') - expect(await rows.count()).toBe(44) + expect(await rows.count()).toBe(45) for (const row of await rows.all()) { await row.getByRole('button', { expanded: false }).first().click() expect(await row.getByRole('listitem').count()).toBeGreaterThan(0) @@ -65,8 +65,14 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor() const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first() await expect.poll(() => schema.textContent()).toContain('"properties"') - const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) => - captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd))) + const callIds = [ + ...Array.from({ length: 3 }, (_, index) => `details-call-${index + 1}`), + // The authored schedule_update round sits beside its schedule_create sibling. + 'details-call-3b', + ...Array.from({ length: 41 }, (_, index) => `details-call-${index + 4}`), + ] + const snapshots = await Promise.all(callIds.map(id => + captureStableAria(page, `[data-chat-call-id="${id}"] [data-tool]`, scaffold.workspaceCwd))) const snapshot = snapshots.join('\n') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) await page.setViewportSize({ width: 360, height: 800 }) diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index 16e9f4851e..cb6df06778 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect, } from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts' @@ -106,6 +107,11 @@ async function collectedRegions(page: Page, selector: string): Promise { const top = document.elementFromPoint(x, y) + // A pane's programmatic focus target also contains its window-drag strip. + // The strip owns those pixels; controls inside it still need their own subtraction. + const strip = top?.closest('[data-dockkit-strip][data-window-drag]') + if (element.matches('[data-dockkit-pane][tabindex="-1"]') + && strip != null && element.contains(strip)) return false return top !== null && (top === element || element.contains(top)) }) : [] @@ -197,11 +203,18 @@ describe('web e2e: macOS window drag coverage', () => { */ async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType }> { const page = await newEnglishPage(browser) - await page.addInitScript((value: string) => { + await page.addInitScript(({ value, snapshot }) => { + // The Desktop platform marker requires the preload's keyboard and preference capabilities. + Object.assign(window, { dshDesktop: { protocolVersion: 1, + keyboard: { subscribe: () => () => {}, closeWindow: async () => {} }, + shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }), + subscribe: () => () => {}, recording: async () => {}, + edit: async () => ({ status: 'not-ready', snapshot }) }, + } }) const mark = (): void => { document.documentElement.setAttribute('data-platform', value) } if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) else mark() - }, 'darwin') + }, { value: 'darwin', snapshot: initialShortcutConfig() }) const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -461,7 +474,8 @@ describe('web e2e: macOS window drag coverage', () => { it('keeps a covering overlay out of the drag surface', async () => { const { page, tripwire } = await darwinPage() try { - await page.locator('button[aria-haspopup="dialog"]').first().click() + await page.getByRole('button', { name: 'Account menu', exact: true }).click() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() const dialog = page.locator('[role="dialog"]').first() await dialog.waitFor({ timeout: 15_000 }) await settled(page) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cc147daa67..e3c004649d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,12 +22,18 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ - "tests/connection-recovery.e2e.ts", - "tests/server-restart.e2e.ts", + "tests/shortcuts-desktop.e2e.ts", + "tests/shortcuts-workspace.e2e.ts", + "tests/shortcuts-panels.e2e.ts", + "tests/shortcuts-fixed.e2e.ts", + "tests/shortcuts.e2e.ts", + "tests/composer-shortcuts.e2e.ts", "tests/default-product-isolation.e2e.ts", "tests/diff-context.e2e.ts", "tests/code-card-layout.e2e.ts", "tests/scaffold.ts", + "tests/connection-recovery.e2e.ts", + "tests/server-restart.e2e.ts", "tests/voice-input.e2e.ts", "tests/voice-setup.e2e.ts", "tests/voice-download.e2e.ts", @@ -83,6 +89,8 @@ "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/onboarding-native.e2e.ts", + "tests/desktop-onboarding.e2e.ts", + "tests/bonus-notice.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/public-mount.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9984bf38de..010e4307f7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -29,6 +29,20 @@ function clientDocumentTitle(): Plugin { } } +/** Keep the redistribution license beside the bundled brand font. */ +function brandFontLicense(): Plugin { + return { + name: 'dsh-brand-font-license', + async generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'assets/fonts/Montserrat-OFL.txt', + source: await readFile(src('../../packages/client/ui-theme/src/styles/Montserrat-OFL.txt')), + }) + }, + } +} + /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { return { @@ -156,7 +170,7 @@ export default defineConfig({ // directory, and the served index resolves identically from the site root. base: './', plugins: [ - rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(), + rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), react(), emitPreviewPage(), productWebBundleIsolation(src('../..'), src('.')), ], build: { diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 3823aee1cd..1072232f44 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -12,8 +12,8 @@ en: e29f1d7026291075 zh: 15e361e797d44fce /api-gateway/strict-generation-pipeline: - en: 06c84cb25fb6c940 - zh: d639c6e0bd271759 + en: cb8da81a92acfa52 + zh: be5f3c268f8571df /api-gateway/runtime-invocation: en: 2a2fed340aaa926c zh: 46bb1f500c870a76 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 957f407758..91f992dc5e 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -98,7 +98,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. -Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. +Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`, the Host pass also `apps/desktop-host` ([build order](development.md#typescript-project-layout)), and both bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. `api/remotes`, `api/gateway`, `api/session-controller`, and `api/workspace-controller` (plus `client/connection`) split TypeScript faces. `api/remotes`' Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference each split package's `tsconfig.host.json` or `tsconfig.client.json` respectively. `api-remotes`' `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. The Agent/Session lookup policy lives in `@deepseek-ai/dsh-api-session-controller`, not in `api-remotes`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1062adcc89..4e21bafa9b 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -98,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 -两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 +两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 `api/remotes`、`api/gateway`、`api/session-controller` 与 `api/workspace-controller`(外加 `client/connection`)都拆分 TypeScript face。`api/remotes` 的 Client project 依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用各拆分包自己的 `tsconfig.host.json` 或 `tsconfig.client.json`。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。Agent/Session lookup 策略位于 `@deepseek-ai/dsh-api-session-controller`,而非 `api-remotes`。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8fb35b34ba..efc66e50ef 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,11 +24,11 @@ en: 214c3c81c21df6f9 zh: 75d2cdc3c9fc9930 /deepseek-harness-architecture/turn-flow: - en: 0ea92353b5344090 - zh: 46ec58a8e6259335 + en: e13500f6d5b614d6 + zh: 7e6acb96a520ded2 /deepseek-harness-architecture/session-log: - en: 03a4394c86b18d91 - zh: f5df5939f9b42990 + en: e1e3a44140036f25 + zh: 84c70c969e5ad216 /deepseek-harness-architecture/capability-seams: en: 93d1dc2eeda4aec2 zh: ee7a9da89151ea17 diff --git a/docs/architecture.md b/docs/architecture.md index 634214943c..0919d65a30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,7 @@ turn/end One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents. -`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). +`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. @@ -122,7 +122,7 @@ The session log is the source of the context the model sees. `deriveMessages()` Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). -**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. A new model-visible input requires a session event. Plugins that change existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. +**Model-visible means logged.** A runtime invariant checks model requests are reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. **Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6082c5c86a..bf3fb3b649 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -114,7 +114,7 @@ turn/end 输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。 -`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 +`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 @@ -126,7 +126,7 @@ turn/end Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。 -**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。新增模型可见输入需要一个会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 +**模型可见即已记录。** 运行时不变量检查模型请求是否可从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 **投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 424540985a..889225b9e7 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: ea114a5c950fa88f - zh: 1d42873021ba511d + en: 35a8ab92922009f2 + zh: b2d0accdef6b94bd diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2914231cea..7a80b84a95 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -174,6 +174,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_schedule["schedule"] + svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_ssh["ssh"] @@ -357,6 +359,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence @@ -626,6 +629,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | +| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a1223a351c..6348eb35bc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -176,6 +176,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_schedule["schedule"] + svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_ssh["ssh"] @@ -359,6 +361,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence @@ -628,6 +631,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | +| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | 负责一条经过认证的 OpenSSH 连接、已安装辅助程序身份、独立程序流,以及配套远端提供方的断连清理。 | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3a1128fded..67b491dd73 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -303,7 +303,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` - `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) +- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -495,6 +495,22 @@ export interface Config { ``` + +
+ +## `@deepseek-ai/dsh-client-shortcuts` + +- `source`: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + + @@ -520,16 +536,48 @@ export interface Config { ## `@deepseek-ai/dsh-client-ui-settings-account` -- `source`: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts) +- `refs`: `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/client/ui-settings-account/src/index.ts:9`](../packages/client/ui-settings-account/src/index.ts) ```ts config-catalog -/** Questionnaire destination and its supported source option. */ -export interface Config { +/** Public contact options and live device-local onboarding progress. */ +export interface Config extends ContactConfig { + /** Onboarding progress format version. */ + version: Volatile<1> + /** Last accepted onboarding page. */ + step: Volatile + /** Selected work scenario. */ + purpose?: Volatile + /** Selected transcript detail. */ + process?: Volatile + /** Completion reason, absent until completion. */ + completion?: Volatile<'completed' | 'skipped' | 'api-key' | null | undefined> + /** Selected usage detail. */ + usage: Volatile<'compact' | 'detailed'> + /** Selected developer-tool visibility. */ + developerTools: Volatile +} + +/** Questionnaire destination and bonus notice timings shared by Host and Client. */ +export interface ContactConfig { /** HTTPS questionnaire URL; override for a test form. */ contactFormUrl: string /** Questionnaire source option; empty until Harness is supported by the form. */ contactSource: string + /** First delay before retrying a failed bonus acknowledgement. */ + bonusAckRetryDelayMs: number + /** Ceiling for the acknowledgement retry backoff. */ + bonusAckRetryMaxDelayMs: number } + +/** Persisted steps; a native top-up page leaves the durable step at credit. */ +export type OnboardingStep = 'welcome' | 'credit' | 'purpose' | 'process' | 'done' + +/** Work scenarios offered by the desktop introduction. */ +export type OnboardingPurpose = 'office' | 'development' | 'both' + +/** Work-detail mode applied to Chat when onboarding completes. */ +export type OnboardingProcess = 'compact' | 'standard' | 'detailed' ``` @@ -743,6 +791,8 @@ export interface Config { accountRequestHeaders?: Record /** Deadline for each platform HTTP request. */ requestTimeoutMs?: number + /** Deadline for recharge-wallet queries; timeout returns a failed balance outcome. */ + balanceTimeoutMs?: number /** Additional logout attempts after the first request fails, at most five. */ logoutMaxRetries?: number /** Delay before the first logout retry; each later delay doubles. */ @@ -1805,8 +1855,8 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -2414,6 +2466,31 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-schedule` + +- `inject`: `agents` · `sessions` · `tools` · `storageDomain` · `sessionController` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) + +```ts config-catalog +/** Configuration for the Host Schedule domain. */ +export interface Config { + /** + * Delivery-history window retained per task, in days; omission defaults to 30. + * Pruning happens when an acknowledgment is appended, and `lastDelivery` is always retained. + */ + deliveryHistoryDays?: number + /** + * Retained delivery records per task; omission defaults to 200. The older of this + * cap and the window wins, and the newest records survive. + */ + deliveryHistoryRecords?: number +} +``` + + @@ -4238,6 +4315,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-settings-shell` | — | [`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-subagent` | — | [`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-web-search` | — | [`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-shortcuts` | — | [`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar` | — | [`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar-browser` | — | [`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar-files` | — | [`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts) | @@ -4273,7 +4351,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | -| `@deepseek-ai/dsh-schedule` | `agents` · `sessions` · `tools` · `sessionPersistence` | [`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | | `@deepseek-ai/dsh-session-projection` | — | [`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5127238f58..6b8acdfd07 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -305,7 +305,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` - `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) +- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -497,6 +497,22 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-client-shortcuts` + +- `source`: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + + @@ -522,16 +538,48 @@ export interface Config { ## `@deepseek-ai/dsh-client-ui-settings-account` -- `source`: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts) +- `refs`: `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/client/ui-settings-account/src/index.ts:9`](../packages/client/ui-settings-account/src/index.ts) ```ts config-catalog -/** Questionnaire destination and its supported source option. */ -export interface Config { +/** Public contact options and live device-local onboarding progress. */ +export interface Config extends ContactConfig { + /** Onboarding progress format version. */ + version: Volatile<1> + /** Last accepted onboarding page. */ + step: Volatile + /** Selected work scenario. */ + purpose?: Volatile + /** Selected transcript detail. */ + process?: Volatile + /** Completion reason, absent until completion. */ + completion?: Volatile<'completed' | 'skipped' | 'api-key' | null | undefined> + /** Selected usage detail. */ + usage: Volatile<'compact' | 'detailed'> + /** Selected developer-tool visibility. */ + developerTools: Volatile +} + +/** Questionnaire destination and bonus notice timings shared by Host and Client. */ +export interface ContactConfig { /** HTTPS questionnaire URL; override for a test form. */ contactFormUrl: string /** Questionnaire source option; empty until Harness is supported by the form. */ contactSource: string + /** First delay before retrying a failed bonus acknowledgement. */ + bonusAckRetryDelayMs: number + /** Ceiling for the acknowledgement retry backoff. */ + bonusAckRetryMaxDelayMs: number } + +/** Persisted steps; a native top-up page leaves the durable step at credit. */ +export type OnboardingStep = 'welcome' | 'credit' | 'purpose' | 'process' | 'done' + +/** Work scenarios offered by the desktop introduction. */ +export type OnboardingPurpose = 'office' | 'development' | 'both' + +/** Work-detail mode applied to Chat when onboarding completes. */ +export type OnboardingProcess = 'compact' | 'standard' | 'detailed' ``` @@ -745,6 +793,8 @@ export interface Config { accountRequestHeaders?: Record /** Deadline for each platform HTTP request. */ requestTimeoutMs?: number + /** Deadline for recharge-wallet queries; timeout returns a failed balance outcome. */ + balanceTimeoutMs?: number /** Additional logout attempts after the first request fails, at most five. */ logoutMaxRetries?: number /** Delay before the first logout retry; each later delay doubles. */ @@ -1807,8 +1857,8 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -2416,6 +2468,31 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-schedule` + +- `inject`: `agents` · `sessions` · `tools` · `storageDomain` · `sessionController` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) + +```ts config-catalog +/** Configuration for the Host Schedule domain. */ +export interface Config { + /** + * Delivery-history window retained per task, in days; omission defaults to 30. + * Pruning happens when an acknowledgment is appended, and `lastDelivery` is always retained. + */ + deliveryHistoryDays?: number + /** + * Retained delivery records per task; omission defaults to 200. The older of this + * cap and the window wins, and the newest records survive. + */ + deliveryHistoryRecords?: number +} +``` + + @@ -4240,6 +4317,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-settings-shell` | — | [`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-subagent` | — | [`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-web-search` | — | [`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-shortcuts` | — | [`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar` | — | [`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar-browser` | — | [`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts) | | `@deepseek-ai/dsh-client-ui-sidebar-files` | — | [`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts) | @@ -4275,7 +4353,6 @@ export interface Config { | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | -| `@deepseek-ai/dsh-schedule` | `agents` · `sessions` · `tools` · `sessionPersistence` | [`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | | `@deepseek-ai/dsh-session-projection` | — | [`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts) | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8a17204de9..3a2f7f09ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: 37441690bae1ebf4 - zh: 498777f8f25b6940 + en: de9ab4baab0c5c91 + zh: cde7e8ab4f698fcb /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index 6adc1ef566..f5b2a100d6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,13 @@ The root build follows the generated dependency order: ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. +Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. diff --git a/docs/development.zh.md b/docs/development.zh.md index 6e757bd416..c8cc93f6ea 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,12 +78,13 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 +两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 090e0d726d..f419955c56 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -10,4 +10,4 @@ zh: a8505006244f5619 /event-producer-and-consumer-matrix/non-harness-or-undeclared-event-strings-seen-in-package-source: en: 96dc0ff1580e5741 - zh: f076e3afa8fbc57c + zh: 96dc0ff1580e5741 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8b6a9b2318..d2507da38e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -20,7 +20,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -41,8 +41,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:59`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | -| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:55`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:90`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:86`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | @@ -54,16 +54,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:174`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | @@ -79,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b0c5b5b382..65f39c5448 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -14,7 +14,7 @@ | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -22,7 +22,7 @@ | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -43,8 +43,8 @@ | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | -| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:59`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | -| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:55`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | +| `deepseek-account/model-sign-in-required` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:90`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | `remotes` | +| `deepseek-account/session-expired` | `emit` | [`packages/credentials/deepseek-account/src/types.ts:86`](../packages/credentials/deepseek-account/src/types.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | `account-controller`, `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | @@ -56,16 +56,17 @@ | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:174`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | @@ -81,7 +82,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -107,4 +108,4 @@ | `slots/changed` | `ui-renderer` (`emit`) | - | -维护模式:自动生成:Cordis 事件声明及生产方/监听方关系从仓库 TypeScript Program 中解析。 +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index fdb697461a..b15903d4d5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -154,6 +154,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -191,6 +192,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -924,15 +926,6 @@ flowchart TD pkg_agent_preset_registry --> pkg_system_prompt pkg_agent_preset_registry --> pkg_tools pkg_agent_preset_registry --> pkg_typert_protocol - pkg_schedule --> pkg_agent - pkg_schedule --> pkg_brand - pkg_schedule --> pkg_invariants - pkg_schedule --> pkg_llm - pkg_schedule --> pkg_session - pkg_schedule --> pkg_session_persistence - pkg_schedule --> pkg_session_projection - pkg_schedule --> pkg_tools - pkg_schedule --> pkg_workspace pkg_session_checkpoint_policy --> pkg_agent pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session @@ -1316,6 +1309,18 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_schedule --> pkg_agent + pkg_schedule --> pkg_api_session_controller + pkg_schedule --> pkg_brand + pkg_schedule --> pkg_invariants + pkg_schedule --> pkg_llm + pkg_schedule --> pkg_session + pkg_schedule --> pkg_session_persistence + pkg_schedule --> pkg_session_projection + pkg_schedule --> pkg_storage_domain + pkg_schedule --> pkg_tools + pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session @@ -1386,6 +1391,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1423,6 +1429,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | @@ -1604,7 +1611,6 @@ flowchart TD | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1662,6 +1668,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 22d75fe61c..62a60aef0d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -156,6 +156,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -193,6 +194,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -926,15 +928,6 @@ flowchart TD pkg_agent_preset_registry --> pkg_system_prompt pkg_agent_preset_registry --> pkg_tools pkg_agent_preset_registry --> pkg_typert_protocol - pkg_schedule --> pkg_agent - pkg_schedule --> pkg_brand - pkg_schedule --> pkg_invariants - pkg_schedule --> pkg_llm - pkg_schedule --> pkg_session - pkg_schedule --> pkg_session_persistence - pkg_schedule --> pkg_session_projection - pkg_schedule --> pkg_tools - pkg_schedule --> pkg_workspace pkg_session_checkpoint_policy --> pkg_agent pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session @@ -1318,6 +1311,18 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_schedule --> pkg_agent + pkg_schedule --> pkg_api_session_controller + pkg_schedule --> pkg_brand + pkg_schedule --> pkg_invariants + pkg_schedule --> pkg_llm + pkg_schedule --> pkg_session + pkg_schedule --> pkg_session_persistence + pkg_schedule --> pkg_session_projection + pkg_schedule --> pkg_storage_domain + pkg_schedule --> pkg_tools + pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session @@ -1388,6 +1393,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1425,6 +1431,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | @@ -1606,7 +1613,6 @@ flowchart TD | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1664,6 +1670,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 0eb4bb9107..000e032a8e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: e7c80d1de4d328b4 zh: d9f368c27fd9f998 /session-persistence-event-catalog/persistence-type-fingerprints: - en: 42ad01cbefcc0966 - zh: c21832fd903f4d6e + en: 0254fc43535dfaad + zh: 2f592cfae60e10ac /session-persistence-event-catalog/event-envelope: en: 7ebb1a2e0dd1f916 zh: 8946f415dda5c1b8 @@ -165,8 +165,8 @@ en: e5b55405bd2b69f5 zh: e5b55405bd2b69f5 /session-persistence-event-catalog/events/schedule/schedule-change-log-only: - en: 1d23d6738a98fbde - zh: 3da1dfebfe2f1d2e + en: 353442ce8925614f + zh: 71e6ab8296c74d2a /session-persistence-event-catalog/events/session: en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e @@ -528,8 +528,8 @@ en: 028791baa24a6a0a zh: 028791baa24a6a0a /session-persistence-event-catalog/resolved-persistence-types/in-history: - en: 81e6913fffa3c12a - zh: 806ed52e823cc08f + en: 7ee8e3e9b3947c22 + zh: 035ceca2d5031800 /session-persistence-event-catalog/resolved-persistence-types/in-progress: en: 61de2b0c9de8c15b zh: 61de2b0c9de8c15b @@ -897,11 +897,8 @@ en: d462b9a5e0855776 zh: d462b9a5e0855776 /session-persistence-event-catalog/resolved-persistence-types/4: - en: 1be6fa10b6117b5c - zh: 1be6fa10b6117b5c -/session-persistence-event-catalog/resolved-persistence-types/afterschedulerecord: - en: fa6f933b0bd06745 - zh: b66de9881c47473e + en: 9ec6b8bd755f3e33 + zh: 9ec6b8bd755f3e33 /session-persistence-event-catalog/resolved-persistence-types/agentinstructionchange: en: c79fd9b4b7a0a5ba zh: d196895911636220 @@ -927,11 +924,8 @@ en: 144fc40c557c39fb zh: 93edf73620f690bc /session-persistence-event-catalog/resolved-persistence-types/assistantstreamrecord~2: - en: 92d1cfcb2e6460c7 - zh: 709b94be1f8b7270 -/session-persistence-event-catalog/resolved-persistence-types/atschedulerecord: - en: 8b2f283ddc7660ac - zh: f449d9579770fae3 + en: e393deed01de3665 + zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: en: 86c7e2b4ae099661 zh: 2ffe1bd3f25b96fd @@ -939,11 +933,11 @@ en: 87f2aaf2b23a5249 zh: 3d6c5ed6603bee31 /session-persistence-event-catalog/resolved-persistence-types/contentblock: - en: e13fed596c512f6c - zh: 516df9e1d246151b + en: d3eb9a17ab27856f + zh: a71b16a1e36a36ac /session-persistence-event-catalog/resolved-persistence-types/contentblocktype: - en: 7569a19f6b02eaff - zh: aca4414298167b81 + en: 27dc8b92bae26f1f + zh: e05ace867fb59ae1 /session-persistence-event-catalog/resolved-persistence-types/contentblock~2: en: 9207aa0489ccf8db zh: 850dd92da6769998 @@ -966,11 +960,8 @@ en: d31020fa1fda6fa4 zh: 77984c4bf09443fb /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: - en: 89ddd311d931d6de - zh: 721587f81b40511b -/session-persistence-event-catalog/resolved-persistence-types/everyschedulerecord: - en: 936195016fec40d5 - zh: 60b2c64f544a2d2a + en: 54c487394ce0db1d + zh: 48d7c9affbe841cb /session-persistence-event-catalog/resolved-persistence-types/feedbackrecord: en: 3b6931556cfe6b9e zh: ce5a5b1d92ce9eac @@ -981,8 +972,8 @@ en: 4cab6c6ed2abedd8 zh: a6cdd344e6c36c24 /session-persistence-event-catalog/resolved-persistence-types/finishreason: - en: a140f3c2ab4f213f - zh: 2d90532ca0327934 + en: 49fd9834247f3cc2 + zh: 4378ead90069d0d6 /session-persistence-event-catalog/resolved-persistence-types/goalblockreason: en: b1589f9578a585dd zh: 3783dbbd8793bb37 @@ -1035,8 +1026,20 @@ en: c239aa9c90226c35 zh: 3561e77832c7c8b5 /session-persistence-event-catalog/resolved-persistence-types/jsonvalue~2: - en: 11f46fc45dcbe604 - zh: 2661bc7ebf7b6152 + en: 6fb52f5c682b7efd + zh: 129b3a4109b617bc +/session-persistence-event-catalog/resolved-persistence-types/legacyafterschedulerecord: + en: 8a1ee52ff092bd30 + zh: 10db0a03ecc0ddfd +/session-persistence-event-catalog/resolved-persistence-types/legacyatschedulerecord: + en: 1362c9c357bfd526 + zh: 1d22c433bf49ea95 +/session-persistence-event-catalog/resolved-persistence-types/legacyeveryschedulerecord: + en: 4db10a2563b18010 + zh: cf8d17875d5b9002 +/session-persistence-event-catalog/resolved-persistence-types/legacyschedulerecord: + en: d2a3b6594fc71ea0 + zh: a5a7df42594fdb57 /session-persistence-event-catalog/resolved-persistence-types/llmcallconfig: en: aa3a91341bf04b9e zh: d767b45d45846333 @@ -1080,8 +1083,8 @@ en: 771e9ab3e64076bc zh: 30d5095a93549d55 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: - en: f96aaab863f77802 - zh: 3d6f70473794ee06 + en: a3300babe57b6b67 + zh: 4aaf06a149b399c3 /session-persistence-event-catalog/resolved-persistence-types/oneshotsubagentdescriptordata: en: 9ba0cfd5ee9d6a38 zh: 1414fd7468c42651 @@ -1104,8 +1107,8 @@ en: 07e04c3e336f880f zh: 6f8986ede9994fa1 /session-persistence-event-catalog/resolved-persistence-types/replayenvelope: - en: 6d1a36d68e868f6b - zh: 8a4235fb147cd8f4 + en: 9a80b6f5eb08eaea + zh: 061c632b8f3e4d87 /session-persistence-event-catalog/resolved-persistence-types/requestcontext: en: cc8bb7075d546941 zh: 77209abf30744be0 @@ -1113,20 +1116,17 @@ en: 93c4403f94ecdc14 zh: 2b2eb0c400881d71 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: 15c400d874e75ff1 - zh: ebcd2df0ba05f0db + en: 13071a796a3bfcd2 + zh: 93e5fbb1cdf78a79 /session-persistence-event-catalog/resolved-persistence-types/schedulechange: - en: 6a8be1d4040fb88c - zh: fe5430a81d577932 + en: 16be53be76c16720 + zh: 38fd164dde86ccac /session-persistence-event-catalog/resolved-persistence-types/schedulecreatechange: - en: 2ade6ccef730c278 - zh: 9fe4aec379fc45d9 + en: c2d3053c1ce07725 + zh: 0a3201d7c483c7c1 /session-persistence-event-catalog/resolved-persistence-types/scheduledeletechange: - en: 59231642e68b813a - zh: 80be1173e96d670f -/session-persistence-event-catalog/resolved-persistence-types/schedulerecord: - en: f9867069eed2d3fb - zh: 391a44aba281b0e4 + en: 35c0b15c285d045e + zh: 8ccce6ea60dd5742 /session-persistence-event-catalog/resolved-persistence-types/sessionheader: en: 0f60d736cd9d6c94 zh: 205b8833a4efc5e5 @@ -1155,8 +1155,8 @@ en: ecc15ebadb77afdf zh: 97ac27d6a4175f6f /session-persistence-event-catalog/resolved-persistence-types/streamchunk: - en: 8682019620da1958 - zh: c4b8ba8f3c432194 + en: e865a9a3b7749c1a + zh: 16800bf55135c89e /session-persistence-event-catalog/resolved-persistence-types/subagentcatalogevent: en: e9c8f82e0497e5a5 zh: 55ab13b1a5067759 @@ -1203,8 +1203,8 @@ en: 8bf023246cbd0887 zh: dbf7113574c1bf59 /session-persistence-event-catalog/resolved-persistence-types/tokenusage: - en: bdf729b844aa389c - zh: c086d4fbc87c79c0 + en: 0aa983637b619157 + zh: e0f68258606aae78 /session-persistence-event-catalog/resolved-persistence-types/tooladditionblock: en: 7ba7c600fc529015 zh: 94b69e9d5bd1fcfd @@ -1224,8 +1224,8 @@ en: 2258683c3cfa4ce3 zh: 5663aa76c911761b /session-persistence-event-catalog/resolved-persistence-types/toolschema: - en: 9c8dae9974f1670f - zh: f1f10c58059fc868 + en: 6e1e6ebc7c1c38c8 + zh: e47a3ca80c4c3b71 /session-persistence-event-catalog/resolved-persistence-types/toolschema~2: en: 12c14727bb7834e2 zh: b0f3c877873c36db @@ -1278,8 +1278,8 @@ en: 38211d71372021e4 zh: c0305af81a67e285 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 8f788be68a2720c8 - zh: 67ec07a9c8699f6f + en: 052f9ce1988e44f2 + zh: 5adaad6c9bca43e2 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1416,11 +1416,11 @@ en: 423077da59c080a5 zh: ea8a92a786603279 /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: - en: f0e715f0fdaf08fa - zh: ab28e85aad591d69 + en: 349cd90f2598fd12 + zh: 1eda02d520ad5d76 /session-persistence-event-catalog/resolved-persistence-types/failure-kind-error: - en: 821ba2489295bff5 - zh: b5682cea9895aff7 + en: 51af619ed8532a43 + zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: en: 62ca405eba4d2757 zh: c6a8861533363ae9 @@ -1545,8 +1545,8 @@ en: 7c000bffbf0c567e zh: b3210306a125820b /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: 1fb4225e5c5c6384 - zh: 3489785e7ae800f5 + en: 20433ac79d2cf7a7 + zh: 8907d87c5179fca8 /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1662,8 +1662,8 @@ en: c99d8a67438771ff zh: 070f6160fc419fa5 /session-persistence-event-catalog/resolved-persistence-types/kind-stop: - en: 01db92f4780227c2 - zh: e04e2eeb4af38d5c + en: 09ce6f3196938ccf + zh: 1b8d5540f47fd3aa /session-persistence-event-catalog/resolved-persistence-types/kind-time-context: en: 5733addfc8b12a47 zh: aaf60d5396e30971 @@ -1707,8 +1707,8 @@ en: b05115b92a176f3a zh: 22ba93572bd0209a /session-persistence-event-catalog/resolved-persistence-types/kind-tool-calls: - en: d09c7fcfd1693606 - zh: a04e0bd15f5b87ea + en: 65fb50e399cddd05 + zh: 9e713bbcb1b7db35 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-goal: en: e11ea3169004ae33 zh: 7d6a3a77cb9bf205 @@ -1857,11 +1857,11 @@ en: bb507524bcc39c53 zh: 8314898562437473 /session-persistence-event-catalog/resolved-persistence-types/type-block-end: - en: 51aab19ee8c0d679 - zh: 1a6b88cf5a137de4 + en: 8c5a05fa6adf3e70 + zh: 66768b25d6fb3e07 /session-persistence-event-catalog/resolved-persistence-types/type-block-start: - en: 239cf3971f4cfc3f - zh: 0da0583120e7f271 + en: 5caed426cab416ee + zh: 86f47f90f8c440f2 /session-persistence-event-catalog/resolved-persistence-types/type-chunk: en: 2186ede3e9917aea zh: 0cfef64045dce95b @@ -1899,8 +1899,8 @@ en: 14e4b48d856bcf57 zh: 72ba176535dc7f3f /session-persistence-event-catalog/resolved-persistence-types/type-finish: - en: b3add00734cb9911 - zh: 377548efe8ef9a31 + en: 21627e72a24990ff + zh: d71375de325ee9f8 /session-persistence-event-catalog/resolved-persistence-types/type-goal-change: en: aad2516b62d3ee83 zh: e7204c4dc4e8e408 @@ -1932,8 +1932,8 @@ en: 34e5591d75322649 zh: a216839f305c9796 /session-persistence-event-catalog/resolved-persistence-types/type-reasoning-delta: - en: db3a10d417867d7b - zh: 4378e4b03c05067b + en: a18feb76be195fa0 + zh: 3db7147ee4c5c963 /session-persistence-event-catalog/resolved-persistence-types/type-request-context: en: f8addb1a01da9ca1 zh: 4ef11ebddd302c05 @@ -1941,11 +1941,11 @@ en: f34ce1d84ad0fb1e zh: c1c49f37c1e33f8c /session-persistence-event-catalog/resolved-persistence-types/type-sandbox-mode: - en: 590152834b153366 - zh: 1d051321572572a7 + en: 4d2313b10fef1ff0 + zh: 3a2cd6bf870ac7d3 /session-persistence-event-catalog/resolved-persistence-types/type-schedule-change: - en: 6c114aa51492ebf0 - zh: 1270e031f482c587 + en: 40e813f93a0215de + zh: d46d0f7e04e7323b /session-persistence-event-catalog/resolved-persistence-types/type-session-log-deepseek-delivery-accepted: en: d4dbca005a27b78b zh: b5792c3f38a6ba75 @@ -1992,8 +1992,8 @@ en: 1484738718de5312 zh: 3e6fbe9c64068225 /session-persistence-event-catalog/resolved-persistence-types/type-text-delta: - en: f32f07d70ae189ff - zh: 615eae6be0834444 + en: 47ae2d57be710450 + zh: b072753349ac505e /session-persistence-event-catalog/resolved-persistence-types/type-todo-write: en: 2fa7ba77598a95b4 zh: 50972b51d77e9155 @@ -2001,8 +2001,8 @@ en: 32f732b97739e4d2 zh: f288e48e356969c0 /session-persistence-event-catalog/resolved-persistence-types/type-tool-call-delta: - en: e8023bce4c9a3c15 - zh: 73308ba59a8ca17a + en: 1df89f427ccac815 + zh: 1bec3d025ea1f7e3 /session-persistence-event-catalog/resolved-persistence-types/type-tool-workflow-agent-end: en: 8eb5e1eb95722bc7 zh: 8dd5980029600648 @@ -2034,8 +2034,8 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: d15bdc3a5b652790 - zh: 6b78c1388fba47d6 + en: a01e61480accb34b + zh: 6387a3cc7666aa7e /session-persistence-event-catalog/resolved-persistence-types/type-user-message: en: 58d3e616db84f840 zh: 9aed64a03347caad diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e654091bce..cdf607da2b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -50,7 +50,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:request/context` | event | `37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf` | [`{ type: "request/context" }`](#persistence-type-sha256-37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf) | | `event:request/header` | event | `4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41` | [`{ type: "request/header" }`](#persistence-type-sha256-4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41) | | `event:sandbox/mode` | event | `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | [`{ type: "sandbox/mode" }`](#persistence-type-sha256-516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415) | -| `event:schedule/change` | event | `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` | [`{ type: "schedule/change" }`](#persistence-type-sha256-2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42) | +| `event:schedule/change` | event | `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | [`{ type: "schedule/change" }`](#persistence-type-sha256-a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb) | | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | @@ -729,7 +729,7 @@ Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/ 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** This request begins a distinct model-message series, independently of the header reason. */ startsSeries?: true } ``` @@ -775,7 +775,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s Types: [ScheduleChange](subsystems/schedule.md) -Source: [`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts) +Source: [`packages/schedule/schedule/src/types.ts:358`](../packages/schedule/schedule/src/types.ts) ### `session/*` @@ -1919,7 +1919,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -Sources: [`packages/llm/llm/src/types.ts:397`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -2907,26 +2907,6 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0` `4` - - - - - - -### `AfterScheduleRecord` - -SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe` - -Sources: [`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `afterSeconds` | required | `number` | -| `id` | required | `string` | -| `kind` | required | `"after"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3074,25 +3054,6 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef). - - - - - - -### `AtScheduleRecord` - -SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e` - -Sources: [`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `id` | required | `string` | -| `kind` | required | `"at"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3137,7 +3098,7 @@ Sources: [`packages/compaction/compaction/src/checkpoint.ts:22`](../packages/com SHA-256: `8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5` -Sources: [`packages/llm/llm/src/types.ts:151`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:150`](../packages/llm/llm/src/types.ts) One of: @@ -3159,7 +3120,7 @@ One of: SHA-256: `7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68` -Sources: [`packages/llm/llm/src/types.ts:149`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:148`](../packages/llm/llm/src/types.ts) One of: @@ -3298,7 +3259,7 @@ Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` -Sources: [`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3307,26 +3268,6 @@ Sources: [`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/sch | `operation` | required | `"dispatch"` | | `version` | required | `1` | - - - - - - -### `EveryScheduleRecord` - -SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14` - -Sources: [`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `everySeconds` | required | `number` | -| `id` | required | `string` | -| `kind` | required | `"every"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3389,7 +3330,7 @@ Sources: [`packages/llm/llm/src/types.ts:98`](../packages/llm/llm/src/types.ts) SHA-256: `5808e36c95355f0e745201b5db1b6bcec4ebf5adb75d3e82d7d59cc89635ab80` -Sources: [`packages/llm/llm/src/types.ts:166`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:165`](../packages/llm/llm/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -3725,6 +3666,86 @@ SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` Array of [`JsonValue`](#persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6). + + + + + + +### `LegacyAfterScheduleRecord` + +SHA-256: `3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f` + +Sources: [`packages/schedule/schedule/src/types.ts:161`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `afterSeconds` | required | `number` | +| `id` | required | `string` | +| `kind` | required | `"after"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyAtScheduleRecord` + +SHA-256: `b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505` + +Sources: [`packages/schedule/schedule/src/types.ts:167`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `id` | required | `string` | +| `kind` | required | `"at"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyEveryScheduleRecord` + +SHA-256: `a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb` + +Sources: [`packages/schedule/schedule/src/types.ts:173`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `everySeconds` | required | `number` | +| `id` | required | `string` | +| `kind` | required | `"every"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyScheduleRecord` + +SHA-256: `4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd` + +Sources: [`packages/schedule/schedule/src/types.ts:185`](../packages/schedule/schedule/src/types.ts) + +| kind | Form property | Other required fields | Full definition | +|---|---|---|---| +| `after` | not declared | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAfterScheduleRecord`](#persistence-type-sha256-3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f) | +| `at` | not declared | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAtScheduleRecord`](#persistence-type-sha256-b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505) | +| `every` | not declared | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyEveryScheduleRecord`](#persistence-type-sha256-a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb) | + @@ -4092,7 +4113,7 @@ Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/se SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af` -Sources: [`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4231,7 +4252,7 @@ Sources: [`packages/llm/llm/src/types.ts:68`](../packages/llm/llm/src/types.ts) SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -Sources: [`packages/llm/llm/src/types.ts:418`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4294,7 +4315,7 @@ One of: - `"read-only"` - `"workspace-write"` - + @@ -4302,18 +4323,18 @@ One of: ### `ScheduleChange` -SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` +SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f` -Sources: [`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts) One of: - [`EveryScheduleDispatchChange`](#persistence-type-sha256-e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344) - [`ScheduleDeleteChange`](#persistence-type-sha256-18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0) - [`OneShotScheduleDispatchChange`](#persistence-type-sha256-8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af) -- [`ScheduleCreateChange`](#persistence-type-sha256-ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f) +- [`ScheduleCreateChange`](#persistence-type-sha256-eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4) - + @@ -4321,14 +4342,14 @@ One of: ### `ScheduleCreateChange` -SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` +SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4` -Sources: [`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| | `operation` | required | `"create"` | -| `schedule` | required | [`ScheduleRecord`](#persistence-type-sha256-d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87) | +| `schedule` | required | [`LegacyScheduleRecord`](#persistence-type-sha256-4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd) | | `version` | required | `1` | @@ -4341,7 +4362,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/sch SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` -Sources: [`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4349,24 +4370,6 @@ Sources: [`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/sch | `operation` | required | `"delete"` | | `version` | required | `1` | - - - - - - -### `ScheduleRecord` - -SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87` - -Sources: [`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts) - -| kind | Form property | Other required fields | Full definition | -|---|---|---|---| -| `after` | not declared | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AfterScheduleRecord`](#persistence-type-sha256-27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe) | -| `at` | not declared | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AtScheduleRecord`](#persistence-type-sha256-658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e) | -| `every` | not declared | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`EveryScheduleRecord`](#persistence-type-sha256-66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14) | - @@ -4543,7 +4546,7 @@ Sources: [`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/i SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -Sources: [`packages/llm/llm/src/types.ts:440`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) One of: @@ -4837,7 +4840,7 @@ Array of [`TodoItem`](#persistence-type-sha256-a99f76fd149051c363f960bfeeb5b9509 SHA-256: `fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394` -Sources: [`packages/llm/llm/src/types.ts:176`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:175`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4967,7 +4970,7 @@ Sources: [`packages/llm/llm/src/message.ts:173`](../packages/llm/llm/src/message SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -Sources: [`packages/llm/llm/src/types.ts:461`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5315,7 +5318,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5936,7 +5939,7 @@ Sources: [`packages/core/session/src/types.ts:375`](../packages/core/session/src SHA-256: `8e31bb958e9b015e6f8407eaade32217068159e816f3cc99c3ad76eb889732e0` -Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:160`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5949,7 +5952,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` -Sources: [`packages/llm/llm/src/types.ts:162`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6460,7 +6463,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:160`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6900,7 +6903,7 @@ SHA-256: `28936c614bd4e7e2774806edcbb3cbfd3ee643ac1cbf0bbdff43d915505a8fe3` SHA-256: `1d76406cbb2ddadb0dbbb51e9d8bc6cf678fed455cf8eb512f36f7c86d37a62f` -Sources: [`packages/llm/llm/src/types.ts:158`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:157`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7068,7 +7071,7 @@ SHA-256: `49dab019d00e8c26f27ad2ccff7b604d69e323ae4d17e0d2a7b6b8a830b1ade2` SHA-256: `287211846beea1eccb5f9acee00d20388a38fcb2e95858fa2332d6f58529379a` -Sources: [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:158`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7712,7 +7715,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -Sources: [`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7726,7 +7729,7 @@ Sources: [`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts) SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -Sources: [`packages/llm/llm/src/types.ts:441`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7932,7 +7935,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -Sources: [`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8106,7 +8109,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:28`](../packages/llm/llm/src SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -Sources: [`packages/llm/llm/src/types.ts:443`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8162,17 +8165,17 @@ SHA-256: `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | `time` | required | `number` | | `type` | required | `"sandbox/mode"` | - + ### `{ type: "schedule/change" }` -SHA-256: `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` +SHA-256: `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | Property | Presence | Type | |---|---|---| -| `data` | required | [`ScheduleChange`](#persistence-type-sha256-e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1) | +| `data` | required | [`ScheduleChange`](#persistence-type-sha256-2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8426,7 +8429,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:21`](../packages/llm/llm/src SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -Sources: [`packages/llm/llm/src/types.ts:442`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8474,7 +8477,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:35`](../packages/llm/llm/src SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -Sources: [`packages/llm/llm/src/types.ts:444`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8652,7 +8655,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 744bc3e42d..146d6a3b51 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -52,7 +52,7 @@ | `event:request/context` | event | `37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf` | [`{ type: "request/context" }`](#persistence-type-sha256-37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf) | | `event:request/header` | event | `4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41` | [`{ type: "request/header" }`](#persistence-type-sha256-4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41) | | `event:sandbox/mode` | event | `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | [`{ type: "sandbox/mode" }`](#persistence-type-sha256-516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415) | -| `event:schedule/change` | event | `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` | [`{ type: "schedule/change" }`](#persistence-type-sha256-2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42) | +| `event:schedule/change` | event | `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | [`{ type: "schedule/change" }`](#persistence-type-sha256-a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb) | | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | @@ -731,7 +731,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** This request begins a distinct model-message series, independently of the header reason. */ startsSeries?: true } ``` @@ -777,7 +777,7 @@ export type SessionEvent = { 类型:[ScheduleChange](subsystems/schedule.zh.md) -来源:[`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:358`](../packages/schedule/schedule/src/types.ts) ### `session/*` @@ -1921,7 +1921,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -来源:[`packages/llm/llm/src/types.ts:397`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -2909,26 +2909,6 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0` `4` - - - - - - -### `AfterScheduleRecord` - -SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe` - -来源:[`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `afterSeconds` | 必需 | `number` | -| `id` | 必需 | `string` | -| `kind` | 必需 | `"after"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3076,25 +3056,6 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef) 的数组。 - - - - - - -### `AtScheduleRecord` - -SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e` - -来源:[`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `id` | 必需 | `string` | -| `kind` | 必需 | `"at"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3139,7 +3100,7 @@ SHA-256: `31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648` SHA-256: `8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5` -来源:[`packages/llm/llm/src/types.ts:151`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:150`](../packages/llm/llm/src/types.ts) 以下类型之一: @@ -3161,7 +3122,7 @@ SHA-256: `8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5` SHA-256: `7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68` -来源:[`packages/llm/llm/src/types.ts:149`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:148`](../packages/llm/llm/src/types.ts) 以下类型之一: @@ -3300,7 +3261,7 @@ SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` -来源:[`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3309,26 +3270,6 @@ SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` | `operation` | 必需 | `"dispatch"` | | `version` | 必需 | `1` | - - - - - - -### `EveryScheduleRecord` - -SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14` - -来源:[`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `everySeconds` | 必需 | `number` | -| `id` | 必需 | `string` | -| `kind` | 必需 | `"every"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3391,7 +3332,7 @@ SHA-256: `f2da0f27c1e6add59982c37b15e4a4912c5b33466859cce3b13d503600e24aef` SHA-256: `5808e36c95355f0e745201b5db1b6bcec4ebf5adb75d3e82d7d59cc89635ab80` -来源:[`packages/llm/llm/src/types.ts:166`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:165`](../packages/llm/llm/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -3727,6 +3668,86 @@ SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` [`JsonValue`](#persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6) 的数组。 + + + + + + +### `LegacyAfterScheduleRecord` + +SHA-256: `3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f` + +来源:[`packages/schedule/schedule/src/types.ts:161`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `afterSeconds` | 必需 | `number` | +| `id` | 必需 | `string` | +| `kind` | 必需 | `"after"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyAtScheduleRecord` + +SHA-256: `b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505` + +来源:[`packages/schedule/schedule/src/types.ts:167`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `id` | 必需 | `string` | +| `kind` | 必需 | `"at"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyEveryScheduleRecord` + +SHA-256: `a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb` + +来源:[`packages/schedule/schedule/src/types.ts:173`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `everySeconds` | 必需 | `number` | +| `id` | 必需 | `string` | +| `kind` | 必需 | `"every"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyScheduleRecord` + +SHA-256: `4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd` + +来源:[`packages/schedule/schedule/src/types.ts:185`](../packages/schedule/schedule/src/types.ts) + +| kind | form 属性 | 其他必需字段 | 完整定义 | +|---|---|---|---| +| `after` | 未声明 | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAfterScheduleRecord`](#persistence-type-sha256-3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f) | +| `at` | 未声明 | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAtScheduleRecord`](#persistence-type-sha256-b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505) | +| `every` | 未声明 | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyEveryScheduleRecord`](#persistence-type-sha256-a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb) | + @@ -4094,7 +4115,7 @@ SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af` -来源:[`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4233,7 +4254,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e` SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -来源:[`packages/llm/llm/src/types.ts:418`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4296,7 +4317,7 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` - `"read-only"` - `"workspace-write"` - + @@ -4304,18 +4325,18 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` ### `ScheduleChange` -SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` +SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f` -来源:[`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts) 以下类型之一: - [`EveryScheduleDispatchChange`](#persistence-type-sha256-e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344) - [`ScheduleDeleteChange`](#persistence-type-sha256-18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0) - [`OneShotScheduleDispatchChange`](#persistence-type-sha256-8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af) -- [`ScheduleCreateChange`](#persistence-type-sha256-ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f) +- [`ScheduleCreateChange`](#persistence-type-sha256-eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4) - + @@ -4323,14 +4344,14 @@ SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` ### `ScheduleCreateChange` -SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` +SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4` -来源:[`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `operation` | 必需 | `"create"` | -| `schedule` | 必需 | [`ScheduleRecord`](#persistence-type-sha256-d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87) | +| `schedule` | 必需 | [`LegacyScheduleRecord`](#persistence-type-sha256-4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd) | | `version` | 必需 | `1` | @@ -4343,7 +4364,7 @@ SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` -来源:[`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4351,24 +4372,6 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` | `operation` | 必需 | `"delete"` | | `version` | 必需 | `1` | - - - - - - -### `ScheduleRecord` - -SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87` - -来源:[`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts) - -| kind | form 属性 | 其他必需字段 | 完整定义 | -|---|---|---|---| -| `after` | 未声明 | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AfterScheduleRecord`](#persistence-type-sha256-27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe) | -| `at` | 未声明 | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AtScheduleRecord`](#persistence-type-sha256-658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e) | -| `every` | 未声明 | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`EveryScheduleRecord`](#persistence-type-sha256-66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14) | - @@ -4545,7 +4548,7 @@ SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db` SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -来源:[`packages/llm/llm/src/types.ts:440`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) 以下类型之一: @@ -4839,7 +4842,7 @@ SHA-256: `4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea` SHA-256: `fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394` -来源:[`packages/llm/llm/src/types.ts:176`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:175`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4969,7 +4972,7 @@ SHA-256: `4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731` SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -来源:[`packages/llm/llm/src/types.ts:461`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5317,7 +5320,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5938,7 +5941,7 @@ SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` SHA-256: `8e31bb958e9b015e6f8407eaade32217068159e816f3cc99c3ad76eb889732e0` -来源:[`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:160`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5951,7 +5954,7 @@ SHA-256: `8e31bb958e9b015e6f8407eaade32217068159e816f3cc99c3ad76eb889732e0` SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` -来源:[`packages/llm/llm/src/types.ts:162`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6462,7 +6465,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:160`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6902,7 +6905,7 @@ SHA-256: `28936c614bd4e7e2774806edcbb3cbfd3ee643ac1cbf0bbdff43d915505a8fe3` SHA-256: `1d76406cbb2ddadb0dbbb51e9d8bc6cf678fed455cf8eb512f36f7c86d37a62f` -来源:[`packages/llm/llm/src/types.ts:158`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:157`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7070,7 +7073,7 @@ SHA-256: `49dab019d00e8c26f27ad2ccff7b604d69e323ae4d17e0d2a7b6b8a830b1ade2` SHA-256: `287211846beea1eccb5f9acee00d20388a38fcb2e95858fa2332d6f58529379a` -来源:[`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:158`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7714,7 +7717,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -来源:[`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7728,7 +7731,7 @@ SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -来源:[`packages/llm/llm/src/types.ts:441`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7934,7 +7937,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -来源:[`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8108,7 +8111,7 @@ SHA-256: `71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd` SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -来源:[`packages/llm/llm/src/types.ts:443`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8164,17 +8167,17 @@ SHA-256: `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | `time` | 必需 | `number` | | `type` | 必需 | `"sandbox/mode"` | - + ### `{ type: "schedule/change" }` -SHA-256: `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` +SHA-256: `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`ScheduleChange`](#persistence-type-sha256-e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1) | +| `data` | 必需 | [`ScheduleChange`](#persistence-type-sha256-2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8428,7 +8431,7 @@ SHA-256: `e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33` SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -来源:[`packages/llm/llm/src/types.ts:442`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8476,7 +8479,7 @@ SHA-256: `3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6` SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -来源:[`packages/llm/llm/src/types.ts:444`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8654,7 +8657,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -来源:[`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml b/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml new file mode 100644 index 0000000000..4ab106dfe2 --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-09-18-schedule-optional-title.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 docs/persistence-changes/2026-09-18-schedule-optional-title.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-09-18-schedule-optional-title: + en: dff09041a3682fc5 + zh: f27d9f3e7dfe8996 +/2026-09-18-schedule-optional-title/summary: + en: cf0b4015528ea944 + zh: c1400b008fffe333 +/2026-09-18-schedule-optional-title/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-09-18-schedule-optional-title/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-09-18-schedule-optional-title/compatibility: + en: d9caa582fc9306ee + zh: 3a725f4e16a5bb8e +/2026-09-18-schedule-optional-title/verification: + en: 62bf89a21ce1ff76 + zh: 71ede4795dd8a053 +/2026-09-18-schedule-optional-title/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.md b/docs/persistence-changes/2026-09-18-schedule-optional-title.md new file mode 100644 index 0000000000..30e129548b --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-18-schedule-optional-title + +English | [中文](2026-09-18-schedule-optional-title.zh.md) + +## Summary + +Makes the stored title optional on the after, at, and every variants of a persisted schedule/change create record. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-18-schedule-optional-title +baseline: false +changes: + - root: "event:schedule/change" + previous: "2026-09-11-initial" + after: "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb" + decision: same-version +``` + + +## Compatibility + +A version-1 Session event written before titles existed has no title member, so those logs decode and fold without one instead of being refused. Readers that require a name treat an absent title as an unnamed task. The Host task record still requires the member: the storage decoder rejects a stored task without it, and creation, update, and the schedule tools still require it. + + +## Verification + +pnpm exec vitest run packages/schedule/schedule/tests: 18 files, 789 tests passed. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json b/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json new file mode 100644 index 0000000000..0b9bc4798a --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json @@ -0,0 +1,302 @@ +{ + "formatVersion": 1, + "roots": [ + { + "key": "event:schedule/change", + "kind": "event", + "event": "schedule/change", + "surface": false, + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 19, + "optional": true + }, + { + "name": "seq", + "type": 13, + "optional": false + }, + { + "name": "time", + "type": 13, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 6, + 8, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 3, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 10, + "optional": false + }, + { + "name": "schedule", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 12, + 15, + 17 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 14, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 16, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 18, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "schedule/change" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md b/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md new file mode 100644 index 0000000000..d46d5920c1 --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-18-schedule-optional-title + +[English](2026-09-18-schedule-optional-title.md) | 中文 + +## 概述 + +将持久化的 schedule/change create 记录中 after、at、every 三个变体的已存 title 改为可选。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-18-schedule-optional-title +baseline: false +changes: + - root: "event:schedule/change" + previous: "2026-09-11-initial" + after: "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb" + decision: same-version +``` + + +## 兼容性 + +在 title 出现之前写入的版本 1 Session 事件没有 title 成员,因此这些日志可以在没有该成员的情况下解码与折叠,而不会被拒绝。需要名称的读取方将缺失的 title 视为未命名任务。宿主任务记录仍然要求该成员:存储解码器拒绝没有它的已存任务,创建、更新与 schedule 工具也仍然要求它。 + + +## 验证 + +pnpm exec vitest run packages/schedule/schedule/tests:18 个文件、789 个测试通过。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 446bcf7503..7472359902 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -11189,7 +11189,7 @@ "kind": "event", "event": "schedule/change", "surface": false, - "digest": "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42", + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", "schema": { "root": 0, "nodes": [ @@ -11377,6 +11377,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -11416,6 +11421,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -11446,6 +11456,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -22477,7 +22492,7 @@ "packages/llm/llm/src/types.ts#ReplayEnvelope" ], "sources": [ - "packages/llm/llm/src/types.ts:418" + "packages/llm/llm/src/types.ts:430" ] }, { @@ -29438,7 +29453,7 @@ "packages/schedule/schedule/src/types.ts#ScheduleDeleteChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:81" + "packages/schedule/schedule/src/types.ts:230" ] }, { @@ -30996,7 +31011,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:158" + "packages/llm/llm/src/types.ts:157" ] }, { @@ -32630,63 +32645,6 @@ "names": [], "sources": [] }, - { - "digest": "27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 1, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 3, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#AfterScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:15" - ] - }, { "digest": "287211846beea1eccb5f9acee00d20388a38fcb2e95858fa2332d6f58529379a", "schema": { @@ -32711,7 +32669,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:159" + "packages/llm/llm/src/types.ts:158" ] }, { @@ -32836,285 +32794,6 @@ "names": [], "sources": [] }, - { - "digest": "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 19, - "optional": true - }, - { - "name": "seq", - "type": 13, - "optional": false - }, - { - "name": "time", - "type": 13, - "optional": false - }, - { - "name": "type", - "type": 20, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 6, - 8, - 9 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "acceptedAt", - "type": 3, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "dispatch" - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 7, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "delete" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 10, - "optional": false - }, - { - "name": "schedule", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 12, - 15, - 17 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 13, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 14, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 13, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 16, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 18, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "schedule/change" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "2aad1179f957631477b582e57cd8f538cb82618fc3fb816bde1171154235b065", "schema": { @@ -33284,7 +32963,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:443" + "packages/llm/llm/src/types.ts:455" ] }, { @@ -33388,6 +33067,265 @@ "names": [], "sources": [] }, + { + "digest": "2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 5, + 7, + 8 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 6, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 9, + "optional": false + }, + { + "name": "schedule", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 11, + 14, + 16 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 12, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 13, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 12, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 15, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 17, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#ScheduleChange" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:256" + ] + }, { "digest": "2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c", "schema": { @@ -36140,7 +36078,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:441" + "packages/llm/llm/src/types.ts:453" ] }, { @@ -39330,6 +39268,68 @@ "names": [], "sources": [] }, + { + "digest": "3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 1, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 3, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:161" + ] + }, { "digest": "408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c", "schema": { @@ -43014,7 +43014,7 @@ "packages/llm/llm/src/types.ts#StreamChunk" ], "sources": [ - "packages/llm/llm/src/types.ts:440" + "packages/llm/llm/src/types.ts:452" ] }, { @@ -44121,6 +44121,151 @@ "names": [], "sources": [] }, + { + "digest": "4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 5, + 7 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 4, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 8, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:185" + ] + }, { "digest": "4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731", "schema": { @@ -44624,7 +44769,7 @@ "packages/llm/llm/src/brand.ts:31", "packages/llm/llm/src/brand.ts:43", "packages/llm/llm/src/brand.ts:67", - "packages/schedule/schedule/src/types.ts:12", + "packages/schedule/schedule/src/types.ts:14", "packages/session/session-title/src/index.ts:42", "packages/session/session-title/src/types.ts:17", "packages/webhook/webhook/src/brand.ts:12", @@ -50614,7 +50759,7 @@ "packages/llm/llm/src/types.ts#FinishReason" ], "sources": [ - "packages/llm/llm/src/types.ts:166" + "packages/llm/llm/src/types.ts:165" ] }, { @@ -54330,54 +54475,6 @@ "packages/llm/llm/src/message.ts:162" ] }, - { - "digest": "658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 1, - "optional": false - }, - { - "name": "kind", - "type": 2, - "optional": false - }, - { - "name": "prompt", - "type": 1, - "optional": false - }, - { - "name": "scheduledAt", - "type": 1, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#AtScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:29" - ] - }, { "digest": "6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25", "schema": { @@ -54560,64 +54657,7 @@ "packages/llm/llm/src/types.ts#ToolSchema" ], "sources": [ - "packages/llm/llm/src/types.ts:461" - ] - }, - { - "digest": "66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 1, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 3, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "every" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#EveryScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:41" + "packages/llm/llm/src/types.ts:473" ] }, { @@ -58136,7 +58176,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:162" + "packages/llm/llm/src/types.ts:161" ] }, { @@ -60897,7 +60937,7 @@ "packages/llm/llm/src/types.ts#ContentBlockType" ], "sources": [ - "packages/llm/llm/src/types.ts:149" + "packages/llm/llm/src/types.ts:148" ] }, { @@ -61484,7 +61524,7 @@ "packages/llm/llm/src/types.ts#ContentBlock" ], "sources": [ - "packages/llm/llm/src/types.ts:151" + "packages/llm/llm/src/types.ts:150" ] }, { @@ -63183,7 +63223,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:444" + "packages/llm/llm/src/types.ts:456" ] }, { @@ -63650,7 +63690,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:161" + "packages/llm/llm/src/types.ts:160" ] }, { @@ -63758,7 +63798,7 @@ "packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:88" + "packages/schedule/schedule/src/types.ts:237" ] }, { @@ -64253,7 +64293,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:442" + "packages/llm/llm/src/types.ts:454" ] }, { @@ -68471,6 +68511,300 @@ "names": [], "sources": [] }, + { + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 19, + "optional": true + }, + { + "name": "seq", + "type": 13, + "optional": false + }, + { + "name": "time", + "type": 13, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 6, + 8, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 3, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 10, + "optional": false + }, + { + "name": "schedule", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 12, + 15, + 17 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 14, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 16, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 18, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "schedule/change" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0baff42eb900561f91032b6950a3925cf19c5c8d9ba33948ac69e4ac7dee573", "schema": { @@ -68976,7 +69310,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:447" + "packages/llm/llm/src/types.ts:459" ] }, { @@ -69540,6 +69874,68 @@ "names": [], "sources": [] }, + { + "digest": "a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 1, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 3, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "every" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyEveryScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:173" + ] + }, { "digest": "a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d", "schema": { @@ -70340,7 +70736,7 @@ "names": [], "sources": [ "packages/core/session/src/types.ts:214", - "packages/llm/llm/src/types.ts:160" + "packages/llm/llm/src/types.ts:159" ] }, { @@ -72178,6 +72574,59 @@ "names": [], "sources": [] }, + { + "digest": "b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 1, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "prompt", + "type": 1, + "optional": false + }, + { + "name": "scheduledAt", + "type": 1, + "optional": false + }, + { + "name": "title", + "type": 1, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyAtScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:167" + ] + }, { "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", "schema": { @@ -74016,165 +74465,6 @@ "packages/compaction/compaction/src/types.ts:72" ] }, - { - "digest": "ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 1, - "optional": false - }, - { - "name": "schedule", - "type": 2, - "optional": false - }, - { - "name": "version", - "type": 11, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 3, - 7, - 9 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 4, - "optional": false - }, - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 6, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 4, - "optional": false - }, - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 8, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 10, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - }, - { - "kind": "literal", - "value": 1 - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleCreateChange" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:74" - ] - }, { "digest": "cb1c8959e1726f82f9a3d2ef2bc90bf8c451a98fd56175e956022124c770c818", "schema": { @@ -77737,7 +78027,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:445" + "packages/llm/llm/src/types.ts:457" ] }, { @@ -78037,136 +78327,6 @@ "names": [], "sources": [] }, - { - "digest": "d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 5, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 4, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 6, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 8, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:71" - ] - }, { "digest": "d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241", "schema": { @@ -81555,250 +81715,6 @@ "names": [], "sources": [] }, - { - "digest": "e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 5, - 7, - 8 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "acceptedAt", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 3, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "dispatch" - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 6, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "delete" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 3, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 9, - "optional": false - }, - { - "name": "schedule", - "type": 10, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 11, - 14, - 16 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 12, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 13, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 12, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 15, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 17, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleChange" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:107" - ] - }, { "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", "schema": { @@ -82985,7 +82901,7 @@ "packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:95" + "packages/schedule/schedule/src/types.ts:244" ] }, { @@ -83842,6 +83758,180 @@ "names": [], "sources": [] }, + { + "digest": "eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 1, + "optional": false + }, + { + "name": "schedule", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 3, + 7, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 8, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 10, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#ScheduleCreateChange" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:223" + ] + }, { "digest": "eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80", "schema": { @@ -87154,7 +87244,7 @@ }, "names": [], "sources": [ - "packages/llm/llm/src/types.ts:446" + "packages/llm/llm/src/types.ts:458" ] }, { @@ -87323,7 +87413,7 @@ "packages/llm/llm/src/types.ts#SystemPromptUpdate" ], "sources": [ - "packages/llm/llm/src/types.ts:397" + "packages/llm/llm/src/types.ts:396" ] }, { @@ -96946,7 +97036,7 @@ "packages/llm/llm/src/types.ts#TokenUsage" ], "sources": [ - "packages/llm/llm/src/types.ts:176" + "packages/llm/llm/src/types.ts:175" ] }, { diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 0f2b71ceeb..a7934d3e58 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: abd1088109b69d49 - zh: 7564a0579de5e142 + en: 58466f6fd60755eb + zh: ab46a374f5a3c3ed diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index d2e8dea206..479dbe7cb3 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -13,7 +13,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, Typert registries, and the Host Gateway/Client API boundaries | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | -| [schedule.md](schedule.md) | Session-local reminder records, durable transitions, active views, and ordinary-conversation delivery | +| [schedule.md](schedule.md) | Host-owned reminder records, durable transitions, active and ended views, and ordinary-conversation delivery | | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant | | [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the Host-served `WorkspaceChangesSummary` of changed files from git snapshots | | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 78c97a016a..a6b9226df6 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -13,7 +13,7 @@ | [scope.md](scope.zh.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [typert.md](typert.zh.md) | 远程调用描述符、lookup/Context 声明、Typert 注册表,以及 Host Gateway/Client API 边界 | | [goal.md](goal.zh.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | -| [schedule.md](schedule.zh.md) | 仅限会话内的提醒记录、持久转换、活动视图与普通对话交付 | +| [schedule.md](schedule.zh.md) | Host 拥有的提醒记录、持久转换、活动和已结束视图与普通对话交付 | | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和未结束轮次不变式 | | [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出、由 Host 提供的 `WorkspaceChangesSummary` 改动摘要 | | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 | diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index f9baaaa97e..64722160fa 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -18,8 +18,8 @@ en: 464315a953330eb2 zh: d9815591f3abc2cb /user-credentials/embedded-platform-credentials: - en: 7f3864e41e315565 - zh: 32a81c5731d56701 + en: 4f15fa7684abd4cc + zh: 9f2cde11303e9e02 /user-credentials/cordis-api/deepseek-account-events/deepseek-account-signed-out-emit: - en: 22aab907d3501c3d - zh: 444f2c942da8ee58 + en: 314e5b36f51b2c64 + zh: 0e1cb3fe5adf5ecd diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 341c554f3a..decd5b088d 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -55,10 +55,12 @@ interface CredentialInfo { ## Embedded Platform credentials -PlatformSession is a Host-only snapshot from getPlatformSession: origin names the configured Platform issuer and token contains its stored account credential. Signed-out accounts return null; a mismatched issuer fails. Native consumers own document invalidation when credentials change. This snapshot is excluded from account-controller RPC, AccountView, and AccountDetails. +PlatformSession is a Host-only snapshot from getPlatformSession: origin names the configured Platform issuer and token contains its stored account credential. userId repeats the stable account ID from the most recent successful getProfile, and is null until one succeeds or when that profile carries no ID. The snapshot reuses that ID without issuing a profile request of its own, so an unknown ID leaves userId null instead of delaying the caller; a profile read whose stable ID first becomes available or changes notifies watch subscribers, which lets identity consumers re-read the snapshot. Consumers key persistent browser storage by origin and userId and fall back to temporary storage for null. Signed-out accounts and credentials changed during the credential read return no snapshot; a mismatched issuer fails. Native consumers own document invalidation when credentials change. This snapshot is excluded from account-controller RPC, AccountView, and AccountDetails. AccountDetails.balance projects recharge wallets in value and promotional wallets in bonusWallets, with independent currency and decimal balance strings. Failed queries contain no wallet arrays. +Bonus notification queries return an AccountBonusBatch with the current Platform account id and eligible orders in server order. AccountBonusNotification retains the server message and expiry without projecting credentials. Acknowledgment carries the expected account id and order id; the Host refuses it after an account change. Both notification operations use the initiating UI language through x-client-locale, without a language query parameter. + @@ -274,24 +276,44 @@ abstract getState(): Promise /** * Query Platform profile independently of wallet balances. + * A ready result whose stable profile ID first becomes available or changes notifies watch + * consumers, so identity consumers re-read getPlatformSession; repeated IDs stay silent. + * @param client - identity of the requesting UI for this call. * @returns profile outcome, or null if signed out or the grant changed during the query. */ -abstract getProfile(): Promise +abstract getProfile(client: AccountClientMetadata): Promise /** * Query Platform recharge and bonus wallet balances independently of profile data. + * @param client - identity of the requesting UI for this call. * @returns balance outcome, or null if signed out or the grant changed during the query. */ -abstract getBalance(): Promise +abstract getBalance(client: AccountClientMetadata): Promise + +/** + * Query the granted bonuses Platform has not yet recorded as displayed. + * @param client - identity of the requesting UI for this call; its language selects the server-authored message. + * @returns bonuses with their account, or null if signed out or the grant changed during the query. + */ +abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise + +/** + * Record one displayed bonus as notified for the account it belongs to. + * @param accountId - account the notification was read for; a different current account is never acknowledged. + * @param orderId - granted bonus order the user saw. + * @param client - identity of the requesting UI for this call. + * @returns true once Platform records the acknowledgement; false if signed out or the account changed. + */ +abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise /** * Join an active attempt or start browser authorization. - * @param locale - active UI language for a new attempt; joining retains its original language. + * @param client - identity of the requesting UI; a new attempt captures it, and joining retains the original attempt's identity. * @param callbackOrigin - browser-accessible loopback HTTP origin, including any SSH local port. * @param loginSource - initiating UI, used to return from a failed exchange. * @returns the initial snapshot without waiting for browser approval. */ -abstract startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise +abstract startSignIn(client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise /** * Cancel only the named attempt; committing attempts settle before returning. @@ -302,9 +324,10 @@ abstract cancelSignIn(id: SignInAttemptId): Promise /** * Remove the local grant while retaining API keys; the provider revokes it in the background. + * @param client - identity of the requesting UI, captured for the background revocation retries. * @returns the signed-out state after local removal; remote failures never restore the grant. */ -abstract signOut(): Promise +abstract signOut(client: AccountClientMetadata): Promise /** * Subscribe to snapshots including a complete initial state. @@ -328,8 +351,9 @@ abstract resolveToken(url: string): Promise abstract rejectToken(token: string): Promise /** - * Read credentials for the configured Platform origin, bound to their issuing environment. - * @returns a Host-only snapshot, or null while signed out. + * Read credentials for the configured Platform origin, bound to their issuing environment, and + * pair them with the account ID from the last successful profile read; no profile request is made. + * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise ``` @@ -460,4 +484,6 @@ Local grant removal has completed. Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) -The account Service Definition exposes getState, getProfile, getBalance, startSignIn, cancelSignIn, signOut, watch, and Host-only resolveToken and getPlatformSession. The platform provider implements it with an AuthorizationFlow and a private GrantRecord. AccountView distinguishes stored presence from server validation; attempt IDs bind cancellation to one local flow. See [the account package](../../packages/credentials/deepseek-account/README.md). +The account Service Definition exposes getState, getProfile, getBalance, getUnnotifiedBonuses, ackBonusNotified, startSignIn, cancelSignIn, signOut, watch, and Host-only resolveToken and getPlatformSession. The platform provider implements it with an AuthorizationFlow and a private GrantRecord. AccountView distinguishes stored presence from server validation; attempt IDs bind cancellation to one local flow. See [the account package](../../packages/credentials/deepseek-account/README.md). + +`AccountClientMetadata` carries `version` from the caller’s `DSH_CLIENT_VERSION`, its active UI `locale`, and `timezoneOffsetSeconds` sampled when the operation begins. The offset is local time minus UTC in seconds: UTC+8 is `28800`. It contains no credentials. Login attempts retain their initiating metadata through exchange and cancellation; sign-out retains its metadata for revocation retries. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 33994d405f..b649f3c1ff 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -55,10 +55,12 @@ interface CredentialInfo { ## 内嵌 Platform 凭证 -PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指定所配置的 Platform 签发来源,token 包含其已存账号凭证。退登账号返回 null;签发来源不匹配时失败。原生使用方负责在凭证变化时使文档失效。账号控制器 RPC、AccountView 和 AccountDetails 均不包含此快照。 +PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指定所配置的 Platform 签发来源,token 包含其已存账号凭证。userId 复制最近一次成功 getProfile 得到的稳定账号 ID;尚无一次成功读取或资料不含 ID 时为 null。快照复用该 ID,不自行发起资料请求,因此 ID 未知时只会让 userId 为 null,而不会延迟调用方;资料读取首次取得稳定 ID 或该 ID 变化时会通知 watch 订阅者,供标识使用方重新读取快照。使用方以 origin 和 userId 作为持久化浏览器存储的键,为 null 时退回临时存储。账号已退登或读取凭证期间凭证变化时不返回快照;签发来源不匹配时失败。原生使用方负责在凭证变化时使文档失效。账号控制器 RPC、AccountView 和 AccountDetails 均不包含此快照。 AccountDetails.balance 将充值钱包投影为 value、赠送钱包投影为 bonusWallets,分别保留币种和十进制余额字符串。查询失败不包含钱包数组。 +赠金通知查询返回 AccountBonusBatch,包含当前 Platform 账号 id 和按服务端顺序排列的可通知订单。AccountBonusNotification 保留服务端消息与到期时间,不投影凭证。确认请求携带预期账号 id 和订单 id;账号变化后 Host 拒绝该请求。两项通知操作都通过 x-client-locale 传递发起界面的语言,不使用语言查询参数。 + @@ -274,24 +276,44 @@ abstract getState(): Promise /** * Query Platform profile independently of wallet balances. + * A ready result whose stable profile ID first becomes available or changes notifies watch + * consumers, so identity consumers re-read getPlatformSession; repeated IDs stay silent. + * @param client - identity of the requesting UI for this call. * @returns profile outcome, or null if signed out or the grant changed during the query. */ -abstract getProfile(): Promise +abstract getProfile(client: AccountClientMetadata): Promise /** * Query Platform recharge and bonus wallet balances independently of profile data. + * @param client - identity of the requesting UI for this call. * @returns balance outcome, or null if signed out or the grant changed during the query. */ -abstract getBalance(): Promise +abstract getBalance(client: AccountClientMetadata): Promise + +/** + * Query the granted bonuses Platform has not yet recorded as displayed. + * @param client - identity of the requesting UI for this call; its language selects the server-authored message. + * @returns bonuses with their account, or null if signed out or the grant changed during the query. + */ +abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise + +/** + * Record one displayed bonus as notified for the account it belongs to. + * @param accountId - account the notification was read for; a different current account is never acknowledged. + * @param orderId - granted bonus order the user saw. + * @param client - identity of the requesting UI for this call. + * @returns true once Platform records the acknowledgement; false if signed out or the account changed. + */ +abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise /** * Join an active attempt or start browser authorization. - * @param locale - active UI language for a new attempt; joining retains its original language. + * @param client - identity of the requesting UI; a new attempt captures it, and joining retains the original attempt's identity. * @param callbackOrigin - browser-accessible loopback HTTP origin, including any SSH local port. * @param loginSource - initiating UI, used to return from a failed exchange. * @returns the initial snapshot without waiting for browser approval. */ -abstract startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise +abstract startSignIn(client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise /** * Cancel only the named attempt; committing attempts settle before returning. @@ -302,9 +324,10 @@ abstract cancelSignIn(id: SignInAttemptId): Promise /** * Remove the local grant while retaining API keys; the provider revokes it in the background. + * @param client - identity of the requesting UI, captured for the background revocation retries. * @returns the signed-out state after local removal; remote failures never restore the grant. */ -abstract signOut(): Promise +abstract signOut(client: AccountClientMetadata): Promise /** * Subscribe to snapshots including a complete initial state. @@ -328,8 +351,9 @@ abstract resolveToken(url: string): Promise abstract rejectToken(token: string): Promise /** - * Read credentials for the configured Platform origin, bound to their issuing environment. - * @returns a Host-only snapshot, or null while signed out. + * Read credentials for the configured Platform origin, bound to their issuing environment, and + * pair them with the account ID from the last successful profile read; no profile request is made. + * @returns a Host-only snapshot, or null while signed out or when the credential changed during the read. */ abstract getPlatformSession(): Promise ``` @@ -460,4 +484,6 @@ Local grant removal has completed. Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts) -账号服务定义提供 getState、getProfile、getBalance、startSignIn、cancelSignIn、signOut、watch 及仅限 Host 的 resolveToken 和 getPlatformSession。平台提供者使用 AuthorizationFlow 和私有 GrantRecord 实现这些操作。AccountView 区分本地存在与服务器验证;尝试 ID 将取消绑定到单次本地流程。参见[账号包](../../packages/credentials/deepseek-account/README.zh.md)。 +账号服务定义提供 getState、getProfile、getBalance、getUnnotifiedBonuses、ackBonusNotified、startSignIn、cancelSignIn、signOut、watch 及仅限 Host 的 resolveToken 和 getPlatformSession。平台提供者使用 AuthorizationFlow 和私有 GrantRecord 实现这些操作。AccountView 区分本地存在与服务器验证;尝试 ID 将取消绑定到单次本地流程。参见[账号包](../../packages/credentials/deepseek-account/README.zh.md)。 + +`AccountClientMetadata` 携带调用方 `DSH_CLIENT_VERSION` 提供的 `version`、当前界面 `locale`,以及操作发起时采样的 `timezoneOffsetSeconds`。偏移为本地时间减 UTC 的秒数:UTC+8 对应 `28800`。其中不含凭证。登录申请在兑换及取消期间保留发起时的元数据;退出操作为撤销重试保留其元数据。 diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index f64f41d29d..7368d1d4cf 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -6,8 +6,8 @@ en: 989057ec9786a505 zh: 03e20367da473086 /llm-streaming/content-blocks-and-messages: - en: b31628f3bdbf62cb - zh: 0e6eb420ae5c5a3f + en: 365ecc228f756098 + zh: 396e68661a789854 /llm-streaming/streamchunk-the-raw-protocol: en: a18fe401763efde4 zh: ef59a76d9c694654 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 6ff93698cb..aeebd3011d 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -17,9 +17,8 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv /** * Merge-extensible content blocks keyed by `type`. New core blocks must land - * with adapter, UI, and compaction support. Developer tool-change blocks are - * reserved for Session V4 persistence; providers and UI reject them until - * their producers and consumers are implemented together. + * with adapter, UI, and compaction support. Tool-change blocks belong to + * developer messages; `projectToolUpdates` selects what each route receives. */ interface ContentBlockMap { 'text': TextBlock @@ -32,7 +31,7 @@ interface ContentBlockMap { } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), and `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`). Tool results are first-class `ToolResultMessage` values with `toolCallId`, result `content`, and optional `isError`; they are not content blocks. `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Developer tool-change blocks are a persistence-only exception described in the [decision](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md). +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), and `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`). Tool results are first-class `ToolResultMessage` values with `toolCallId`, result `content`, and optional `isError`; they are not content blocks. `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Developer tool-change blocks are projected according to the resolved route capability. Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. @@ -71,7 +70,7 @@ interface AssistantProviderMetadata { type Message = MessageRoleMap[keyof MessageRoleMap] ``` -`DeveloperMessage` records incremental agent session changes in conversation order with the `developer` role. `ToolAdditionBlock.toolName` activates the definition selected by the containing Session event's historical header reference; `ToolRemovalBlock.toolName` removes the active definition. Both blocks are rejected in other message roles. `deferLoading` independently requests deferred definition loading without requiring an addition record. See [Session](../../packages/core/session/README.md) for header binding and the [LLM package](../../packages/llm/llm/README.md#known-limitations-and-deferred-work) for provider support limits. +`DeveloperMessage` records incremental agent session changes in conversation order with the `developer` role. `ToolAdditionBlock.toolName` activates the definition selected by the containing Session event's historical header reference; `ToolRemovalBlock.toolName` removes the active definition. Both blocks are rejected in other message roles. `deferLoading` independently requests deferred definition loading without requiring an addition record. See [Session](../../packages/core/session/README.md) for header binding and the [LLM package](../../packages/llm/llm/README.md#known-limitations-and-deferred-work) for provider support limits. `ToolUpdate` is `in-history` or `addition-only` on resolved and prepared model metadata. `GenerateOptions.toolHistory` carries `ToolHistory`: initial `tools` and ordered `updates`, each binding a developer `messageId` to its historically resolved `additions`. The runtime projects this state into provider declarations; it does not change the active tool list in logged headers. Where a message came from is itself a merge-extensible sum type: @@ -573,6 +572,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { reasoning?: LlmModelReasoningInfo /** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */ systemPromptUpdate?: SystemPromptUpdate + /** Declared mid-conversation tool declaration handling; absent means every request declares the complete tool list. */ + toolUpdate?: ToolUpdate } ``` @@ -615,6 +616,8 @@ interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] + /** Session-folded tool history used for route projection; omission sends complete declarations without tool updates. */ + toolHistory?: ToolHistory temperature?: number maxTokens?: number /** @@ -792,6 +795,8 @@ interface PreparedLlmCall { readonly inputModalities?: readonly ModelModality[] /** Exact model system prompt update mode captured with the adapter dispatch generation. */ readonly systemPromptUpdate?: SystemPromptUpdate + /** Exact model tool update mode captured with the adapter dispatch generation. */ + readonly toolUpdate?: ToolUpdate /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 400fcd5a96..c7e5473284 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -17,9 +17,8 @@ ```ts type-equiv /** * Merge-extensible content blocks keyed by `type`. New core blocks must land - * with adapter, UI, and compaction support. Developer tool-change blocks are - * reserved for Session V4 persistence; providers and UI reject them until - * their producers and consumers are implemented together. + * with adapter, UI, and compaction support. Tool-change blocks belong to + * developer messages; `projectToolUpdates` selects what each route receives. */ interface ContentBlockMap { 'text': TextBlock @@ -32,7 +31,7 @@ interface ContentBlockMap { } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)和 `ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`)。工具结果是一等 `ToolResultMessage`,含有 `toolCallId`、结果 `content` 与可选的 `isError`;它不是内容块。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 Developer 工具变更块属于仅持久化的例外,见[决策](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md)。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)和 `ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`)。工具结果是一等 `ToolResultMessage`,含有 `toolCallId`、结果 `content` 与可选的 `isError`;它不是内容块。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 Developer 工具变更块按已解析路由的能力投影。 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 @@ -71,7 +70,7 @@ interface AssistantProviderMetadata { type Message = MessageRoleMap[keyof MessageRoleMap] ``` -`DeveloperMessage` 以 `developer` 角色按对话顺序记录增量智能体 Session 变更。`ToolAdditionBlock.toolName` 激活由所在 Session 事件的历史请求头引用所选定的定义;`ToolRemovalBlock.toolName` 移除当前生效的定义。其他消息角色拒绝这两种内容块。`deferLoading` 独立控制工具定义的加载请求,不要求存在添加记录。请求头绑定见 [Session](../../packages/core/session/README.zh.md),提供方支持限制见 [LLM 包](../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 +`DeveloperMessage` 以 `developer` 角色按对话顺序记录增量智能体 Session 变更。`ToolAdditionBlock.toolName` 激活由所在 Session 事件的历史请求头引用所选定的定义;`ToolRemovalBlock.toolName` 移除当前生效的定义。其他消息角色拒绝这两种内容块。`deferLoading` 独立控制工具定义的加载请求,不要求存在添加记录。请求头绑定见 [Session](../../packages/core/session/README.zh.md),提供方支持限制见 [LLM 包](../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 已解析和已准备模型元数据中的 `ToolUpdate` 为 `in-history` 或 `addition-only`。`GenerateOptions.toolHistory` 携带 `ToolHistory`:初始 `tools` 和有序 `updates`,每项把 developer `messageId` 绑定到已解析历史定义的 `additions`。运行时将此状态投影为提供方声明,不修改已记录请求头中的有效工具列表。 消息来源本身也是一个可合并扩展的和类型: @@ -579,6 +578,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { reasoning?: LlmModelReasoningInfo /** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */ systemPromptUpdate?: SystemPromptUpdate + /** Declared mid-conversation tool declaration handling; absent means every request declares the complete tool list. */ + toolUpdate?: ToolUpdate } ``` @@ -621,6 +622,8 @@ interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] + /** Session-folded tool history used for route projection; omission sends complete declarations without tool updates. */ + toolHistory?: ToolHistory temperature?: number maxTokens?: number /** @@ -798,6 +801,8 @@ interface PreparedLlmCall { readonly inputModalities?: readonly ModelModality[] /** Exact model system prompt update mode captured with the adapter dispatch generation. */ readonly systemPromptUpdate?: SystemPromptUpdate + /** Exact model tool update mode captured with the adapter dispatch generation. */ + readonly toolUpdate?: ToolUpdate /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index f73b0b4513..5fae9f54a6 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -2,27 +2,39 @@ # 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 docs/subsystems/schedule.md -/session-local-schedule: - en: d9c45890973c8312 - zh: dc24bdb262b521c8 -/session-local-schedule/durable-records: - en: 65b12288096cbacb - zh: 50a39aaf9fd16b76 -/session-local-schedule/absolute-time-input: - en: 831be0104c9a76ab - zh: f95f1791a918e272 -/session-local-schedule/fixed-rate-input-and-catch-up: - en: 071aa73c5c4ca8cd - zh: 34f4ea9a0d0e3f90 -/session-local-schedule/durable-changes-and-replay: - en: cc9573bae1cabf26 - zh: 9d32f9efcbb7b3c1 -/session-local-schedule/active-views-and-management: - en: 057d439bc9263d02 - zh: 9a675fd7fecf4e45 -/session-local-schedule/read-only-web-catalog: - en: 3bfdb5370bd16560 - zh: 5176c01962819f0f -/session-local-schedule/live-delivery: - en: 95a78df72f98f9b8 - zh: ffaa6f163cdf9034 +/host-wide-schedule: + en: 71c4686e65a7c7f9 + zh: dfba124c28db2f63 +/host-wide-schedule/durable-records: + en: 41cd197b5c74bfa3 + zh: 18ed11259036d726 +/host-wide-schedule/absolute-time-input: + en: 9f814e5a1c9f93c1 + zh: 5f04bd46f43ebbf9 +/host-wide-schedule/daily-wall-clock-input: + en: 2d249edc3dfccb8a + zh: 33fecda132b03b84 +/host-wide-schedule/fixed-rate-input-and-catch-up: + en: 9057ff8733ca051a + zh: c4bf9d86363808cd +/host-wide-schedule/cron-wall-clock-input: + en: 1ba9ae84f05d8919 + zh: 5b4abfdcddbbcae0 +/host-wide-schedule/historical-session-changes: + en: 9cd7f65e0699aa91 + zh: 42f2a6fda33d2ff0 +/host-wide-schedule/active-views-and-management: + en: 0656173de50ee132 + zh: 54176540371d0f28 +/host-wide-schedule/web-catalog: + en: 3526df6b736ae81c + zh: 677a09e8ac367e56 +/host-wide-schedule/timing-edits: + en: ce5d2095a3ad2e82 + zh: 167b3715f8eaffc1 +/host-wide-schedule/saved-delivery-records: + en: 077aa6573d9ede1a + zh: 0acea592a6e3630a +/host-wide-schedule/host-delivery: + en: b6d522d24b9b97ba + zh: 84df8ea09bedf51c diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index b92b81c6f2..6dca5eabcc 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -1,20 +1,22 @@ -# Session-local Schedule +# Host-wide Schedule English | [中文](schedule.zh.md) -Schedule owns durable reminders that return to the original live Session as ordinary later conversation turns. The [durable Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns persistence, lifecycle, and active-state presentation, and the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation. This page records the durable and model-facing shapes from [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition, tool behavior, and the exact reminder framing. +Schedule stores reminders independently of Session activation and delivers them to their original Session. This page records the durable and model-facing types from [`types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition and reminder framing. ## Durable records -`ScheduleId` is a [branded id](core.md#branded-ids), unique and never reused within one Session. Version 1 supports a positive safe-integer `after_seconds` delay, an explicit absolute `at` target, or a safe-integer `every_seconds` interval of at least five minutes. Creation canonicalizes every first target into a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record retains its submitted delay, an `at` record stores only the resulting instant, and an `every` record retains its fixed interval and next target. +`ScheduleId` is a globally unique [branded id](core.md#branded-ids). The version-1 `schedule` storage domain owns a `tasks` table whose entries bind a `ScheduleRecord` to its original `sessionId`, with stored `status: 'active' | 'inactive'` and optional `lastDelivery`. Records lacking status normalize to `active` without scanning or recreating historical Sessions. Creation canonicalizes the target into RFC 3339 UTC `scheduledAt`; `after` retains the submitted delay, `every` retains its fixed interval, `daily` and `weekly` retain normalized local `time` and their explicit `timeZone`, and `cron` retains its canonical five-field `expression` and explicit `timeZone`. Daily, weekly, and cron decoding preserve the committed instant and stored zone spelling. The stored task record declares a required `title`, the short name the task surfaces show; creation requires it and never derives it from the instruction. No stored record may omit its title: a record whose `title` is missing, blank after trimming, untrimmed, or longer than 120 characters fails durable decoding with `ScheduleLogError`, and the schedule domain declares no backup-and-skip policy, so one such stored task rejects the whole domain open instead of being dropped. Only explicit deletion removes a task; previously physically deleted tasks are not restored or fabricated. ```ts type-equiv /** Durable one-shot reminder created from a positive delay. */ interface AfterScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for a delayed one-shot reminder. */ readonly kind: 'after' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Positive safe-integer delay accepted at creation. */ @@ -27,10 +29,12 @@ interface AfterScheduleRecord { ```ts type-equiv /** Durable one-shot reminder created from an absolute instant. */ interface AtScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for an absolute one-shot reminder. */ readonly kind: 'at' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Four-digit-year RFC 3339 UTC target. */ @@ -39,29 +43,56 @@ interface AtScheduleRecord { ``` ```ts type-equiv -/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */ +/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */ interface EveryScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for a fixed-rate recurring reminder. */ readonly kind: 'every' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Fixed safe-integer interval, never below five minutes. */ readonly everySeconds: number - /** Earliest anchor-aligned occurrence not yet dispatched. */ + /** Next anchor-aligned occurrence while active, or final occurrence when inactive. */ readonly scheduledAt: string } ``` ```ts type-equiv -/** One-shot record variants that terminate on an id-only dispatch. */ +/** Durable daily wall-clock reminder; gaps skip a date and overlaps use the earlier instant. */ +interface DailyScheduleRecord { + /** Globally unique task identity. */ + readonly id: ScheduleId + /** Rule discriminator for a daily wall-clock reminder. */ + readonly kind: 'daily' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string + /** Trimmed reminder content supplied at creation. */ + readonly prompt: string + /** Local time normalized to HH:mm:ss.SSS. */ + readonly time: string + /** Explicit IANA zone; equivalent timing edits retain the stored spelling. */ + readonly timeZone: string + /** Committed next UTC instant while active, or final occurrence when inactive. */ + readonly scheduledAt: string +} +``` + +```ts type-equiv +/** One-shot task variants. */ type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord ``` ```ts type-equiv -/** The v1 durable reminder record union. */ -type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord +/** Recurring Host task variants. */ +type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord +``` + +```ts type-equiv +/** Reminder rule and target, stored with its original Session binding. */ +type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord ``` ## Absolute-time input @@ -69,7 +100,7 @@ type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord The `at` selector is either a strict offset-bearing RFC 3339 string or an exact local-calendar object. The local form keeps its interpretation explicit at the tool boundary: ```ts type-equiv -/** Structured local-calendar input accepted by `schedule_create`. */ +/** Structured local-calendar input accepted by creation and timing edits. */ interface LocalAtInput { /** Four-digit ISO calendar date. */ readonly date: string @@ -81,7 +112,7 @@ interface LocalAtInput { ``` ```ts type-equiv -/** Absolute selector accepted by `schedule_create`. */ +/** Absolute selector accepted by creation and timing edits. */ type AtInput = string | LocalAtInput ``` @@ -89,24 +120,80 @@ The official Web overlay samples the browser's IANA zone for every prompt. Time- 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. + +## Daily wall-clock input + +`daily` is one of the mutually exclusive creation selectors, alongside `after_seconds`, `at`, `every_seconds`, the weekly selector, and the cron selector. For example, `daily: { time: '23:00:00', time_zone: 'Asia/Shanghai' }` selects 23:00 in that zone on each eligible local date. The exact object has no date, offset, or interval field: + +```ts type-equiv +/** Daily local-time selector accepted by creation and timing edits. */ +interface DailyInput { + /** Local HH:mm:ss time with optional one-to-three fractional digits. */ + readonly time: string + /** Explicit UTC or IANA Area/Location zone. */ + readonly time_zone: string +} +``` + +Creation requires `HH:mm:ss` with optional one-to-three fractional digits, rejects leap seconds and `24:00`, canonicalizes the explicit IANA zone, and stores `time` as `HH:mm:ss.SSS`. The first `scheduledAt` is strictly after creation time. A nonexistent local time or entire date is skipped; an overlap selects the earlier instant once per local date. If that earlier instant has passed, the later duplicate is not eligible. Only UTC target years 0001–9999 are constrained; local dates at the UTC range edges may fall in year 0 or 10000. + +A saved next target is a committed UTC instant. The Host decoder and restart preserve that instant without resolving it against time-zone rules again, and valid stored zone aliases remain readable when canonical names change. Subsequent targets use the Host's current IANA data. The next target must be both after the delivery decision and on a local date later than the selected occurrence's date. Creation fails with `time_out_of_range` when no future UTC target is representable. + ## Fixed-rate input and catch-up -`every_seconds` is a per-record interval of at least 300 seconds, anchored to creation time. It is fixed-rate recurrence only: the protocol has no calendar or Cron expression, recurrence time zone, shared cooldown, or cross-record admission gate. +`every_seconds` is a safe-integer interval of at least 300 seconds, aligned to creation time. It measures elapsed time, not daily wall-clock time: `every_seconds: 86400` cannot substitute for `daily` across offset changes. The protocol provides no shared cooldown or cross-record admission rule; the cron selector described below is wall-clock timing, not a fixed-rate interval. -When a Session was cold or busy across several targets, one Every record contributes only its latest due occurrence. The dispatch advances it directly to the first creation-anchor-aligned target after the dispatch decision time, without enumerating, persisting, or replaying missed intervals. If that next target cannot fit in a four-digit UTC year, the final dispatch terminates the record. +When the Host starts with an overdue Every, Daily, Weekly, or Cron record, it sends only the latest due occurrence. Every advances to the first target aligned to its current interval anchor after the delivery decision; Daily, Weekly, and Cron follow the local-date rules above and below. Missed occurrences do not accumulate. If no next UTC target is representable, delivery retains the record as inactive with its latest receipt. -When multiple distinct Every records are overdue and no one-shot is due, each contributes one occurrence to the same follow-up batch in target and creation order. Every record keeps independent state, while all dispatches in that admitted batch use the same decision time. Batching bounds model turns; the five-minute minimum bounds each record's timer frequency. +One-shots produce individual follow-ups. Recurring records due in the same scan for the same Session share one follow-up rendered by `renderRecurringReminderBatchFraming`, with one latest occurrence per record. The five-minute minimum applies only to fixed-rate intervals; delivery does not wait for the target Agent to become idle. -## Durable changes and replay + +## Cron wall-clock input -The version-1 `schedule/change` Session event is the only durable Schedule authority. Create stores the complete record, and delete is a terminal id-only transition. A one-shot dispatch is also terminal and id-only. An Every dispatch carries the wall-clock decision time used to select its latest due occurrence and normally advances the active record instead of terminating it. Dispatch means the follow-up was synchronously queued, not that a model answer succeeded or the user read it. +`cron` is one of the mutually exclusive creation selectors. Its exact object carries only the strict five-field Vixie expression `minute hour day-of-month month day-of-week` and an explicit IANA zone, for example `cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'Asia/Shanghai' }`. The field bounds are: + +| Field | Accepted range | Notes | +|---|---|---| +| minute | 0-59 | | +| hour | 0-23 | | +| day-of-month | 1-31 | | +| month | 1-12 | | +| day-of-week | 0-7 | Both `0` and `7` mean Sunday. | + +Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. Everything else is rejected with `invalid_rule` and a message naming the offending field: `L`, `W`, and `#` operators, `JAN`/`MON` names, `@daily`-style macros, six-field expressions with a seconds field, out-of-range values, inverted ranges, zero steps, and empty fields. Because the dialect has five fields, the smallest interval is one minute; sub-minute scheduling stays unsupported. + +Creation canonicalizes the expression before storing it. Canonicalization expands each field into its matched value set and re-encodes that set deterministically: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step. Preserving each field's star flag is what keeps the day-of-month/day-of-week rule below Vixie-faithful across the stored expression. The durable decoder rejects a stored expression that is not canonical. + +The first target is the first strictly future instant whose local date and time in the zone match. It reuses the Daily and Weekly local-time resolution: a non-existent local time is skipped, and an ambiguous one takes the earlier occurrence once for that date. The committed UTC `scheduledAt` is never recomputed on restart. + +Day-of-month and day-of-week follow Vixie semantics. A field is a star when its text starts with `*`, independently of the values it matches, so `*/1` and `*/2` are stars while `1-31` and `0-7` are restricted. When either field is a star, a local date matches only when both fields match; when neither is a star, it matches when either field matches. + +Cron decoding preserves the committed instant and the stored zone spelling, and rejects a non-canonical stored expression. Cron belongs only to the current Host `ScheduleRecord`; the frozen historical Session decoder does not admit it. + +## Historical Session changes + +Version-1 `schedule/change` events remain decodable as historical Session data. Their create, fold, and invariant types use `LegacyScheduleRecord`, which admits only After, At, and Every; Daily, Weekly, and Cron belong only to the current Host `ScheduleRecord`. The Host record decoder is separate from the frozen historical decoder. A create record written before titles existed carries no `title`, so the historical decoder admits that absent member while the Host decoder still requires it. Historical events do not populate the storage domain or schedule delivery. Existing reminders in these events require explicit recreation through `schedule_create`; no implicit Session migration or conversion of old `at` records occurs. + +```ts type-equiv +/** + * Frozen Session event and fold vocabulary; daily rules belong only to Host storage. + * + * A version-1 event written before titles existed persists no `title` member, so + * `after`, `at`, and `every` decode without one and stay readable. The Host task + * record requires the member and never persists a record without it. + */ +type LegacyScheduleRecord = + | LegacyAfterScheduleRecord + | LegacyAtScheduleRecord + | LegacyEveryScheduleRecord +``` ```ts type-equiv /** Creates one durable reminder record. */ interface ScheduleCreateChange { readonly version: 1 readonly operation: 'create' - readonly schedule: ScheduleRecord + readonly schedule: LegacyScheduleRecord } ``` @@ -149,11 +236,11 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after its exact `inheritedEventCount`, so it retains history without adopting the parent Session's active reminders. Projection initialization receives that cut beside the immutable header, uses the shared transition, and persists both the cut, active records, and used-id history so cached restore preserves strict replay. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). +The historical decoder and fold reject unknown versions, extra fields, reused ids, and invalid transitions. The event remains indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). Session history is not the authority for active tasks. ## Active views and management -Tool values combine the durable record with delivery state derived from the current wall clock. `session-local` means the original Session must be live: no external notification channel or cold-session scheduler exists. +Tool values combine the stored record with timing derived from the current wall clock. The Host restores the original Session when delivery requires it; listing and deleting tasks do not activate that Session. ```ts type-equiv /** Current delivery timing derived from the durable record and wall clock. */ @@ -161,8 +248,8 @@ type ScheduleState = 'scheduled' | 'overdue' ``` ```ts type-equiv -/** Fixed v1 delivery boundary: the original session must be live. */ -type ScheduleDeliveryMode = 'session-local' +/** Host-driven delivery resumes the original Session when needed. */ +type ScheduleDeliveryMode = 'host' ``` ```ts type-equiv @@ -175,18 +262,282 @@ type ScheduleView = ScheduleRecord & { } ``` -The generated [tool catalog](../tool-catalog.md#deepseek-aidsh-schedule) owns the argument and result schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Management calls serialize with due work in one Agent-scoped queue. Every read or decision first waits for the shared Session persistence barrier; create and an actual delete wait again after appending. A barrier failure reports `persistence_uncertain` instead of guessing whether an eager write committed. The other stable error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `not_future`, `time_out_of_range`, `frequency_too_high`, `corrupt_schedule_log`, and `internal_error`. +The [tool catalog](../tool-catalog.md#deepseek-aidsh-schedule) owns schemas for `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update`. Create, delete, and update acknowledge the storage-domain write. A Host-wide queue serializes these mutations against due delivery; deleting a task does not remove an already queued message. Model tools address the current Session, while the shared Host create, list, update, and delete methods accept an explicit Session binding. Creation requires a `title` that must be non-empty after trimming and at most 120 characters; a missing, blank, or over-long title is rejected with `invalid_prompt`, and creation never derives one from the instruction. Create and list views carry the stored `title` alongside the instruction, and a stored record whose `title` is missing or invalid is rejected at decode. -## Read-only Web catalog +```ts type-equiv +/** Reminder creation selector, shared by the model consumer and Host service. */ +interface ScheduleCreateRequest { + /** Non-empty reminder text. */ + prompt: string + /** Required task name of at most 120 characters, non-empty after trimming; names the card, detail heading, and task lists. */ + title: string + /** Relative one-shot delay in seconds. */ + after_seconds?: number + /** Absolute one-shot target. */ + at?: AtInput + /** Fixed recurrence interval in seconds. */ + every_seconds?: number + /** Daily wall-clock time in an explicit IANA zone. */ + daily?: DailyInput + /** Weekly wall-clock time and explicit ISO weekday set in an IANA zone. */ + weekly?: WeeklyInput + /** Five-field cron expression evaluated in an explicit IANA zone. */ + cron?: CronInput +} +``` -When the optional Session projection registry is present, Schedule registers the client-visible `schedule` key whose value is the complete active `ScheduleRecord[]`. Live, cache, history, and detached reads use the same header-aware strict fold; malformed authoritative input fails the existing read path instead of publishing a partial value. +```ts type-equiv +/** Session-scoped task list, without Agent activation. */ +interface ScheduleListRequest { + /** Original Session binding. */ + sessionId: SessionId +} +``` -The shipped Web bundle keeps `ui-schedule` disabled by default, while the explicit Schedule overlay enables it together with the Host capability. [`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.md) owns the header interaction, [`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.md) owns list-row presentation, and the durable Schedule Agent Note owns their shared active-state boundary. The shared value represents current active state, never delivery history or a receipt; due reminders still appear through the ordinary Assistant output described below. +```ts type-equiv +/** Delete request identifying a task within its original Session binding. */ +interface ScheduleDeleteRequest extends ScheduleListRequest { + /** Task to remove. */ + id: ScheduleId +} +``` -## Live delivery +```ts type-equiv +/** Successful `schedule_delete` value, including the non-mutating not-found result. */ +type ScheduleDeleteResult = + | { readonly id: ScheduleId; readonly deleted: true } + | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } +``` -The process-local owner derives its earliest timer from the durable fold and rereads the wall clock after every bounded wait. Cold Sessions do no work; reopening one reconstructs timers and makes past targets overdue. Due one-shots take priority and enter one later turn at a time. When no one-shot is due, all overdue Every records form the single batch described above. +## Web catalog -Due work waits for the Agent to become fully idle and claims the maintenance phase before it refolds state, samples the decision, queues one `followup()`, and appends the corresponding dispatch changes. It never calls `steer()` and never interrupts a current turn. +The Remote `schedule.catalog()` method returns active and inactive Host tasks with their original Session bindings, ordered by `scheduledAt` ascending and then lexicographically by `id`. It reads only the Schedule domain, without activating Sessions or reading their history. Browser consumers receive this browser-safe type: -The admitted one-shot or fixed-rate batch starts one normal later turn and appears only through the ordinary conversation transcript; Schedule has no independent durable Web receipt. The read-only active catalog above never represents delivery success. If framing or synchronous queue admission fails, no dispatch is recorded and the reminder stays active. The narrow crash interval after admission but before durable dispatch can repeat reminder content after recovery, so the boundary is best-effort at-least-once rather than exactly-once delivery. +```ts type-equiv +/** Durable Session inbox delivery acknowledgment, not model execution completion. */ +interface ScheduleDeliveryReceipt { + /** Canonical UTC target of the delivered occurrence. */ + readonly scheduledAt: string + /** Canonical UTC time sampled after Session persistence acknowledged delivery. */ + readonly deliveredAt: string + /** Identity of the delivered message, shared by tasks in one recurring batch. */ + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** Browser-safe retained reminder with its original Session binding. */ +type ScheduleCatalogEntry = ScheduleRecord & { + /** Session receiving this reminder when it becomes due. */ + readonly sessionId: SessionId + /** Inactive reminders remain visible but never schedule another delivery. */ + readonly status: 'active' | 'inactive' + /** Most recent durably acknowledged inbox delivery, when available. */ + readonly lastDelivery?: ScheduleDeliveryReceipt +} +``` + +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 keeps `ui-schedule` disabled by default; the Schedule overlay enables it 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 + +`schedule.update({ sessionId, id, expected, change })` edits an active task without activating its Session. `expected` is the complete observed `ScheduleRecord`, not a catalog entry with extra metadata. The Host compares it to the current record inside the same FIFO used by dispatch and deletion. Normal delivery can advance the target during editing; that stale snapshot returns `schedule_conflict` instead of overwriting the current rule. Missing or mismatched bindings return `schedule_not_found`, and inactive tasks return `schedule_ended`. + +```ts type-equiv +/** Timing-only edit; it may select a different recurrence kind than the stored record, and one-shot targets use an absolute `at`. */ +type ScheduleTimingChange = + | { readonly kind: 'at'; readonly at: AtInput } + | { readonly kind: 'every'; readonly every_seconds: number } + | { readonly kind: 'daily'; readonly daily: DailyInput } + | { readonly kind: 'weekly'; readonly weekly: WeeklyInput } + | { readonly kind: 'cron'; readonly cron: CronInput } +``` + +```ts type-equiv +/** Compare-and-update request within the original Session binding. */ +interface ScheduleUpdateRequest extends ScheduleDeleteRequest, ScheduleUpdateContent { + /** Complete record observed when editing began, including the committed target. */ + readonly expected: ScheduleRecord + /** New timing; its kind may differ from the stored record's kind, and an omitted value keeps the committed target. */ + readonly change?: ScheduleTimingChange +} +``` + +```ts type-equiv +/** Successful current record, non-mutating lookup/conflict failure, or invalid name/instruction/timing. */ +type ScheduleUpdateResult = + | { readonly id: ScheduleId; readonly updated: boolean; readonly record: ScheduleRecord } + | ScheduleUpdateMiss + | ScheduleToolError +``` + +A changed Daily rule computes its first strictly future target using the saved local time and zone semantics. A changed Cron rule canonicalizes its expression and computes its first strictly future target under the same local-date, gap, and earlier-overlap rules. A changed Every interval starts a new anchor at accepted save time plus that interval. An After or At task can receive a different absolute target as an At record with the same id. These operations preserve the stored `title`, the prompt, original Session binding, lifecycle status, and all saved receipts. A change may also select a different recurrence kind, which recomputes the target from the new rule with the same id, title, prompt, Session binding, status, and saved receipts. An equivalent normalized rule returns `updated: false` with its unchanged record; it does not rewrite storage, reset the target, or emit a change event. A differently spelled but equivalent Cron expression is such a no-op, because the comparison uses the canonical expression and the canonical zone. + +Input validation uses existing timing errors. A changed absolute target must still be future when its FIFO slot runs. The successful task put precedes the change notification and timer recomputation. Storage and lifecycle failures reject; cancellation is checked after queue and initialization waits but cannot roll back an in-flight write. One-shot records retain a UTC instant rather than the editing form's IANA zone. Timing edits append no historical Session event: the model reaches the same in-place update through `schedule_update`, which compares the record it read with the stored one instead of deleting and recreating the task. + +## Saved delivery records + +`schedule.history({ sessionId, id, limit })` reads saved receipts without activating a Session. The explicit limit is a safe integer from 1 through 100. Pages follow reverse append order, not wall-clock sorting; the optional message-id cursor excludes the oldest record from the preceding page. New arrivals do not shift that cursor. A missing task or wrong Session binding returns `schedule_not_found`; an unknown cursor returns `delivery_cursor_not_found`, not an empty successful page. + +```ts type-equiv +/** Saved inbox delivery with its immutable sent prompt when recorded by this Host. */ +interface ScheduleDeliveryRecord extends ScheduleDeliveryReceipt { + /** Prompt sent for this occurrence; unavailable for legacy receipts. */ + readonly prompt?: string +} +``` + +```ts type-equiv +/** Explicitly bounded saved-delivery query within one Session binding. */ +interface ScheduleDeliveryHistoryRequest extends ScheduleDeleteRequest { + /** Required safe-integer page size from 1 through 100. */ + limit: number + /** Message identity of the oldest entry in the previous page; excluded from this page. */ + before?: MessageId +} +``` + +```ts type-equiv +/** Configured limits applied when appending one task's delivery receipt. */ +interface DeliveryRetentionBounds { + /** Retained window in days, measured back from the acknowledgment being appended. */ + readonly days: number + /** Maximum retained records per task; the newest survive. */ + readonly records: number +} +``` + +```ts type-equiv +/** Newest-first saved deliveries, or a non-mutating task/cursor lookup failure. */ +type ScheduleDeliveryHistoryResult = + | { + readonly id: ScheduleId + readonly records: ScheduleDeliveryRecord[] + /** Whether earlier delivery records may be unavailable; missing receipts are never reconstructed. */ + readonly earlierRecordsUnavailable: boolean + /** True only after an append removed saved records; absent legacy evidence is false. */ + readonly earlierRecordsPruned: boolean + /** Current Host retention configuration, also used by the delivery writer. */ + readonly retention: DeliveryRetentionBounds + /** Oldest returned message identity, present only when older saved records remain. */ + readonly nextBefore?: MessageId + } + | { readonly id: ScheduleId; readonly code: 'schedule_not_found' | 'delivery_cursor_not_found' } +``` + +New tasks initialize an empty history. Older task rows without history expose only their existing latest receipt, mark earlier records unavailable, and are not rewritten by reads. Future acknowledgments preserve that receipt and append actual new receipts with instruction snapshots. Lost history and missing legacy instruction snapshots are never reconstructed. The same task-row write commits history, latest receipt, status, and next target; explicit task deletion stops future delivery, removes the task row together with its saved receipts, and takes it out of `list` and `catalog`; conversation messages are unaffected. Saved delivery records are pruned on append to the configured `deliveryHistoryDays` window, measured back from each receipt's `deliveredAt`, and to the `deliveryHistoryRecords` cap; the appended latest receipt always survives, and a pruned window marks earlier records unavailable. Pagination bounds returned record count rather than storage size or instruction bytes. + +## Host delivery + +The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session through the Session controller, including cold Sessions. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch. + +Plugin-sourced `followup()` synchronously appends the message to the Session inbox. After a successful Session flush, one task-record put appends a saved delivery with its instruction snapshot and stores the latest `ScheduleDeliveryReceipt` together with the one-shot's `inactive` status or the recurring task's next target and status. Recurring tasks in the same batch share the receipt's `messageId`; each receipt retains that task's delivered occurrence time. Delivery never steers or cancels the current turn and does not wait for model completion. + +A failure to resolve the Session, enqueue the message, or confirm persistence leaves the task stored and reports a Host warning. A later management change or Host restart retries it. Session persistence and the task put are not atomic across stores. A crash after inbox persistence but before the task put can repeat delivery; the receipt does not close this gap. Schedule provides no model-result acknowledgement, execution cancellation, or external notification channel. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.schedule` — `ScheduleService` + +Shared management service; reads, deletion, and timing edits never activate a Session. + +`sessionPersistence` is a load-order requirement rather than a directly called service: a delivery commits only when `ctx.sessions.flush()` reports that a `session/flush` listener participated, and the persistence backend providing this service is the plugin that registers that listener. + +```ts cordis-catalog +/** + * Create a reminder bound to the caller-selected Session without activating it. + * + * The request must supply a title; a missing, blank-after-trim, or over-long + * title rejects with `invalid_prompt` instead of deriving one from the prompt. + * The record is built from the clock reading taken before the request joins the + * serialized queue, so a create that waits behind a longer operation keeps its + * request-time anchor and may already be due when the queue reaches it. + * @param sessionId - Original Session receiving the reminder. + * @param request - Validated tool selector, required title, and reminder content. + * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits. + * @returns The durably stored schedule. Cancellation does not roll back an in-flight write. + */ +async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise + +/** + * Read the selected Session's active tasks without resuming its Agent. + * @param request - Session whose task list is requested. + * @returns Persisted reminders in storage order. + */ +@Remote('list') async list(request: ScheduleListRequest): Promise + +/** + * Read all active and inactive Host reminders with their original Session bindings. + * A deleted reminder has no row, so it is absent here. + * Does not activate Sessions or read Session history. + * @returns Reminders ordered by scheduledAt ascending, then lexicographically by id. + */ +@Remote('catalog') async catalog(): Promise + +/** + * Read saved inbox deliveries without activating or reading the original Session. + * The task's own row supplies its binding, so its records stay readable through this lookup. + * @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor. + * @returns Newest-first deliveries in append order, or a task/cursor lookup failure. + * @throws ScheduleInputError when limit is not a safe integer from 1 through 100. + */ +@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise + +/** + * Delete one task belonging to the selected Session, leaving queued messages intact. + * + * The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its + * saved delivery records go with it. + * @param request - Session and exact task identity. + * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits. + * @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write. + */ +@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise + +/** + * Update the name, instruction, and timing of an active task within the original Session + * binding without activating the Session or changing saved deliveries. + * + * Each supplied field replaces its stored value; an omitted field keeps it. A name or + * instruction change alone does not reset the committed target. + * @param request - Task binding, complete observed record, and any combination of timing, name, and instruction. + * @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins. + * @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict result. + * Storage and lifecycle failures reject; cancellation after a write starts does not roll it back. + */ +@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise +``` + +Types: [SessionId](core.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) + + + +### `schedule/*` events + + + +#### `schedule/changed` — emit + +Durable task set changed; clients refetch global task and Session-active catalogs. + +```ts cordis-catalog +/** Durable task set changed; clients refetch global task and Session-active catalogs. + * @mode emit + */ +'schedule/changed'(): void +``` + +Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index a3e6a4e5f8..df3af3f5e1 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -1,20 +1,22 @@ -# 仅限 Session 内的 Schedule +# 宿主级 Schedule [English](schedule.md) | 中文 -Schedule 拥有持久提醒;这些提醒会作为普通的后续对话轮次返回原 live Session。[持久 Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md) 负责持久化、生命周期与活动状态呈现,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。 +Schedule 独立于 Session 的加载状态存储提醒,并将其投递到原 Session。本页记录 [`types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久与模型可见类型;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合方式与提醒文本格式。 ## 持久记录 -`ScheduleId` 是[品牌化 id](core.zh.md#branded-ids),在单个 Session 内唯一且绝不复用。版本 1 支持正的安全整数 `after_seconds` 延时、显式的绝对 `at` 目标,或至少五分钟的安全整数 `every_seconds` 间隔。创建操作会将每个初始目标规范化为使用四位年份的 RFC 3339 UTC `scheduledAt`;`after` 记录会保留提交的延时,`at` 记录只存储结果时点,`every` 记录则保留固定间隔和下一个目标。 +`ScheduleId` 是全局唯一的[品牌化 ID](core.zh.md#branded-ids)。版本 1 的 `schedule` storage domain 的 `tasks` 表将每条 `ScheduleRecord` 与原始 `sessionId` 绑定,并存储 `status: 'active' | 'inactive'` 和可选的 `lastDelivery`。缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。创建时将目标规范化为 RFC 3339 UTC `scheduledAt`;`after` 保留提交的延迟,`every` 保留固定间隔,`daily` 与 `weekly` 保留规范化后的本地 `time` 及显式 `timeZone`,`cron` 保留规范化后的五字段 `expression` 及显式 `timeZone`。每日、每周与 cron 记录解码保留已提交时点及已存储的时区拼写。已存储的任务记录声明必填的 `title`,即任务界面显示的短名称;创建时必须提供该字段,且绝不从指令派生。任何已存储记录都不得缺少标题:`title` 缺失、去除首尾空白后为空、带首尾空白或超过 120 个字符的已存储记录都会以 `ScheduleLogError` 拒绝持久化解码;schedule domain 未声明备份并跳过的策略,因此一条这样的已存储任务会拒绝整个 domain 的打开,而不是被丢弃。仅显式删除会移除任务;此前已物理删除的任务不会被恢复或虚构。 ```ts type-equiv /** Durable one-shot reminder created from a positive delay. */ interface AfterScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for a delayed one-shot reminder. */ readonly kind: 'after' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Positive safe-integer delay accepted at creation. */ @@ -27,10 +29,12 @@ interface AfterScheduleRecord { ```ts type-equiv /** Durable one-shot reminder created from an absolute instant. */ interface AtScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for an absolute one-shot reminder. */ readonly kind: 'at' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Four-digit-year RFC 3339 UTC target. */ @@ -39,29 +43,56 @@ interface AtScheduleRecord { ``` ```ts type-equiv -/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */ +/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */ interface EveryScheduleRecord { - /** Session-local stable identity. */ + /** Globally unique task identity. */ readonly id: ScheduleId /** Rule discriminator for a fixed-rate recurring reminder. */ readonly kind: 'every' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string /** Trimmed reminder content supplied at creation. */ readonly prompt: string /** Fixed safe-integer interval, never below five minutes. */ readonly everySeconds: number - /** Earliest anchor-aligned occurrence not yet dispatched. */ + /** Next anchor-aligned occurrence while active, or final occurrence when inactive. */ readonly scheduledAt: string } ``` ```ts type-equiv -/** One-shot record variants that terminate on an id-only dispatch. */ +/** Durable daily wall-clock reminder; gaps skip a date and overlaps use the earlier instant. */ +interface DailyScheduleRecord { + /** Globally unique task identity. */ + readonly id: ScheduleId + /** Rule discriminator for a daily wall-clock reminder. */ + readonly kind: 'daily' + /** Required stored task name; already trimmed, non-empty, and at most 120 characters. */ + readonly title: string + /** Trimmed reminder content supplied at creation. */ + readonly prompt: string + /** Local time normalized to HH:mm:ss.SSS. */ + readonly time: string + /** Explicit IANA zone; equivalent timing edits retain the stored spelling. */ + readonly timeZone: string + /** Committed next UTC instant while active, or final occurrence when inactive. */ + readonly scheduledAt: string +} +``` + +```ts type-equiv +/** One-shot task variants. */ type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord ``` ```ts type-equiv -/** The v1 durable reminder record union. */ -type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord +/** Recurring Host task variants. */ +type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord +``` + +```ts type-equiv +/** Reminder rule and target, stored with its original Session binding. */ +type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord ``` ## 绝对时间输入 @@ -69,7 +100,7 @@ type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord `at` 选择器可以是严格且带偏移量的 RFC 3339 字符串,也可以是精确的本地日历对象。本地形式让这种解释在工具边界保持显式: ```ts type-equiv -/** Structured local-calendar input accepted by `schedule_create`. */ +/** Structured local-calendar input accepted by creation and timing edits. */ interface LocalAtInput { /** Four-digit ISO calendar date. */ readonly date: string @@ -81,7 +112,7 @@ interface LocalAtInput { ``` ```ts type-equiv -/** Absolute selector accepted by `schedule_create`. */ +/** Absolute selector accepted by creation and timing edits. */ type AtInput = string | LocalAtInput ``` @@ -89,24 +120,80 @@ type AtInput = string | LocalAtInput Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。 + +## 每日本地钟表时间输入 + +`daily` 是与 `after_seconds`、`at`、`every_seconds`、`weekly` 和 `cron` 互斥的创建选择器之一。例如,`daily: { time: '23:00:00', time_zone: 'Asia/Shanghai' }` 选择该时区每个符合条件的本地日期的 23:00。对象必须精确包含以下字段,不包含日期、偏移量或间隔: + +```ts type-equiv +/** Daily local-time selector accepted by creation and timing edits. */ +interface DailyInput { + /** Local HH:mm:ss time with optional one-to-three fractional digits. */ + readonly time: string + /** Explicit UTC or IANA Area/Location zone. */ + readonly time_zone: string +} +``` + +创建时要求 `HH:mm:ss` 及可选的一至三位小数秒,拒绝闰秒和 `24:00`,将显式 IANA 时区规范化,并以 `HH:mm:ss.SSS` 存储 `time`。首个 `scheduledAt` 严格晚于创建时刻。不存在的本地时间或整个日期被跳过;重叠时间选择较早时点,每个本地日期一次。若较早时点已过去,较晚的重复时点不符合条件。仅 UTC 目标年份受 0001–9999 范围约束;UTC 范围边缘的本地日期可以落在第 0 年或第 10000 年。 + +已保存的下一目标是已提交的 UTC 时点。宿主解码器与重启保留该时点,不依据时区规则重新解析;规范名称变化后,有效的已存储时区别名仍可读取。后续目标使用宿主当前的 IANA 数据。下一目标必须晚于投递判断时刻,并且其本地日期晚于所选发生时点的日期。没有可表示的未来 UTC 目标时,创建以 `time_out_of_range` 失败。 + ## 固定速率输入与补偿 -`every_seconds` 是每条记录单独拥有且至少为 300 秒的间隔,以创建时间为锚点。它只提供固定速率重复调度:协议不包含日历规则或 Cron 表达式、重复调度时区、共享冷却时间或跨记录准入门禁。 +`every_seconds` 是至少 300 秒的安全整数间隔,与创建时间对齐。它衡量经过的时间,而不是每日本地钟表时间:发生偏移量变化时,`every_seconds: 86400` 不能替代 `daily`。协议不提供共享冷却时间或跨记录准入规则;下文描述的 cron 选择器是钟表时间语义,不是固定速率间隔。 -如果一个 Session 在多个目标到期期间处于 cold 或 busy 状态,一条 Every 记录只会贡献其中最新的一次到期触发。dispatch 会直接将记录推进到 dispatch 判断时刻之后第一个与创建锚点对齐的目标,而不会枚举、持久化或回放错过的间隔。如果下一个目标无法落在四位数年份的 UTC 范围内,最后一次 dispatch 将终结该记录。 +宿主启动时若 Every、Daily、Weekly 或 Cron 记录已逾期,只发送最新一次到期发生时点。Every 推进到投递判断时刻之后第一个与当前间隔起算点对齐的目标;Daily、Weekly 和 Cron 遵循上文与下文的本地日期规则。错过的发生时点不会累积。若没有可表示的下一 UTC 目标,投递后保留未运行记录及最近一次回执。 -当多条彼此不同的 Every 记录均已到期,且没有一次性提醒到期时,每条记录都会向同一个 follow-up 批次贡献一次触发,并按目标时间和创建顺序排列。每条 Every 记录的状态互相独立,但该获准批次中的所有 dispatch 都使用同一个判断时刻。批处理限制模型轮次数量;五分钟下限限制每条记录的 timer 频率。 +一次性提醒各自产生一条 follow-up。同一次扫描中属于同一 Session 的到期重复记录共用由 `renderRecurringReminderBatchFraming` 渲染的一条 follow-up,每条记录各包含最新一次触发。五分钟下限仅适用于固定速率间隔;投递不等待目标 Agent 空闲。 -## 持久变更与回放 + +## Cron 钟表时间输入 -版本 1 的 `schedule/change` 会话事件是 Schedule 唯一的持久权威。create 保存完整记录,delete 是终结性且仅含 id 的转换。一次性提醒的 dispatch 同样是终结性且仅含 id。Every dispatch 携带用于选择最新到期触发的墙钟判断时刻,通常推进活动记录而不终结它。dispatch 表示 follow-up 已同步入队,而不表示模型答复成功或用户已读取答复。 +`cron` 是互斥的创建选择器之一。它的对象只携带严格的标准五字段 Vixie 表达式 `minute hour day-of-month month day-of-week` 和显式 IANA 时区,例如 `cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'Asia/Shanghai' }`。字段取值范围如下: + +| 字段 | 取值范围 | 说明 | +|---|---|---| +| minute | 0-59 | | +| hour | 0-23 | | +| day-of-month | 1-31 | | +| month | 1-12 | | +| day-of-week | 0-7 | `0` 和 `7` 都表示周日。 | + +每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。其他写法一律以 `invalid_rule` 拒绝,且错误信息会指出违规字段:`L`、`W`、`#` 运算符,`JAN`/`MON` 名称,`@daily` 风格宏,带秒字段的六字段表达式,超出范围的值、反向范围、零步长和空字段。由于该方言只有五个字段,最小间隔为一分钟;亚分钟级调度仍然不受支持。 + +创建时会先规范化表达式再存储。规范化会把每个字段展开为匹配值集合,再确定性地重新编码该集合:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长。保留每个字段的星号标志,正是下文 day-of-month/day-of-week 规则在存储的表达式上仍与 Vixie 一致的原因。持久化解码器会拒绝非规范化的已存储表达式。 + +首个目标是该时区内本地日期与时间匹配的第一个严格未来时点。它复用 Daily 与 Weekly 的本地时间解析:不存在的本地时间被跳过,重叠时间每个日期只取较早一次。已提交的 UTC `scheduledAt` 在重启时绝不重新计算。 + +day-of-month 与 day-of-week 遵循 Vixie 语义。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此 `*/1` 与 `*/2` 都是星号字段,而 `1-31` 与 `0-7` 受限。任一字段为星号字段时,本地日期只有在两个字段都匹配时才算匹配;两个字段都不是星号字段时,匹配任一字段即算匹配。 + +cron 解码保留已提交时点及已存储的时区拼写,并拒绝非规范化的已存储表达式。cron 仅属于当前宿主的 `ScheduleRecord`;冻结的历史 Session 解码器不接受它。 + +## 历史 Session 变更 + +版本 1 的 `schedule/change` 事件仍可作为历史 Session 数据解码。其 create、fold 和 invariant 类型使用 `LegacyScheduleRecord`,仅接受 After、At 和 Every;Daily、Weekly 与 Cron 仅属于当前宿主的 `ScheduleRecord`。宿主记录解码器独立于冻结的历史解码器。在 title 出现之前写入的 create 记录不含 `title`,因此历史解码器接受该缺失成员,而宿主解码器仍要求它。历史事件不会填充 storage domain 或触发投递。这些事件中的已有提醒需要显式通过 `schedule_create` 重新创建;不会隐式迁移 Session 或转换旧 `at` 记录。 + +```ts type-equiv +/** + * Frozen Session event and fold vocabulary; daily rules belong only to Host storage. + * + * A version-1 event written before titles existed persists no `title` member, so + * `after`, `at`, and `every` decode without one and stay readable. The Host task + * record requires the member and never persists a record without it. + */ +type LegacyScheduleRecord = + | LegacyAfterScheduleRecord + | LegacyAtScheduleRecord + | LegacyEveryScheduleRecord +``` ```ts type-equiv /** Creates one durable reminder record. */ interface ScheduleCreateChange { readonly version: 1 readonly operation: 'create' - readonly schedule: ScheduleRecord + readonly schedule: LegacyScheduleRecord } ``` @@ -149,11 +236,11 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠精确 `inheritedEventCount` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Projection 初始化会在不可变 header 旁接收该 cut,复用共享 transition,并持久化 cut、活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 +历史 decoder 与 fold 拒绝未知版本、额外字段、复用 ID 和无效转换。该事件仍编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。Session 历史不负责决定当前活动任务。 ## 活动视图与管理 -工具值将持久记录与根据当前墙钟派生的交付状态组合起来。`session-local` 表示原 Session 必须处于 live 状态:不存在外部通知渠道或 cold Session scheduler。 +工具值将存储记录与当前墙钟派生的时间状态组合。宿主在投递需要时恢复原 Session;列出和删除任务不会激活该 Session。 ```ts type-equiv /** Current delivery timing derived from the durable record and wall clock. */ @@ -161,8 +248,8 @@ type ScheduleState = 'scheduled' | 'overdue' ``` ```ts type-equiv -/** Fixed v1 delivery boundary: the original session must be live. */ -type ScheduleDeliveryMode = 'session-local' +/** Host-driven delivery resumes the original Session when needed. */ +type ScheduleDeliveryMode = 'host' ``` ```ts type-equiv @@ -175,18 +262,282 @@ type ScheduleView = ScheduleRecord & { } ``` -生成的[工具目录](../tool-catalog.zh.md#deepseek-aidsh-schedule)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与结果 schema。一条 Agent-scoped 队列将管理调用与到期工作串行化。每次读取或判断都会先等待共享的 Session 持久化 barrier;create 与实际执行的 delete 在追加后还会再次等待。barrier 失败会报告 `persistence_uncertain`,而不是猜测 eager write 是否已提交。其他稳定错误代码是 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`not_future`、`time_out_of_range`、`frequency_too_high`、`corrupt_schedule_log` 和 `internal_error`。 +[工具目录](../tool-catalog.zh.md#deepseek-aidsh-schedule) 负责 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update` 的 schema。创建、删除和更新在 storage domain 写入确认后返回成功。宿主级队列将这些变更与到期投递串行化;删除任务不会移除已入队消息。模型工具操作当前 Session,共享宿主的 create、list、update 和 delete 方法接受显式 Session 绑定。创建必须提供 `title`,其去除首尾空白后必须非空且不超过 120 个字符;缺失、空白或过长的标题以 `invalid_prompt` 拒绝,且创建过程绝不从指令派生标题。create 和 list 视图在指令之外同时携带已存储的 `title`,且 `title` 缺失或非法的已存储记录在解码时被拒绝。 -## 只读 Web 目录 +```ts type-equiv +/** Reminder creation selector, shared by the model consumer and Host service. */ +interface ScheduleCreateRequest { + /** Non-empty reminder text. */ + prompt: string + /** Required task name of at most 120 characters, non-empty after trimming; names the card, detail heading, and task lists. */ + title: string + /** Relative one-shot delay in seconds. */ + after_seconds?: number + /** Absolute one-shot target. */ + at?: AtInput + /** Fixed recurrence interval in seconds. */ + every_seconds?: number + /** Daily wall-clock time in an explicit IANA zone. */ + daily?: DailyInput + /** Weekly wall-clock time and explicit ISO weekday set in an IANA zone. */ + weekly?: WeeklyInput + /** Five-field cron expression evaluated in an explicit IANA zone. */ + cron?: CronInput +} +``` -可选 Session projection 注册表存在时,Schedule 会注册客户端可见的 `schedule` key,其值是完整的活动 `ScheduleRecord[]`。live、cache、history 与 detached 读取共用同一套 header-aware 严格 fold;畸形权威输入会使既有读取路径失败,而不会发布部分值。 +```ts type-equiv +/** Session-scoped task list, without Agent activation. */ +interface ScheduleListRequest { + /** Original Session binding. */ + sessionId: SessionId +} +``` -shipped Web bundle 默认禁用 `ui-schedule`,显式 Schedule overlay 则把它与 Host 能力一同启用。[`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.zh.md)拥有 header 交互,[`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.zh.md)拥有列表行呈现,持久 Schedule Agent Note 拥有二者共享的活动状态边界。共享值只表示当前活动状态,绝不表示交付历史或回执;到期提醒仍通过下文所述的普通 Assistant 输出出现。 +```ts type-equiv +/** Delete request identifying a task within its original Session binding. */ +interface ScheduleDeleteRequest extends ScheduleListRequest { + /** Task to remove. */ + id: ScheduleId +} +``` -## Live 交付 +```ts type-equiv +/** Successful `schedule_delete` value, including the non-mutating not-found result. */ +type ScheduleDeleteResult = + | { readonly id: ScheduleId; readonly deleted: true } + | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } +``` -进程内 owner 根据持久 fold 派生最早的 timer,并在每次有界等待后重新读取墙钟。cold Session 不执行任何工作;重新打开后会重建 timer,并使已经过去的目标进入 overdue 状态。到期的一次性提醒享有优先级,每次只进入一个后续轮次。当没有一次性提醒到期时,所有 overdue 的 Every 记录会组成上述单个批次。 +## Web 目录 -到期工作会先等待 Agent 完全 idle 并认领 maintenance phase,再重新折叠状态、采样本次判断、将一个 `followup()` 排入队列,并追加对应的 dispatch 变更。它绝不会调用 `steer()`,也绝不会中断当前轮次。 +Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型: -获得准入的一次性提醒或固定速率批次会启动一个普通的后续轮次,且只通过普通对话 transcript(文本记录)出现;Schedule 不提供独立的持久 Web 回执。上面的只读活动目录绝不表示交付成功。如果 framing 构造或同步队列准入失败,则不会记录 dispatch,提醒仍保持活动。队列准入后、持久 dispatch 前的狭窄崩溃窗口可能使提醒内容在恢复后重复,因此该边界提供的是尽力而为的至少一次交付,而非恰好一次交付。 +```ts type-equiv +/** Durable Session inbox delivery acknowledgment, not model execution completion. */ +interface ScheduleDeliveryReceipt { + /** Canonical UTC target of the delivered occurrence. */ + readonly scheduledAt: string + /** Canonical UTC time sampled after Session persistence acknowledged delivery. */ + readonly deliveredAt: string + /** Identity of the delivered message, shared by tasks in one recurring batch. */ + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** Browser-safe retained reminder with its original Session binding. */ +type ScheduleCatalogEntry = ScheduleRecord & { + /** Session receiving this reminder when it becomes due. */ + readonly sessionId: SessionId + /** Inactive reminders remain visible but never schedule another delivery. */ + readonly status: 'active' | 'inactive' + /** Most recent durably acknowledged inbox delivery, when available. */ + readonly lastDelivery?: ScheduleDeliveryReceipt +} +``` + +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 + +Web bundle 默认禁用 `ui-schedule`;Schedule overlay 将它与宿主能力一起启用。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 + +## 修改时间 + +`schedule.update({ sessionId, id, expected, change })` 修改活动任务,不激活其 Session。`expected` 是完整的已观察 `ScheduleRecord`,不是附带额外元数据的目录条目。Host 在投递和删除共用的 FIFO 内将它与当前记录比较。正常投递可能在编辑期间推进目标;此时旧快照返回 `schedule_conflict`,不会覆盖当前规则。任务不存在或绑定不匹配时返回 `schedule_not_found`,未运行任务返回 `schedule_ended`。 + +```ts type-equiv +/** Timing-only edit; it may select a different recurrence kind than the stored record, and one-shot targets use an absolute `at`. */ +type ScheduleTimingChange = + | { readonly kind: 'at'; readonly at: AtInput } + | { readonly kind: 'every'; readonly every_seconds: number } + | { readonly kind: 'daily'; readonly daily: DailyInput } + | { readonly kind: 'weekly'; readonly weekly: WeeklyInput } + | { readonly kind: 'cron'; readonly cron: CronInput } +``` + +```ts type-equiv +/** Compare-and-update request within the original Session binding. */ +interface ScheduleUpdateRequest extends ScheduleDeleteRequest, ScheduleUpdateContent { + /** Complete record observed when editing began, including the committed target. */ + readonly expected: ScheduleRecord + /** New timing; its kind may differ from the stored record's kind, and an omitted value keeps the committed target. */ + readonly change?: ScheduleTimingChange +} +``` + +```ts type-equiv +/** Successful current record, non-mutating lookup/conflict failure, or invalid name/instruction/timing. */ +type ScheduleUpdateResult = + | { readonly id: ScheduleId; readonly updated: boolean; readonly record: ScheduleRecord } + | ScheduleUpdateMiss + | ScheduleToolError +``` + +Daily 规则改变后,沿用本地时间和时区语义计算严格晚于当前时间的首个目标。Cron 规则改变后,先规范化表达式,再按相同的本地日期、缺口与较早重叠规则计算首个严格未来目标。Every 间隔改变后,以接纳保存时刻加该间隔建立新的起点。After 或 At 任务可以在保留 id 的同时,通过 At 记录接收新的绝对目标。这些操作保留已存储的 `title`、指令、原始 Session 绑定、生命周期状态和全部已保存回执。变更也可以选择不同的重复类型,此时按新规则重算目标,并保留同一 id、标题、指令、Session 绑定、状态与全部已保存回执。归一化后等价的规则返回 `updated: false` 及未改变的记录,不重写存储、不重设目标,也不发出变更事件。写法不同但等价的 Cron 表达式就是这样的无变更,因为比较使用规范化表达式与规范化时区。 + +输入校验复用现有时间错误。改变后的绝对目标在 FIFO 槽位开始执行时仍须位于未来。任务 put 成功后才发出变更通知并重算 timer。存储和生命周期失败会 reject;取消检查发生在排队和初始化等待之后,但不能回滚已经开始的写入。一次性记录保留 UTC 时点,而非编辑表单中的 IANA 时区。时间编辑不追加历史 Session 事件:模型通过 `schedule_update` 做同样的原地修改,它与读取到的记录做比对,而不是删除后重建任务。 + +## 已保存的发送记录 + +`schedule.history({ sessionId, id, limit })` 读取已保存的回执,不激活 Session。显式 limit 必须是 1–100 的安全整数。分页按追加顺序逆序排列,不按墙钟排序;可选的消息 id 游标排除上一页最早的记录。新记录追加不会改变该游标的定位。任务不存在或 Session 绑定错误时返回 `schedule_not_found`;游标未知时返回 `delivery_cursor_not_found`,不会视为查询成功的空页。 + +```ts type-equiv +/** Saved inbox delivery with its immutable sent prompt when recorded by this Host. */ +interface ScheduleDeliveryRecord extends ScheduleDeliveryReceipt { + /** Prompt sent for this occurrence; unavailable for legacy receipts. */ + readonly prompt?: string +} +``` + +```ts type-equiv +/** Explicitly bounded saved-delivery query within one Session binding. */ +interface ScheduleDeliveryHistoryRequest extends ScheduleDeleteRequest { + /** Required safe-integer page size from 1 through 100. */ + limit: number + /** Message identity of the oldest entry in the previous page; excluded from this page. */ + before?: MessageId +} +``` + +```ts type-equiv +/** Configured limits applied when appending one task's delivery receipt. */ +interface DeliveryRetentionBounds { + /** Retained window in days, measured back from the acknowledgment being appended. */ + readonly days: number + /** Maximum retained records per task; the newest survive. */ + readonly records: number +} +``` + +```ts type-equiv +/** Newest-first saved deliveries, or a non-mutating task/cursor lookup failure. */ +type ScheduleDeliveryHistoryResult = + | { + readonly id: ScheduleId + readonly records: ScheduleDeliveryRecord[] + /** Whether earlier delivery records may be unavailable; missing receipts are never reconstructed. */ + readonly earlierRecordsUnavailable: boolean + /** True only after an append removed saved records; absent legacy evidence is false. */ + readonly earlierRecordsPruned: boolean + /** Current Host retention configuration, also used by the delivery writer. */ + readonly retention: DeliveryRetentionBounds + /** Oldest returned message identity, present only when older saved records remain. */ + readonly nextBefore?: MessageId + } + | { readonly id: ScheduleId; readonly code: 'schedule_not_found' | 'delivery_cursor_not_found' } +``` + +新任务初始化空历史。缺少历史字段的旧任务仅展示已有的最近一次回执,标明更早记录不可用,且读取不会重写任务行。后续确认会保留该回执,并追加带有指令快照的真实新回执。已丢失的历史与缺失的旧指令快照不会被重建。历史、最近回执、状态和下一目标由同一次任务行写入提交;显式删除任务会停止后续投递、把任务行连同其已保存的回执一并移除,并使其离开 `list` 与 `catalog`;对话消息不受影响。已保存的发送记录在追加确认时按配置的 `deliveryHistoryDays` 窗口(以每条回执的 `deliveredAt` 向前计算)与 `deliveryHistoryRecords` 条数上限裁剪;新追加的最近一次回执始终保留,发生裁剪时标记更早记录不可用。分页限制返回的记录数量,不限制存储大小或指令字节数。 + +## 宿主投递 + +宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递时通过 Session controller 获取原 Session,包括尚未加载的 Session。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。 + +插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。 + +Session 获取、消息入队或持久化确认失败时,任务继续保存在存储中,并报告宿主警告。后续管理变更或宿主重启会重试。Session 持久化与任务 put 不具有跨存储原子性。收件箱已持久化、任务尚未 put 时发生崩溃,可能重复投递;回执并未消除这一间隔。Schedule 不提供模型结果回执、执行取消或外部通知渠道。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.schedule` — `ScheduleService` + +Shared management service; reads, deletion, and timing edits never activate a Session. + +`sessionPersistence` is a load-order requirement rather than a directly called service: a delivery commits only when `ctx.sessions.flush()` reports that a `session/flush` listener participated, and the persistence backend providing this service is the plugin that registers that listener. + +```ts cordis-catalog +/** + * Create a reminder bound to the caller-selected Session without activating it. + * + * The request must supply a title; a missing, blank-after-trim, or over-long + * title rejects with `invalid_prompt` instead of deriving one from the prompt. + * The record is built from the clock reading taken before the request joins the + * serialized queue, so a create that waits behind a longer operation keeps its + * request-time anchor and may already be due when the queue reaches it. + * @param sessionId - Original Session receiving the reminder. + * @param request - Validated tool selector, required title, and reminder content. + * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits. + * @returns The durably stored schedule. Cancellation does not roll back an in-flight write. + */ +async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise + +/** + * Read the selected Session's active tasks without resuming its Agent. + * @param request - Session whose task list is requested. + * @returns Persisted reminders in storage order. + */ +@Remote('list') async list(request: ScheduleListRequest): Promise + +/** + * Read all active and inactive Host reminders with their original Session bindings. + * A deleted reminder has no row, so it is absent here. + * Does not activate Sessions or read Session history. + * @returns Reminders ordered by scheduledAt ascending, then lexicographically by id. + */ +@Remote('catalog') async catalog(): Promise + +/** + * Read saved inbox deliveries without activating or reading the original Session. + * The task's own row supplies its binding, so its records stay readable through this lookup. + * @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor. + * @returns Newest-first deliveries in append order, or a task/cursor lookup failure. + * @throws ScheduleInputError when limit is not a safe integer from 1 through 100. + */ +@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise + +/** + * Delete one task belonging to the selected Session, leaving queued messages intact. + * + * The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its + * saved delivery records go with it. + * @param request - Session and exact task identity. + * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits. + * @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write. + */ +@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise + +/** + * Update the name, instruction, and timing of an active task within the original Session + * binding without activating the Session or changing saved deliveries. + * + * Each supplied field replaces its stored value; an omitted field keeps it. A name or + * instruction change alone does not reset the committed target. + * @param request - Task binding, complete observed record, and any combination of timing, name, and instruction. + * @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins. + * @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict result. + * Storage and lifecycle failures reject; cancellation after a write starts does not roll it back. + */ +@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) + + + +### `schedule/*` events + + + +#### `schedule/changed` — emit + +Durable task set changed; clients refetch global task and Session-active catalogs. + +```ts cordis-catalog +/** Durable task set changed; clients refetch global task and Session-active catalogs. + * @mode emit + */ +'schedule/changed'(): void +``` + +Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index d7a28c8d0b..e4269bb273 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -9,8 +9,8 @@ en: 541354f6468e117d zh: 2d7a665bad828a02 /sessions/sessioneventmap-the-event-vocabulary/the-request-header-event-request-header: - en: ab34605472a45b68 - zh: bfd04649c552cebd + en: 6eb931fcb2f4ab8d + zh: d822a83384cc1fee /sessions/sessioneventmap-the-event-vocabulary/the-route-capacity-event-request-context: en: e6bf2297344b77e3 zh: f99beedebc892f6a diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index e55ebf703c..bc2abb0098 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -136,7 +136,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** This request begins a distinct model-message series, independently of the header reason. */ startsSeries?: true } /** @@ -180,7 +180,7 @@ interface SessionEventMap { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). The rendered system prompt is not part of the header: it is derived history, the `system/message` event at surface node 0 and any later in-history system node ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)), so a prompt change replaces or appends a system node and leaves the header unchanged. A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). The rendered system prompt is not part of the header: it is derived history, the `system/message` event at surface node 0 and any later in-history system node ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)), so a prompt change replaces or appends a system node and leaves the header unchanged. A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. An `'initial'`, `'resume'`, or `'change'` snapshot carries `startsSeries: true` when that request also begins a series; reason `'series'` already records that fact. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** @@ -628,6 +628,12 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; + /** + * Fold unseen committed events into capability-independent tool history. + * Initial access reconstructs inherited history; later reads consume only new events. + * @returns an immutable snapshot for LLM request projection, including historical addition definitions. + */ + toolHistory(): ToolHistory; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 4143f2366c..6b2f859d6b 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -136,7 +136,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** This request begins a distinct model-message series, independently of the header reason. */ startsSeries?: true } /** @@ -180,7 +180,7 @@ interface SessionEventMap { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。渲染后的系统提示词不属于请求头:它是派生历史,即 surface 第 0 号节点上的 `system/message` 事件以及任何后续的历史内系统节点([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md)),因此提示词变更替换或追加一个系统节点,而请求头保持不变。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。渲染后的系统提示词不属于请求头:它是派生历史,即 surface 第 0 号节点上的 `system/message` 事件以及任何后续的历史内系统节点([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md)),因此提示词变更替换或追加一个系统节点,而请求头保持不变。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果 `'initial'`、`'resume'` 或 `'change'` 快照所属请求同时开启序列,它会携带 `startsSeries: true`;reason `'series'` 已记录这一事实。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** @@ -630,6 +630,12 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; + /** + * Fold unseen committed events into capability-independent tool history. + * Initial access reconstructs inherited history; later reads consume only new events. + * @returns an immutable snapshot for LLM request projection, including historical addition definitions. + */ + toolHistory(): ToolHistory; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 90bde18fbe..385b99e972 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -15,11 +15,11 @@ en: 1e01c817c5879c40 zh: a79abd92ee4c8f85 /right-sidebar/navigation-ctx-sidebarright: - en: a78d2265ade73602 - zh: d1a927f5e25db43e + en: 0f80b5364cbad804 + zh: db81652a5374fad1 /right-sidebar/slots-and-owner-props: - en: 45a42c40269a2f43 - zh: 8f9bfdacb43fe79a + en: f58dc6ff6b0027a7 + zh: 707722fcbd096efc /right-sidebar/document-renderers: en: 2bf47a890fce175e zh: 45f81c579b8939a8 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 7ec6d931ef..6315827c27 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -90,6 +90,8 @@ Placement is the caller's option, never a type's property. The conversation call `focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. +`focusedTarget(element?)` captures the visible page under live DOM focus, including an embedding iframe; outside or stale sidebar markup yields no target. `commandTarget(element?)` also permits the mounted Session's active dock pane for an open initiated outside the sidebar. A `SidebarRightTarget` captures Session, pane, host, tab occurrence, and navigation revision. `isTargetCurrent(target)` checks that identity before execution, so reopening a record or navigating it invalidates a captured action; it never retargets to another page. See the [controller methods](../../packages/client/ui-sidebar-right/src/client/service.ts) for close and placement outcomes. + ## Slots and owner props The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)). @@ -106,6 +108,8 @@ A body, title and guide replacement receive the framework-injected `useTabInfo() `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). +`tab.actions.bindCommands(commands)` contributes `SidebarRightTabCommands`, currently an optional `refresh` callback, for the mounted body. Omitted operations are unavailable; the returned disposer releases that registration without removing a newer body's commands. The tab lifetime also releases them. `tab.refreshShortcut` supplies the effective refresh catalog entry for page controls; it is optional when no entry is available. These fields are declared in the [slot owner types](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts). + ## Document renderers The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer registers its own body and can reuse shared presentation through its child slots. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 413ea2145d..1df6007e12 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -90,6 +90,8 @@ export function apply(ctx: Context): void { `focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 +`focusedTarget(element?)` 从实时 DOM 焦点(包括内嵌 iframe)捕获可见页面;侧栏外部或过期的侧栏标记不产生目标。`commandTarget(element?)` 还允许侧栏外部发起的打开操作使用已挂载 Session 的活动停靠窗格。`SidebarRightTarget` 捕获 Session、窗格、host、tab occurrence 和导航 revision。`isTargetCurrent(target)` 在执行前检查这些身份,因此重新打开记录或再次导航都会使已捕获的动作失效;它不会改为操作其他页面。关闭与落位结果见[控制器方法](../../packages/client/ui-sidebar-right/src/client/service.ts)。 + ## Slot 与 owner props Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。 @@ -106,6 +108,8 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 +`tab.actions.bindCommands(commands)` 为已挂载正文提供 `SidebarRightTabCommands`,目前包含可选的 `refresh` 回调。省略的操作不可用;返回的 disposer 释放本次注册,不会移除较新正文的命令。标签生命周期结束时也会释放这些命令。`tab.refreshShortcut` 为页面控件提供生效的刷新目录项;没有可用条目时该字段可省略。这些字段声明在 [slot owner 类型](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts)中。 + ## 文档渲染器 `text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器注册自己的正文,并可通过子 slot 复用共享展示组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index a2a1ef534a..f4673fc8b6 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md /web-client-architecture: - en: b7d2de3798122a9e - zh: 62598228a4241d8c + en: 4936655bc5d3ba5b + zh: 062419330dea65b3 /web-client-architecture/layers-and-ownership: en: c0454bca818f86ee zh: 427582150fd3b976 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 80d233b634..f07094d649 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -4,6 +4,8 @@ English | [中文](web-client.zh.md) The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong. +[Keyboard shortcuts](../../packages/client/shortcuts/README.md) owns window-local command registration and physical-key dispatch; [the shortcut reference](../../packages/client/ui-shortcuts/README.md) presents available commands and local input actions. Command owners declare each runtime/platform default and share their existing actions with mouse controls. The shared modal primitive arbitrates top-layer Escape and restores focus. + ## Layers and ownership | Layer | Main owners | Responsibility | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f14cdbe9b1..5a0069abb4 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -4,6 +4,8 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI,[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。 +[快捷键](../../packages/client/shortcuts/README.zh.md)负责窗口内命令注册和物理键分发;[快捷键速查](../../packages/client/ui-shortcuts/README.zh.md)展示可用命令及局部输入操作。命令 owner 声明各运行端/平台的默认键位,并与鼠标控件共用既有操作。共用模态组件裁决顶层 Esc 并恢复焦点。 + ## 分层与所有权 | 层 | 主要 owner | 职责 | diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 9d4611a68a..535ca0ce8a 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -15,8 +15,8 @@ en: 7ba5b4e06c162bd6 zh: f1dd7eacacd07e0b /workspaces/default-workspace-initialization: - en: 9446b81370c5492f - zh: 92852f2999d766f2 + en: c4307480e504ba81 + zh: dc8a98e1b447b900 /workspaces/session-pinning: en: 3f5ab940adbfab1e zh: f759cd34e3b06ca1 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 18c1bf3d8b..900be6948c 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -123,7 +123,7 @@ Sessions get their cwd at create time from whoever creates them, not from this r ## Default Workspace initialization -The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspaceInitializeDefaultRequest`: a Client-resolved `directoryName` and initial `title`. The Host resolves the Documents location and asks the registry to initialize once. Locale selection belongs to the Client; the registry accepts a directory resolver and commits the registration with its durable identity. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling. +The controller's `initializeDefault` takes no request: it owns the fixed `default-workspace` directory name, resolves the Documents location, and asks the registry to initialize once. The registry accepts a directory resolver, derives the initial title from the requested directory's final segment rather than the canonical one, and commits the registration with its durable identity. No language reaches the Host — browser consumers label a Workspace still carrying that automatic title through the controller's `workspaceDisplayTitle`, so only the on-screen name follows the reader's language. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling. ## Session pinning @@ -339,12 +339,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('create') create(request: WorkspaceCreateRequest): Promise /** - * Initialize or reuse the default Workspace during first-use startup. - * @param request - initial directory name and title; never rename an existing default. + * Initialize or reuse the default Workspace during first-use startup. The + * directory name is fixed, so the Host never renames or relocates an + * existing default; its initial title is that same name, which browser + * consumers label in the reader's language. * @param signal - caller lifetime; cancels native directory lookup. * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ -@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise +@Remote('initializeDefault') async initializeDefault(signal: AbortSignal): Promise /** * Rename one Workspace to a unique non-blank title. @@ -495,13 +497,15 @@ async create(path: string, title?: string): Promise * Initialize the default Workspace only while both the registry and Session * history are empty. Repeated requests reuse its durable identity; deleting * that registration permanently disables automatic creation. - * @param resolveDirectory - resolve the absolute directory and initial title; - * called only for eligible creation, inside the registry mutation queue. - * Missing directories are created recursively before registration. + * @param resolveDirectory - resolve the absolute directory; called only for + * eligible creation, inside the registry mutation queue. Missing directories + * are created recursively before registration, and the initial title is the + * requested directory's own final segment — not the canonical one, so a + * symlink at that path does not retitle the Workspace after its target. * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ -initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise +initializeDefault(resolveDirectory: () => Promise): Promise /** * Look up a workspace by id. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index c2674737db..ad78c7e1f3 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -123,7 +123,7 @@ interface Workspace { ## 默认工作区初始化 -控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspaceInitializeDefaultRequest`,包含 Client 解析出的 `directoryName` 和初始 `title`。Host 解析 Documents 位置,并请求注册表执行一次初始化。语言选择由 Client 负责;注册表接收目录解析器,并将登记与持久化身份一起提交。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。 +控制器的 `initializeDefault` 不接受请求参数:它拥有固定目录名 `default-workspace`,解析 Documents 位置,并请求注册表执行一次初始化。注册表接收目录解析器,以所请求目录(而非规范路径)的最后一段作为初始标题,并将登记与持久化身份一起提交。语言不会传到 Host——浏览器消费方通过控制器的 `workspaceDisplayTitle` 为仍保留该自动标题的工作区加标签,因此只有屏幕上的名称跟随读者语言。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。 ## 会话置顶 @@ -339,12 +339,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('create') create(request: WorkspaceCreateRequest): Promise /** - * Initialize or reuse the default Workspace during first-use startup. - * @param request - initial directory name and title; never rename an existing default. + * Initialize or reuse the default Workspace during first-use startup. The + * directory name is fixed, so the Host never renames or relocates an + * existing default; its initial title is that same name, which browser + * consumers label in the reader's language. * @param signal - caller lifetime; cancels native directory lookup. * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ -@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise +@Remote('initializeDefault') async initializeDefault(signal: AbortSignal): Promise /** * Rename one Workspace to a unique non-blank title. @@ -495,13 +497,15 @@ async create(path: string, title?: string): Promise * Initialize the default Workspace only while both the registry and Session * history are empty. Repeated requests reuse its durable identity; deleting * that registration permanently disables automatic creation. - * @param resolveDirectory - resolve the absolute directory and initial title; - * called only for eligible creation, inside the registry mutation queue. - * Missing directories are created recursively before registration. + * @param resolveDirectory - resolve the absolute directory; called only for + * eligible creation, inside the registry mutation queue. Missing directories + * are created recursively before registration, and the initial title is the + * requested directory's own final segment — not the canonical one, so a + * symlink at that path does not retitle the Workspace after its target. * After resolution, caller cancellation does not roll back creation or registration. * @returns the initialized Workspace, or undefined when automatic creation is ineligible. */ -initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise +initializeDefault(resolveDirectory: () => Promise): Promise /** * Look up a workspace by id. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index eb3cee0809..cf6fb49b8f 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: dae063dda1a53c5a - zh: 19fc8498d0603265 + en: ac9bc0329363007d + zh: 0ef8b06296058cc6 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -174,14 +174,17 @@ en: 573da0cf49b1eed6 zh: 573da0cf49b1eed6 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create: - en: d9ff99ac30a603eb - zh: 15e658a8f7003e90 + en: ddd8148a0f9c849b + zh: 8c930b69c46eab92 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete: - en: 8b37dec413d42cd7 - zh: 292685c19ab48260 + en: 3b5a084ce0d87d13 + zh: c1356d4b062f0e63 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list: - en: 3c18d46b1371641e - zh: 13f0d78532bdec8b + en: 4422ae2e2a4ca897 + zh: 56431645118d6ca0 +/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update: + en: d2cc5c77c73d6068 + zh: 639a86d6588e9d82 /tool-schema-catalog/deepseek-ai-dsh-tool-lsp: en: 32982123b1f0e5bc zh: 32982123b1f0e5bc diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3e4407192e..0fa4a68948 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -32,7 +32,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.terminals`, `ctx.systemPrompt`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot shell/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.jobs`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | -| `@deepseek-ai/dsh-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. | +| `@deepseek-ai/dsh-schedule` | `schedule_create`, `schedule_delete`, `schedule_list`, `schedule_update` | `ctx.tools`, `ctx.schedule`, `a live root Agent` | `tool/call`, `Schedule storage domain create, update, or delete`, `tool/result` | - | Registered in live root Agent scopes while the opt-in Schedule service is loaded. Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly local times in an explicit IANA zone, and cron as a five-field expression. Management uses the Host storage domain; due messages resume the original Session. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-stdio`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | @@ -1382,7 +1382,7 @@ create, edit, pause, and resume require direct-human root authority; complete an ### `schedule_create` -Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest occurrence per overdue rule. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed. +Create one reminder in the current session. Supply a non-empty prompt, a title, and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 300, daily as {time: "23:00:00", time_zone: "Asia/Shanghai"}, weekly as {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} with Monday 1 through Sunday 7, or cron as {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} with the five fields minute hour day-of-month month day-of-week. Every creation requires a title of at most 120 characters, non-empty after trimming; it names the task on its card, its detail heading, and in the task lists. Daily, weekly, and cron reminders retain that local time and zone; missing wall-clock times skip the date and repeated times use only the earlier instant. A cron day-of-month and day-of-week pair matches when either field matches once both are restricted. Fixed-rate targets stay creation-aligned until an interval edit establishes a new anchor. All four recurring kinds batch one latest occurrence per overdue rule. The Host restores this session when a reminder is due. After downtime, each recurring reminder delivers its latest missed occurrence once. Delivery can repeat after a crash. ```json { @@ -1392,6 +1392,10 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl "type": "string", "description": "Reminder content to present when the target becomes due." }, + "title": { + "type": "string", + "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists." + }, "after_seconds": { "type": "number", "description": "Positive safe-integer delay in seconds." @@ -1400,6 +1404,71 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl "type": "number", "description": "Fixed-rate safe-integer interval in seconds, at least 300." }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "time", + "time_zone" + ] + }, + "weekly": { + "type": "object", + "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + }, + "weekdays": { + "type": "array", + "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "time", + "time_zone", + "weekdays" + ] + }, + "cron": { + "type": "object", + "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, "at": { "oneOf": [ { @@ -1430,7 +1499,8 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl } }, "required": [ - "prompt" + "prompt", + "title" ] } ``` @@ -1439,7 +1509,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_delete` -Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false. +Delete one retained reminder in the current session by its exact id, whether active or inactive. Unknown or already-deleted ids return deleted false. Deletion does not retract a queued message. ```json { @@ -1447,7 +1517,7 @@ Delete one active reminder in the current session by the exact id returned by sc "properties": { "id": { "type": "string", - "description": "Exact session-local schedule id." + "description": "Exact schedule id." } }, "required": [ @@ -1460,7 +1530,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_list` -List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode. +List every active reminder in the current session, including its exact id, title, UTC target, scheduled or overdue state, and host delivery mode. The returned order is not significant. ```json { @@ -1471,7 +1541,133 @@ List every active reminder in the current session in creation order, including i Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) -Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. +### `schedule_update` + +Change one reminder in the current session in place, keeping its id and its saved delivery records: address it by the exact id schedule_list returned, then supply a new title or prompt, or exactly one new selector from at, every_seconds, daily, weekly, or cron in the same forms schedule_create accepts. An omitted field keeps its stored value. After is not updatable: create a new reminder for a relative delay. The Host compares the record it finds for that id with the stored one, so a concurrent edit returns schedule_conflict instead of overwriting it; an inactive or unknown reminder returns updated false. Editing an every_seconds interval anchors the new fixed rate at the accepted save time; a name or instruction change alone keeps the committed target. + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact schedule id that schedule_list returned for one reminder." + }, + "title": { + "type": "string", + "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + }, + "prompt": { + "type": "string", + "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "time", + "time_zone" + ] + }, + "weekly": { + "type": "object", + "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + }, + "weekdays": { + "type": "array", + "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "time", + "time_zone", + "weekdays" + ] + }, + "cron": { + "type": "object", + "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, + "at": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "time": { + "type": "string" + }, + "time_zone": { + "type": "string" + } + }, + "required": [ + "date", + "time", + "time_zone" + ] + } + ], + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "id" + ] +} +``` + +Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) + +Registered in live root Agent scopes while the opt-in Schedule service is loaded. Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly local times in an explicit IANA zone, and cron as a five-field expression. Management uses the Host storage domain; due messages resume the original Session. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index cf78ee4d4f..c64eb3413e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -36,7 +36,7 @@ | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.terminals`、`ctx.systemPrompt`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.jobs`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | -| `@deepseek-ai/dsh-schedule` | `schedule_create`、`schedule_delete`、`schedule_list` | `ctx.tools`、`ctx.sessions`、Session 持久化、未来创建的 live 根 Agent | `tool/call`、`schedule/change create or delete`、`tool/result` | - | 仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 | +| `@deepseek-ai/dsh-schedule` | `schedule_create`、`schedule_delete`、`schedule_list`、`schedule_update` | `ctx.tools`、`ctx.schedule`、live 根 Agent | `tool/call`、Schedule storage domain 创建、更新或删除、`tool/result` | - | 选择启用的 Schedule 服务加载期间,在 live 根 Agent scope 内注册。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。 | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-stdio`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | @@ -1388,7 +1388,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_create` -在当前会话中创建一条提醒。请提供非空 prompt 和恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;或不小于 300 的安全整数 every_seconds。固定速率提醒始终与创建时刻对齐,会跳过错过的发生时点,并把每条逾期规则的最新一个发生时点合并到一个批次中。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。 +在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 300 的安全整数 every_seconds;形如 {time: "23:00:00", time_zone: "Asia/Shanghai"} 的 daily;形如 {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} 的 weekly,其中周一为 1、周日为 7;或形如 {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} 的 cron,其五个字段为 minute hour day-of-month month day-of-week。每次创建都必须提供标题,最多 120 个字符且去除首尾空白后非空;该标题用于任务卡片、详情标题和任务列表。每日、每周与 cron 提醒保留指定的本地时间与时区;不存在的钟表时间会跳过该日期,重复时刻只取较早实例。cron 的 day-of-month 与 day-of-week 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。 ```json { @@ -1398,6 +1398,10 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "type": "string", "description": "Reminder content to present when the target becomes due." }, + "title": { + "type": "string", + "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists." + }, "after_seconds": { "type": "number", "description": "Positive safe-integer delay in seconds." @@ -1406,6 +1410,71 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "type": "number", "description": "Fixed-rate safe-integer interval in seconds, at least 300." }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "time", + "time_zone" + ] + }, + "weekly": { + "type": "object", + "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + }, + "weekdays": { + "type": "array", + "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "time", + "time_zone", + "weekdays" + ] + }, + "cron": { + "type": "object", + "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, "at": { "oneOf": [ { @@ -1436,7 +1505,8 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete } }, "required": [ - "prompt" + "prompt", + "title" ] } ``` @@ -1445,7 +1515,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_delete` -使用 schedule_create 或 schedule_list 返回的确切 id,删除当前会话中的一条活动提醒。未知或已经结束的 id 会返回 deleted false。 +使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。 ```json { @@ -1453,7 +1523,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "properties": { "id": { "type": "string", - "description": "Exact session-local schedule id." + "description": "Exact schedule id." } }, "required": [ @@ -1466,7 +1536,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_list` -按创建顺序列出当前会话中的所有活动提醒,包括确切 id、UTC 目标、scheduled 或 overdue 状态,以及 session-local 交付模式。 +列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。 ```json { @@ -1477,7 +1547,137 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete 来源:[`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) -仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 +选择启用的 Schedule 服务加载期间,在 live 根 Agent scope 内注册。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。 + + + +### `schedule_update` + +在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。 + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact schedule id that schedule_list returned for one reminder." + }, + "title": { + "type": "string", + "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + }, + "prompt": { + "type": "string", + "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "time", + "time_zone" + ] + }, + "weekly": { + "type": "object", + "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "time": { + "type": "string", + "description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + }, + "weekdays": { + "type": "array", + "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "time", + "time_zone", + "weekdays" + ] + }, + "cron": { + "type": "object", + "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, + "at": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "time": { + "type": "string" + }, + "time_zone": { + "type": "string" + } + }, + "required": [ + "date", + "time", + "time_zone" + ] + } + ], + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "id" + ] +} +``` + +Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) + +Registered in live root Agent scopes while the opt-in Schedule service is loaded. Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly local times in an explicit IANA zone, and cron as a five-field expression. Management uses the Host storage domain; due messages resume the original Session. diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 53d47f563d..a439c0fee0 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -2,6 +2,30 @@ # 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 docs/user/guide/schedule.md -/schedule-session-local-reminders: - en: 758fff68dae2823c - zh: eda1d8a529a97b5d +/schedule-reminders: + en: c3cb91d1d53b7603 + zh: 715ced3f1a2e9dd5 +/schedule-reminders/summary: + en: bc85e4ac935f2fd5 + zh: c39e54e4beaa3f9a +/schedule-reminders/table-of-contents: + en: a1159100bd5429cd + zh: 2ca8ff4462484376 +/schedule-reminders/enable-and-create-reminders: + en: bba6e40f1d266983 + zh: 82477fb6308e4789 +/schedule-reminders/inspect-and-delete-tasks: + en: a44fc1aabc2ab957 + zh: 31f444d4c7fd5c7a +/schedule-reminders/edit-an-active-task: + en: f28e54059d256ff0 + zh: 9e59ceef4cf2dd45 +/schedule-reminders/timing-and-delivery-reference: + en: 6e5aa8ae5906df4f + zh: afdf670a31fae2bb +/schedule-reminders/further-exploration: + en: b7eb797281f028c0 + zh: 3754f9d19127a3c5 +/schedule-reminders/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index fdb4899925..046279b0ac 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -1,21 +1,81 @@ -# Schedule session-local reminders +# Schedule reminders English | [中文](schedule.zh.md) -This overlay opts one `dsh web` process into Schedule reminders without changing the shipped default Web composition: +## Summary + +Create reminders in a conversation, then inspect active and inactive tasks and edit an active task's name, instruction, and run time from the Automation tasks page. Daily reminders follow a saved local time and time zone; fixed-rate reminders follow elapsed intervals. Delivered one-shot tasks remain available until you explicitly delete them. + +## Table of Contents + +- [Enable and create reminders](#create-reminders) +- [Inspect and delete tasks](#manage-tasks) +- [Edit an active task](#edit-timing) +- [Timing and delivery reference](#timing-and-delivery) +- [Further Exploration](#further-exploration) + + +## Enable and create reminders + +From a repository checkout, enable Schedule for one `dsh web` process with the explicit overlay. Configure a model provider before asking it to create reminders. ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -The current overlay supports reminders created with a positive whole-number `after_seconds`, an absolute `at` target, or a fixed-rate `every_seconds` interval of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`. +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 five minutes, 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. -With this overlay enabled, a successfully opened Session with active reminders shows a read-only catalog in the conversation header. It lists the complete prompt, scheduled or overdue status, one-time or exact repeating cadence, browser-local target time, and relative time. The sidebar also places a non-interactive alarm after the title of grouped, flat, and search rows when their currently available projection value is non-empty. These surfaces never create, edit, delete, or acknowledge reminders, and a cold Session's cached alarm can be briefly missing or stale. +For example, ask: “Remind me every day at 23:00 in Asia/Shanghai to check the weather.” Open Automation tasks and select the created reminder. Check that its frequency shows a daily rule and the requested zone, not Once. A one-time reminder does not become recurring after delivery. -The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target. + +## Inspect and delete tasks -The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders. +Open Automation tasks from the sidebar to view reminders across Sessions without activating their conversations. Search by the stored task name, the instruction, or the task's internal Session id, and filter by the single All, Enabled, or Inactive status row. An idle, unarchived Session row whose Session has at least one active task shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session row with active tasks lists up to two of those tasks with their frequency and their next run as a device-zone time with its relative duration. The open Session's header shows an icon-only reminder clock while active reminders exist: it opens the sole task's details directly, or a list of the Session's reminders otherwise. -Every reminders stay aligned to their creation time. If one is overdue, only its latest due occurrence is presented and the next target remains on the original fixed-rate sequence. All distinct Every records overdue at the same idle decision are combined into one follow-up with one occurrence each; missed intervals do not create a backlog. Due one-shots run before that batch. Calendar and Cron expressions are not supported. +Select a task to open Rules, then choose Delivery records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab. -Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. Schedule does not provide browser, operating-system, email, SMS, or other external notification. A durable dispatch records that the follow-up was queued; it does not acknowledge model success or user receipt. +An empty record view means a successful query returned no saved records. Loading, request failure, a missing task, and an invalid older-record cursor have separate messages. Use Retry delivery records after a failed request or missing-task response; use Refresh delivery records after an invalid cursor to reload the newest page. Previously loaded records may remain visible with a warning while a request fails. A new latest delivery refreshes the newest page. Late responses for a task or view you have left do not replace the current view. + +For tasks without saved history, only an existing latest receipt can be shown until new deliveries add records; later deliveries do not recover the missing history or clear its notice. Unsaved earlier records and instruction snapshots cannot be recovered, and the current instruction is not substituted for a missing saved instruction. The earlier-history notice appears only when the Host marks earlier records unavailable; new tasks with no deliveries show the empty state without that notice. + +Active tasks participate in scheduling. Inactive tasks do not schedule another delivery. A one-time task becomes inactive after its reminder is durably written to the conversation inbox; choose All tasks or Inactive to find it. The conversation-header catalog and model `schedule_list` show only active reminders, so disappearance from those views does not mean the retained task was deleted. + +The Linked session entry in the detail tab strip opens the original conversation when its metadata is ready and the Session is available and unarchived. Otherwise the entry is disabled with a reason. The task details and deletion action remain available even when its original Session cannot open. Browsing tasks does not unarchive Sessions. + +Choose Delete task and confirm to stop future delivery and remove the task from the list, including for an inactive task. Deletion removes the task together with its saved delivery records, so the task's saved history is gone. Confirming the deletion closes the panel or tab that asked for it and reports the outcome in one app-wide notice — deleted, or a failure that leaves the task in place to retry; it does not remove the original conversation or retract a message already queued there. The row stays visible until a successful query confirms the remaining tasks. If a query fails, Retry reloads the catalog; the error is not an empty successful result. + + +## Edit an active task + +In Automation tasks, select an active task and open Rules to edit its name, instruction, and run time. Inactive tasks are read-only, and edits stay in a local draft until you choose Save changes. Editing keeps the task id, original conversation, latest delivery receipt, and all saved delivery records. + +- Daily and Weekly: edit Time and Time zone; a weekly rule also toggles Weekday. A clock row shows whole seconds, an untouched row keeps a stored millisecond value for its submit, and the stored IANA zone is kept. A changed rule selects its first future occurrence using the DST rules below. +- Every: choose Every N hours, Every N minutes, or Every N seconds and enter the quantity in the unit the row states; the smallest accepted interval is 1 hour, 5 minutes, or 300 seconds for that unit, and the submitted interval is whole seconds. A changed interval starts from the Host's accepted-save time, with the first target one new interval later. Time zones do not affect this elapsed interval. +- One-shot After/At: edit separate Date and Time rows and Time zone. The initial zone is the record's stored zone, or this device's zone when the record stores none, and the seeded clock names the same stored instant; a one-shot rule that stored no zone shows one hint stating that the selected zone interprets the entered date and time. Changing the zone keeps those clock values and changes the instant. A changed target saves as At with the same task id. The zone is not retained as recurrence metadata. + +The rows change only a local draft. Save changes submits the name, instruction, and the complete rule in one update, and a save bar appears while the draft differs from the stored task; Cancel restores the stored values. The Repeat menu offers Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), and the Host accepts any of them, so a one-shot After may save as At. An equivalent normalized rule performs no write or target reset: the same Every interval does not restart its timing, and an unchanged one-shot target preserves After or At. The rows are disabled while a save is in flight. Leaving the card does not roll back a Host write already begun. + +A catalog refresh keeps the fields you have edited and takes every other field from the refreshed record, so a concurrent change to another field survives an unsaved edit. Invalid input is reported and blocks Save. If delivery or another edit changed the task, a conflict prevents overwriting that change; retry to edit the latest rule. An accepted update refreshes the catalog. A rejected or unconfirmed update is distinct from a catalog-loading error: use Retry to reload the catalog, not to repeat an acknowledged update. If the update itself cannot be confirmed, check the task before retrying. Late responses after closing details or switching tasks do not replace the current view. + + +## Timing and delivery reference + +Reminders remain stored when their Session is not open. While the Host runs, it restores the original Session when a reminder becomes due and queues a follow-up without interrupting current work. Restarting checks stored tasks and sends an overdue one-shot or only the latest missed occurrence of each recurring task. Closing the Host stops scheduling until it starts again. Due recurring tasks for the same Session can share one message. + +Fixed-rate reminders stay aligned to creation until their interval changes; a changed interval aligns to the accepted-save time. Daily reminders instead follow the saved local time and IANA zone. A daily local time or date that does not exist is skipped; an overlapping time uses the earlier instant once that day. An absolute-time request rejects a nonexistent local time and chooses the earlier instant during an overlap. The browser supplies its zone for interpreting natural-language requests; naming a zone explicitly avoids ambiguity. Task details keep a saved wall-clock rule's zone in its frequency text and show every next target as a device-zone clock; a one-shot task stores only the UTC instant, so its timestamps use the browser's zone. + +Saved delivery records confirm messages persisted in the conversation inbox, not that the model completed the requested work. Failed inbox persistence or task writes add no saved record. A crash or task-write failure after inbox persistence can leave a delivered message unrecorded and repeat a delivery; exactly-once delivery is not guaranteed. This is not an execution history or an external notification channel. + +Saved records are pruned when an acknowledgment is appended: the Host keeps the records whose acknowledgment time falls inside the configured `deliveryHistoryDays` window, measured back from the appended receipt's `deliveredAt`, and at most the newest `deliveryHistoryRecords` records. The latest receipt always survives, and a pruned window marks earlier records unavailable. The Host keeps the retained history in memory and rewrites the full task row for each acknowledgment. Pagination limits returned record count, not the retained history or instruction bytes. + +Pause and a new Session for each run are not supported. Name, instruction, and run-time editing is available to the model through `schedule_update`, which changes the reminder in place and keeps its id and saved delivery records, and to the Automation tasks page against the selected task's binding; neither is a cross-Session relay workflow. Reminders recorded only in an older Session log are not scheduled automatically; recreate the ones you still need. Previously deleted task records are not reconstructed. + + +## Further Exploration + +- [Schedule reference](../../../packages/schedule/schedule/README.md) documents exact timing selectors, persistence, and delivery limits. +- [Automation tasks page reference](../../../packages/client/ui-schedule/README.md) describes the global page and conversation-header catalog. + +## Dev Note + +None. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index d5d134ed7e..8c8a65af17 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -1,21 +1,81 @@ -# 安排会话内提醒 +# Schedule 提醒 [English](schedule.md) | 中文 -此 overlay 让一个 `dsh web` 进程显式启用 Schedule 提醒,同时不改变交付的默认 Web 组合: +## 概述 + +在对话中创建提醒,再从自动化任务页面查看活动和未运行任务,并修改活动任务的名称、指令和运行时间。每日提醒遵循保存的本地时间与时区;固定速率提醒遵循经过的时间间隔。已投递的一次性任务会保留到显式删除为止。 + +## 目录 + +- [启用并创建提醒](#create-reminders) +- [查看和删除任务](#manage-tasks) +- [修改活动任务](#edit-timing) +- [时间与投递参考](#timing-and-delivery) +- [延伸阅读](#further-exploration) + + +## 启用并创建提醒 + +在仓库检出目录中,通过显式 overlay 为一个 `dsh web` 进程启用 Schedule。在请求模型创建提醒前,先配置模型 provider。 ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -当前 overlay 支持使用正整数 `after_seconds`、绝对时间 `at` 目标,或至少 300 秒的固定速率 `every_seconds` 间隔创建提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。 +向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少五分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。 -启用此 overlay 后,成功打开且存在活动提醒的 Session 会在对话 header 中显示只读目录。目录列出完整 prompt、等待中或已逾期状态、单次或精确重复周期、浏览器本地目标时间与相对时间。侧边栏还会在 grouped、flat 与 search 行当前可用的 projection 值非空时,于标题后显示不可交互的闹钟。这些界面不会创建、编辑、删除或确认提醒;cold Session 的缓存闹钟允许短暂漏显或残留。 +例如,请求:“每天 Asia/Shanghai 时区 23:00 提醒我查看天气。”打开自动化任务页面并选择刚创建的提醒,检查频率显示的是每日规则和所需时区,而非仅一次。一次性提醒不会在投递后变成重复任务。 -浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 + +## 查看和删除任务 -每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 +从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务已存储的名称、指令或任务所属的内部 Session id 搜索,并用唯一的“全部/已开启/未运行”状态筛选行过滤。Session 行在其 Session 有至少一个活动任务、自身空闲且未归档时显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。悬停任一带活动任务的 Session 行时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在有活动提醒时显示仅图标时钟入口:只有一个任务时直接打开其详情,否则列出该 Session 的提醒。 -Every 提醒始终与其创建时刻对齐。如果提醒逾期,只会呈现最新一个到期发生时点,下一个目标仍保留在原固定速率序列上。同一次 idle 决策中逾期的所有不同 Every 记录会合并为一个 follow-up,每条记录各有一个发生时点;错过的间隔不会形成积压。已到期的一次性提醒会在该批次之前运行。不支持日历表达式和 Cron 表达式。 +选择任务打开“规则”,再选择“发送记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。 -创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知。持久 dispatch 会记录 follow-up 已经入队;它不确认模型成功或用户已收到提醒。 +记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试加载发送记录”;游标无效时,使用“刷新发送记录”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。 + +没有已保存历史的任务只能显示已有的最近一次回执,直到新投递增加记录;后续投递不会恢复缺失历史或清除相应提示。未保存的更早记录和指令快照无法恢复,不会用当前指令代替缺失的已保存指令。仅当宿主标记更早记录不可用时,才显示更早历史提示;尚未投递的新任务显示空态,不显示该提示。 + +活动任务参与调度;未运行任务不会再次安排投递。一次性任务在提醒持久写入对话收件箱后变为未运行;选择所有任务或未运行即可找到它。对话顶部目录和模型 `schedule_list` 仅显示活动提醒,因此从这些视图消失并不表示保留的任务已被删除。 + +详情页签条内的关联会话入口在元数据就绪、Session 可用且未归档时打开原对话;否则入口禁用并显示原因。即使原 Session 无法打开,任务详情与删除操作仍然可用。浏览任务不会取消 Session 归档。 + +选择删除任务并确认,可停止未来投递并将该任务从列表中移除,包括未运行任务。删除会移除该任务及其已保存的发送记录,因此该任务的已保存历史随之消失。确认删除会关闭发起删除的面板或页签,并在同一个应用级提示里报告结果——已删除,或失败(失败时任务保留、可重试);删除不会删除原对话,也不会撤回已进入队列的消息。该行会保留到成功查询确认剩余任务为止。查询失败时可通过重试重新加载目录;错误不会被视为查询成功的空结果。 + + +## 修改活动任务 + +在自动化任务页面选择活动任务,打开“规则”,即可修改其名称、指令和运行时间。未运行任务保持只读,修改先保存在本地草稿中,直到选择“保存更改”。编辑保留任务 id、原对话、最近一次投递回执以及全部已保存的发送记录。 + +- Daily 和 Weekly:修改“时间”和“时区”;每周规则还可切换“星期”。时钟行显示整秒,未改动的行在提交时保留已存储的毫秒值,已存储的 IANA 时区保持不变。规则改变后,按下文的夏令时规则选择首个未来时点。 +- Every:选择“每 N 小时”“每 N 分钟”或“每 N 秒”,并按行内标出的单位填写数量;该单位下的最小间隔为 1 小时、5 分钟或 300 秒,提交的间隔为整秒。新间隔从宿主接受保存的时间起算,首个目标为一个新间隔之后。时区不影响这个经过时间间隔。 +- 一次性 After/At:分别修改“日期”“时间”两行以及“时区”。初始时区取记录已存储的时区;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一已存储瞬时。未保存时区的一次性规则会显示一条提示,说明输入的日期时间按所选时区解释。更改时区会保留这些钟表值并改变时点。目标改变后以相同任务 id 保存为 At,不将时区保留为重复规则元数据。 + +各行只修改本地草稿。“保存更改”会以一次更新提交名称、指令和完整规则;草稿与已存储任务不同时显示保存栏,“取消”恢复已存储的值。“重复”菜单提供每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron),宿主接受其中任何一项,因此一次性 After 可保存为 At。规范化后等价的规则不写入或重设目标:相同的 Every 间隔不会重新起算,未变更的一次性目标保留 After 或 At。保存期间卡片各行禁用。离开卡片不会回滚宿主已开始的写入。 + +刷新目录会保留你已修改的字段,其余字段取自已刷新的记录,因此其他字段的并发变化不会被未保存的修改覆盖。输入无效时显示错误并阻止保存。若投递或其他编辑改变了任务,冲突会阻止覆盖该变化;请重试以修改最新规则。更新被接受后刷新目录。更新被拒绝或无法确认与目录加载错误相互区分:应使用“重试”重新加载目录,而不是重复已确认的更新。若更新本身无法确认,应在重试前检查任务。关闭详情或切换任务后才到达的响应不会替换当前视图。 + + +## 时间与投递参考 + +Session 未打开时,提醒仍然保存。宿主运行期间,在提醒到期时恢复原 Session 并排入 follow-up,不中断当前工作。重启后检查存储的任务,投递逾期的一次性提醒,或每条重复任务最近错过的一次。关闭宿主后,调度停止,直到宿主再次启动。同一 Session 的到期重复任务可以共用一条消息。 + +固定速率提醒在间隔改变前与创建时间对齐;间隔改变后与接受保存的时间对齐。每日提醒则遵循保存的本地时间与 IANA 时区。不存在的每日本地时间或日期会被跳过;重叠时间选择当天较早的一次。绝对时间请求会拒绝不存在的本地时间,并在重叠时选择较早时刻。浏览器提供其时区来解释自然语言请求;显式指定时区可以避免歧义。任务详情在频率文案里保留钟表规则保存的时区,并把每个下一目标显示为设备时区的钟点;一次性任务仅存储 UTC 时点,因此其时间戳使用浏览器时区。 + +已保存的发送记录确认消息已持久化到对话收件箱,不表示模型完成了所请求的工作。收件箱持久化或任务写入失败时,不增加已保存记录。收件箱持久化后崩溃或任务写入失败,可能留下未记入发送记录的已投递消息,并重复投递;不保证恰好投递一次。这不是执行历史,也不是外部通知渠道。 + +已保存的记录在追加确认时裁剪:宿主保留确认时间落在配置的 `deliveryHistoryDays` 窗口内的记录(窗口以本次追加回执的 `deliveredAt` 向前计算),且最多保留最新的 `deliveryHistoryRecords` 条。最近一次回执始终保留,发生裁剪时标记更早记录不可用。宿主将保留的历史保存在内存中,每次确认均重写完整任务行。分页限制返回的记录数,不限制保留的历史或指令字节数。 + +不支持暂停和每次运行新建 Session。名称、指令和运行时间的修改对模型开放:`schedule_update` 原地修改提醒并保留其 id 与已保存的投递记录;自动化任务页面也可按所选任务的绑定修改。两者都不是跨会话转交工作流。仅记录在旧 Session 日志中的提醒不会自动调度;请重新创建仍需要的提醒。此前已删除的任务记录不会被重建。 + + +## 延伸阅读 + +- [Schedule 参考](../../../packages/schedule/schedule/README.zh.md) 说明精确的时间选择器、持久化与投递限制。 +- [自动化任务页面参考](../../../packages/client/ui-schedule/README.zh.md) 说明全局页面与对话顶部目录。 + +## 开发备注 + +无。 diff --git a/package.json b/package.json index 5b1270b94c..8ea0f03ef2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index a68d0e7d56..d091e6d74c 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: 9602bc97f4c13c51 - zh: 267f84da67b8c6fb + en: 2818a70fa6bbb1b6 + zh: 800b34102e82c192 /packages/release-expectations: en: 1ab880da3642bde1 zh: 6e168322f026a9cf diff --git a/packages/README.md b/packages/README.md index 0ce8e92027..54c4cdc758 100644 --- a/packages/README.md +++ b/packages/README.md @@ -32,7 +32,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`api/`](api/README.md) | Remote BFF assembly and Typert RPC gateway | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | | [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | -| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | +| [`schedule/`](schedule/README.md) | Host-owned scheduled follow-ups | | [`feedback/`](feedback/README.md) | Human feedback capture and command | | [`identity/`](identity/README.md) | Shared anonymous identity | | [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters | diff --git a/packages/README.zh.md b/packages/README.zh.md index 8e0650d8f6..9ea4426082 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -32,7 +32,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`api/`](api/README.zh.md) | Remote BFF 装配与 Typert RPC 网关 | | [`typert/`](typert/README.zh.md) | 类型图生成、产物加载与运行时注册表 | | [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 | -| [`schedule/`](schedule/README.zh.md) | 仅限会话内的定时后续操作 | +| [`schedule/`](schedule/README.zh.md) | Host 拥有的定时后续操作 | | [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 | | [`identity/`](identity/README.zh.md) | 共享匿名身份 | | [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | diff --git a/packages/api/account-controller/README.i18n.yaml b/packages/api/account-controller/README.i18n.yaml index 8f05a9b6c5..352b915310 100644 --- a/packages/api/account-controller/README.i18n.yaml +++ b/packages/api/account-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 /deepseek-ai-dsh-api-account-controller/use-this-package: - en: 1cd6c99044a5e158 - zh: e8a73543a20bc596 + en: 5ad12300bbc151ef + zh: 3ccfb6046ab423ef /deepseek-ai-dsh-api-account-controller/understand-the-implementation: en: 3d899b3480391175 zh: 803808ca9691366e diff --git a/packages/api/account-controller/README.md b/packages/api/account-controller/README.md index 3cc6a7d9bb..dabbb13b52 100644 --- a/packages/api/account-controller/README.md +++ b/packages/api/account-controller/README.md @@ -20,7 +20,9 @@ Account screens use authenticated Remote commands and a snapshot stream. The con ## Use this package -The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. +The account namespace exposes getState, getProfile / getBalance, getUnnotifiedBonuses, ackBonusNotified, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. getUnnotifiedBonuses returns null and ackBonusNotified returns false while the account is absent or has changed; failures the caller should retry arrive as thrown Remote errors. + +Every operation that reaches Platform takes the calling UI's `AccountClientMetadata` — client version, active language, and UTC offset in seconds — so the Host reports the requesting UI rather than the last caller it saw. Cancellation and watch remain identity-free because they never reach Platform. `watchExpiry` delivers live credential-expiry notifications without an initial item or replay. Desktop uses this stream to hand off a one-shot toast when switching to Welcome. diff --git a/packages/api/account-controller/README.zh.md b/packages/api/account-controller/README.zh.md index 3656b38149..63c05a43ff 100644 --- a/packages/api/account-controller/README.zh.md +++ b/packages/api/account-controller/README.zh.md @@ -20,7 +20,9 @@ kind: "package-reference" ## 使用此包 -account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。 +account 命名空间提供 getState、getProfile / getBalance、getUnnotifiedBonuses、ackBonusNotified、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。账号缺失或已切换时 getUnnotifiedBonuses 返回 null、ackBonusNotified 返回 false;需要调用方重试的失败以抛出的 Remote 错误返回。 + +到达 Platform 的每个操作都接收调用界面的 `AccountClientMetadata`——客户端版本、当前语言和以秒为单位的 UTC 偏移——因此 Host 报告的是发起请求的界面,而不是它上一次见到的调用方。取消和 watch 不接该参数,因为它们不会到达 Platform。 `watchExpiry` 仅发送实时凭据失效通知,不发送初始值,也不重放历史通知。桌面端通过该流,在切换到 Welcome 时交接一次性 toast。 diff --git a/packages/api/account-controller/src/index.ts b/packages/api/account-controller/src/index.ts index f42e2f752a..ff065388b0 100644 --- a/packages/api/account-controller/src/index.ts +++ b/packages/api/account-controller/src/index.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { isRunningAccountTask } from '@deepseek-ai/dsh-deepseek-account' import type {} from '@deepseek-ai/dsh-agent' -import type { AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' +import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' import type { AccountView, SignInAttemptId } from './types.ts' /** Account commands and reconnect-safe state stream. */ @@ -19,25 +19,53 @@ export class AccountController extends TypertRemoteService { getState(): Promise { return this.ctx.deepseekAccount.getState() } /** * Query display-safe Platform profile data. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. * @returns profile outcome, or null when the account grant is absent or changed. */ @Remote - getProfile(): Promise { return this.ctx.deepseekAccount.getProfile() } + getProfile(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getProfile(client) + } /** * Query Platform recharge-wallet balances. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. * @returns balance outcome, or null when the account grant is absent or changed. */ @Remote - getBalance(): Promise { return this.ctx.deepseekAccount.getBalance() } + getBalance(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getBalance(client) + } + /** + * Query the granted bonuses Platform has not yet recorded as displayed. + * @param client - identity of the requesting UI; its language selects the server-authored message. + * @returns bonuses with their account, or null when the account grant is absent or changed. + */ + @Remote + getUnnotifiedBonuses(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getUnnotifiedBonuses(client) + } + /** + * Record one displayed bonus as notified for the account it belongs to. + * @param accountId - account the notification was read for. + * @param orderId - granted bonus order the user saw. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns true once Platform records the acknowledgement; false when the account is absent or changed. + */ + @Remote + ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.ackBonusNotified(accountId, orderId, client) + } /** * Begin browser sign-in. - * @param locale - active UI language for a new attempt. + * @param client - identity of the requesting UI, captured by a new attempt. * @param callbackOrigin - browser-accessible loopback HTTP origin. * @param loginSource - initiating UI, used to return from a failed exchange. * @returns a new or already-active login attempt. */ @Remote - startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { return this.ctx.deepseekAccount.startSignIn(locale, callbackOrigin, loginSource) } + startSignIn(client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { + return this.ctx.deepseekAccount.startSignIn(client, callbackOrigin, loginSource) + } /** * Cancel the named local attempt. * @param attemptId - attempt to cancel. @@ -55,10 +83,11 @@ export class AccountController extends TypertRemoteService { } /** * Remove the local account grant and revoke it through Platform in the background, without deleting API keys. + * @param client - identity of the requesting UI, captured for the background revocation retries. * @returns state after removing the local account grant. */ @Remote - signOut(): Promise { return this.ctx.deepseekAccount.signOut() } + signOut(client: AccountClientMetadata): Promise { return this.ctx.deepseekAccount.signOut(client) } /** * Subscribe to credential expiry without replaying prior notifications. * @param signal - stream lifetime. diff --git a/packages/api/account-controller/src/types.ts b/packages/api/account-controller/src/types.ts index 3c0455849e..3d105804f8 100644 --- a/packages/api/account-controller/src/types.ts +++ b/packages/api/account-controller/src/types.ts @@ -1,2 +1,2 @@ /** Account Remote values contain no credential payloads. */ -export type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +export type { AccountBonusBatch, AccountBonusNotification, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountProfile, AccountUserId, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' diff --git a/packages/api/account-controller/tests/controller.spec.ts b/packages/api/account-controller/tests/controller.spec.ts index d814e93ff7..aa253ca386 100644 --- a/packages/api/account-controller/tests/controller.spec.ts +++ b/packages/api/account-controller/tests/controller.spec.ts @@ -1,6 +1,7 @@ /** Account Remote delegation preserves provider results, failures, and stream lifetime. */ import { Context } from '@deepseek-ai/cordis' import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' import type { AccountView, SignInAttemptId } from '../src/types.ts' import { afterEach, expect, it, vi } from 'vitest' import AccountController from '../src/index.ts' @@ -8,6 +9,7 @@ import AccountController from '../src/index.ts' const roots: Context[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) const state: AccountView = { status: 'signed-out', links: { usageUrl: 'https://platform.example/usage', topUpUrl: 'https://platform.example/top_up' }, attempt: null } +const client: AccountClientMetadata = { version: '0.0.0-test', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } function fixture() { const ctx = new Context() @@ -16,6 +18,8 @@ function fixture() { getState: vi.fn().mockResolvedValue(state), getProfile: vi.fn().mockResolvedValue({ status: 'failed' }), getBalance: vi.fn().mockResolvedValue(null), + getUnnotifiedBonuses: vi.fn().mockResolvedValue(null), + ackBonusNotified: vi.fn().mockResolvedValue(false), startSignIn: vi.fn().mockResolvedValue(state), cancelSignIn: vi.fn().mockResolvedValue(state), signOut: vi.fn().mockResolvedValue(state), @@ -28,19 +32,36 @@ function fixture() { it('delegates account operations without coupling profile and balance queries', async () => { const { provider, controller } = fixture() expect(await controller.getState()).toBe(state) - expect(await controller.getProfile()).toEqual({ status: 'failed' }) + expect(await controller.getProfile(client)).toEqual({ status: 'failed' }) expect(provider.getBalance).not.toHaveBeenCalled() - expect(await controller.getBalance()).toBeNull() - expect(await controller.startSignIn('zh-CN', 'http://127.0.0.1:8080', 'desktop')).toBe(state) - expect(provider.startSignIn).toHaveBeenCalledExactlyOnceWith('zh-CN', 'http://127.0.0.1:8080', 'desktop') + expect(await controller.getBalance(client)).toBeNull() + expect(await controller.startSignIn(client, 'http://127.0.0.1:8080', 'desktop')).toBe(state) + expect(provider.startSignIn).toHaveBeenCalledExactlyOnceWith(client, 'http://127.0.0.1:8080', 'desktop') const id = 'attempt' as SignInAttemptId expect(await controller.cancelSignIn(id)).toBe(state) expect(provider.cancelSignIn).toHaveBeenCalledExactlyOnceWith(id) - expect(await controller.signOut()).toBe(state) - expect(provider.signOut).toHaveBeenCalledExactlyOnceWith() + expect(await controller.signOut(client)).toBe(state) + expect(provider.signOut).toHaveBeenCalledExactlyOnceWith(client) const failure = new Error('storage unavailable') provider.signOut.mockRejectedValueOnce(failure) - await expect(controller.signOut()).rejects.toBe(failure) + await expect(controller.signOut(client)).rejects.toBe(failure) +}) + +it('delegates bonus reads and acknowledgements without altering their values', async () => { + const { provider, controller } = fixture() + const accountId = 'account' as AccountUserId + const orderId = 'order' as AccountBonusOrderId + const batch: AccountBonusBatch = { accountId, bonuses: [{ orderId, campaign: 'dsh_login_bonus', amount: '5.00', + currency: 'CNY', grantedAt: '2026-09-21T12:00:00Z', expiresAt: '2026-10-21T12:00:00Z', message: '已赠送您 5.00 元 DSH 体验赠金。' }] } + provider.getUnnotifiedBonuses.mockResolvedValueOnce(batch) + expect(await controller.getUnnotifiedBonuses(client)).toBe(batch) + expect(provider.getUnnotifiedBonuses).toHaveBeenCalledExactlyOnceWith(client) + provider.ackBonusNotified.mockResolvedValueOnce(true) + expect(await controller.ackBonusNotified(accountId, orderId, client)).toBe(true) + expect(provider.ackBonusNotified).toHaveBeenCalledExactlyOnceWith(accountId, orderId, client) + const failure = new Error('unavailable') + provider.getUnnotifiedBonuses.mockRejectedValueOnce(failure) + await expect(controller.getUnnotifiedBonuses(client)).rejects.toBe(failure) }) it('passes the subscriber lifetime to the provider and returns its state stream', async () => { diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 850d57540a..16ee438803 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,11 +15,11 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: a6229be20242889b - zh: 8d270ab676d44e21 + en: b85e651d75ad3930 + zh: 586ab15f54aefed6 /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: 3a6f03172f88e970 - zh: 23cad6f60df8e4f2 + en: d6d82bf0e55ae5e9 + zh: c3a0c80f8b115039 /deepseek-ai-dsh-api-remotes/build-boundary: en: 7a9934a3ba5b185f zh: 40457fd5707f57f0 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 493418fbce..78a8d8fc06 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -27,7 +27,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. -The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). @@ -44,7 +44,7 @@ The listener signature is not restated here. Each allowlisted event's Cordis `Ev The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. -The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. +The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 69190cf2ce..cd09dbb7a3 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包只选择并挂载生成的会话 contribution,不复制激活策略。 -Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 @@ -44,7 +44,7 @@ Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 -无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 +无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a201c15042..54ec56c0fa 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-scope": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", @@ -67,6 +66,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-settings-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", @@ -77,20 +77,16 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", - "@deepseek-ai/dsh-client-connection": "workspace:*", - "@deepseek-ai/dsh-client-file-upload": "workspace:*", - "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-api-session-controller": "workspace:*", - "@deepseek-ai/dsh-api-workspace-files": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", "zod": "^4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index a00689e949..7262b843a7 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -7,6 +7,7 @@ import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import scheduleRemote from '@deepseek-ai/dsh-schedule/remote' import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginManagerRemote from '@deepseek-ai/dsh-plugin-manager/remote' @@ -40,6 +41,7 @@ export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-api-account-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-schedule/remote' export type {} from '@deepseek-ai/dsh-office-to-pdf/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' @@ -173,7 +175,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote, pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index daee61cfe8..f57a5c6481 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' +import type {} from '@deepseek-ai/dsh-schedule/client' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' /** @@ -42,5 +43,6 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'plugin-manager/install-log', mode: 'emit' }, { event: 'plugin-manager/install-state', mode: 'emit' }, { event: 'settings/document-updated', mode: 'emit' }, + { event: 'schedule/changed', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 8539b44058..80841acf6d 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -123,6 +123,16 @@ describe('Remote event Host source', () => { }, }) + emitRaw(ctx, 'schedule/changed', []) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'schedule/changed', args: [] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'schedule/changed', args: [] }, + }) + const firstDone = first.next() firstAbort.abort(new Error('first Client disconnected')) emitRaw(ctx, 'commands/change', []) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 55407602bc..2e74f687da 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -38,6 +38,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../schedule/schedule" + }, { "path": "../../feedback/message-feedback" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 9357ca3a8e..5a93826f6d 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -35,6 +35,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../schedule/schedule" + }, { "path": "../../preset/agent-preset-registry" }, diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index 9029bf26b2..6df6cd44a3 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -18,8 +18,8 @@ en: aa0d93826ab685fe zh: 144e48d49020f5a4 /workspace-controller/use-this-package/first-use-workspace: - en: 5d7a5ff08c15aec5 - zh: 7668ed81af0d70d8 + en: 61032e060e7213f0 + zh: 437eb982a8738539 /workspace-controller/model-experience: en: 4f399d6a27daf683 zh: 13811489fe636127 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index b1ca974656..ab96bb0114 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -29,9 +29,11 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream ### First-use Workspace -`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Workspace Client](../../client/ui-workspace/README.md) resolves these names from its startup language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. +`workspace.initializeDefault()` returns the durable default Workspace; the Client service exposes it as `workspaces.initializeDefault(signal?)`. It takes no request: the Host owns the fixed `default-workspace` directory name, and the registry derives the initial title from that same segment, so one installation keeps one on-disk path and one stored title in every language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. OS filename restrictions apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. -The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Invalid names reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. +The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup and is never renamed or relocated. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. + +`DEFAULT_WORKSPACE_DIRECTORY` and `workspaceDisplayTitle(title, localizedDefault)` are published from `./default-workspace` for browser consumers: a Workspace still carrying the automatic title reads as the reader's localized default name, and every other title reads verbatim. A Workspace the user renamed to exactly `default-workspace` — or a folder of that name adopted from the picker — is labeled as the default; nothing else depends on the distinction. | Configuration | Default | Purpose | | --- | --- | --- | diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 7f7dbd49c0..bcf713306e 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -29,9 +29,11 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 ### 首次使用工作区 -`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Workspace Client](../../client/ui-workspace/README.zh.md)按启动时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 +`workspace.initializeDefault()` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(signal?)` 提供该操作。它不接受请求参数:固定目录名 `default-workspace` 由 Host 拥有,注册表也以同一路径片段作为初始标题,因此任何语言下同一安装环境都只有一个磁盘路径和一个存储标题。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 -[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。名称无效时以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 +[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents,也不会被重命名或迁移。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 + +`./default-workspace` 为浏览器消费方导出 `DEFAULT_WORKSPACE_DIRECTORY` 与 `workspaceDisplayTitle(title, localizedDefault)`:仍保留自动标题的工作区按读者语言显示默认名称,其他标题一律原样显示。被用户重命名为 `default-workspace` 的工作区,或从选择器采用的同名文件夹,也会按默认工作区显示;除显示之外没有其他行为依赖该判断。 | 配置 | 默认值 | 用途 | | --- | --- | --- | diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 7ac0b5c176..7e0e1194f3 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -26,6 +26,10 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./default-workspace": { + "types": "./lib/types/default-workspace.d.ts", + "default": "./lib/types/default-workspace.js" + }, "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 84a469ec07..c3182102a7 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -19,7 +19,7 @@ export { WorkspaceArchiveError, WorkspaceController, WorkspaceCreateError } from export type { IWorkspaces, WorkspaceSource } from './service.ts' export type { SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap, WorkspaceId, - WorkspaceInitializeDefaultRequest, WorkspaceView, + WorkspaceView, } from '../types.ts' type WorkspaceBaselineFrame = Extract diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 0e2a247de9..779ffb1616 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -12,7 +12,6 @@ import type { WorkspaceCreateValue, WorkspaceDeleteValue, WorkspaceInsertSessionBeforeRequest, - WorkspaceInitializeDefaultRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, @@ -104,14 +103,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { /** * Initialize the default Workspace and merge its authoritative row. - * @param request - initial directory name and title. * @param signal - caller lifetime. * @returns generated Remote result. */ - async initializeDefault( - request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal, - ): Promise> { - const result = await this.remote.initializeDefault(request, signal) + async initializeDefault(signal?: AbortSignal): Promise> { + const result = await this.remote.initializeDefault(signal) if (result.ok && result.value !== undefined) this.upsert(result.value.workspace) return result } diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 21dd9627da..637cc4e57a 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -4,7 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' +import type { WorkspaceView } from '../types.ts' import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' /** Structured create failure for callers that distinguish Host business errors. */ @@ -55,11 +55,10 @@ export interface IWorkspaces { create(input: { path: string }): Promise /** * Initialize or reuse the default Workspace. - * @param request - initial directory name and title. * @param signal - caller lifetime. * @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure. */ - initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise + initializeDefault(signal?: AbortSignal): Promise /** * Rename a Workspace. * @param workspaceId - target Workspace. @@ -134,8 +133,8 @@ export class WorkspaceController extends Service implements IWorkspaces { return result.value.workspace } - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { - const result = await this.model.initializeDefault(request, signal) + async initializeDefault(signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(signal) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value?.workspace } diff --git a/packages/api/workspace-controller/src/default-directory.ts b/packages/api/workspace-controller/src/default-directory.ts index 2001b903c6..2bb3de438c 100644 --- a/packages/api/workspace-controller/src/default-directory.ts +++ b/packages/api/workspace-controller/src/default-directory.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { DEFAULT_WORKSPACE_DIRECTORY } from './default-workspace.ts' /** Platform observations replaceable in directory-resolution tests. */ interface DocumentsDirectoryInternals { @@ -28,14 +29,12 @@ export function validateDocumentsDirectory(directory: string, platform: NodeJS.P /** * Resolve the first-use directory on the Host without creating files. - * @param directoryName - validated single directory name supplied by the Client. * @param documentsDirectory - explicit deployment override for the system Documents directory. * @param signal - caller lifetime and lookup deadline. * @param internals - platform facts and native command runner. * @returns the absolute candidate path. */ export async function defaultWorkspaceDirectory( - directoryName: string, documentsDirectory: string | undefined, signal: AbortSignal, internals: DocumentsDirectoryInternals = {}, @@ -75,5 +74,5 @@ export async function defaultWorkspaceDirectory( } directory = validateDocumentsDirectory(directory, platform) signal.throwIfAborted() - return paths.join(directory, 'deepseek-harness', directoryName) + return paths.join(directory, 'deepseek-harness', DEFAULT_WORKSPACE_DIRECTORY) } diff --git a/packages/api/workspace-controller/src/default-workspace.ts b/packages/api/workspace-controller/src/default-workspace.ts new file mode 100644 index 0000000000..de7112aa28 --- /dev/null +++ b/packages/api/workspace-controller/src/default-workspace.ts @@ -0,0 +1,30 @@ +/** + * Fixed first-use Workspace naming, shared by the Host that creates the + * directory and by browser consumers that label the resulting row. A pure fold + * with no imports, so client bundles inline it instead of requesting a + * module-table row this package does not publish. + * @module @deepseek-ai/dsh-api-workspace-controller/default-workspace + */ + +/** + * Leaf directory name of the first-use Workspace under + * `/deepseek-harness`. Language-neutral, so one installation keeps + * one on-disk path across language switches. The registry derives the initial + * title from this same segment, which is the title + * {@link workspaceDisplayTitle} recognizes as automatic. + */ +export const DEFAULT_WORKSPACE_DIRECTORY = 'default-workspace' + +/** + * The text one Workspace is labeled with. A Workspace still carrying the + * automatic first-use title reads as the caller's localized default name; every + * other title reads verbatim in every language. A title the user typed as + * exactly {@link DEFAULT_WORKSPACE_DIRECTORY} is labeled as the default too; + * nothing else depends on the distinction. + * @param title - stored Workspace title. + * @param localizedDefault - the default Workspace name in the active language. + * @returns the title to display. + */ +export function workspaceDisplayTitle(title: string, localizedDefault: string): string { + return title === DEFAULT_WORKSPACE_DIRECTORY ? localizedDefault : title +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 928f52f883..0b23517921 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed, workspaceView } from './feed.ts' @@ -16,7 +16,6 @@ import type { WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, - WorkspaceInitializeDefaultRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, @@ -89,24 +88,20 @@ export class WorkspaceController extends TypertRemoteService { } /** - * Initialize or reuse the default Workspace during first-use startup. - * @param request - initial directory name and title; never rename an existing default. + * Initialize or reuse the default Workspace during first-use startup. The + * directory name is fixed, so the Host never renames or relocates an + * existing default; its initial title is that same name, which browser + * consumers label in the reader's language. * @param signal - caller lifetime; cancels native directory lookup. * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. */ @Remote('initializeDefault') - async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { - const { directoryName, title } = request - if (directoryName.trim() === '' || directoryName !== directoryName.trim() - || directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') { - throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) - } + async initializeDefault(signal: AbortSignal): Promise { const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs) - const path = await defaultWorkspaceDirectory( - directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), + return await defaultWorkspaceDirectory( + this.config.documentsDirectory, AbortSignal.any([signal, timeout]), ) - return { path, title } }) return workspace === undefined ? undefined : { workspace: workspaceView(workspace) } } diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index ffaef0070f..d5b98746be 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -66,14 +66,6 @@ export interface WorkspaceCreateRequest { readonly path: string } -/** Names chosen by the first-use Client before default Workspace initialization. */ -export interface WorkspaceInitializeDefaultRequest { - /** Host rejects blank names, separators, colon, NUL, surrounding whitespace, and trailing dots; OS filename restrictions also apply. */ - readonly directoryName: string - /** Initial display title, retained across language changes. */ - readonly title: string -} - /** Created or previously registered Workspace. */ export interface WorkspaceCreateValue { readonly workspace: WorkspaceView diff --git a/packages/api/workspace-controller/tests/default-directory.host.spec.ts b/packages/api/workspace-controller/tests/default-directory.host.spec.ts index 5878f42432..d44e74dafa 100644 --- a/packages/api/workspace-controller/tests/default-directory.host.spec.ts +++ b/packages/api/workspace-controller/tests/default-directory.host.spec.ts @@ -4,36 +4,36 @@ import { defaultWorkspaceDirectory, validateDocumentsDirectory } from '../src/de describe('system Documents directory', () => { it.each([ - ['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/默认工作区', 'osascript'], - ['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\默认工作区', 'powershell.exe'], - ['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/默认工作区', 'xdg-user-dir'], + ['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/default-workspace', 'osascript'], + ['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\default-workspace', 'powershell.exe'], + ['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/default-workspace', 'xdg-user-dir'], ] as const)('uses the %s account directory and preserves spaces and Unicode', async (platform, stdout, path, command) => { const run = vi.fn(async () => ({ stdout, stderr: '' })) const signal = new AbortController().signal - await expect(defaultWorkspaceDirectory('默认工作区', undefined, signal, { platform, run })).resolves.toBe(path) + await expect(defaultWorkspaceDirectory(undefined, signal, { platform, run })).resolves.toBe(path) expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal) }) - it.each(['Default workspace', '默认工作区', 'default-workspace'])('uses the configured directory with name %s', async (folder) => { + it('uses the configured directory without a system lookup', async () => { const run = vi.fn() - await expect(defaultWorkspaceDirectory(folder, '/documents', new AbortController().signal, { platform: 'linux', run })) - .resolves.toBe(`/documents/deepseek-harness/${folder}`) + await expect(defaultWorkspaceDirectory('/documents', new AbortController().signal, { platform: 'linux', run })) + .resolves.toBe('/documents/deepseek-harness/default-workspace') expect(run).not.toHaveBeenCalled() }) it.each(['', '\r\n', '/home/a\n'])('rejects an unavailable XDG directory %j', async (stdout) => { const run: NativeCommandRunner = async () => ({ stdout, stderr: '' }) - await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run })) + await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run })) .rejects.toThrow('unavailable') }) it('propagates lookup failure and cancellation', async () => { const run: NativeCommandRunner = async () => { throw new Error('lookup denied') } - await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'darwin', run })) + await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'darwin', run })) .rejects.toThrow('lookup denied') - await expect(defaultWorkspaceDirectory('Default workspace', '/documents', AbortSignal.abort(), { platform: 'linux' })) + await expect(defaultWorkspaceDirectory('/documents', AbortSignal.abort(), { platform: 'linux' })) .rejects.toThrow() - await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'freebsd' })) + await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'freebsd' })) .rejects.toThrow('unavailable') }) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index ade48b44b0..9660b2ead8 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -176,18 +176,18 @@ describe('ClientWorkspaceModel', () => { const model = modelFor(remote) baseline(model) const signal = new AbortController().signal - await expect(model.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).resolves.toMatchObject({ ok: true }) - expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal) + await expect(model.initializeDefault(signal)).resolves.toMatchObject({ ok: true }) + expect(remote.initializeDefault).toHaveBeenCalledWith(signal) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default']) const before = model.getSnapshot() remote.initializeDefault.mockResolvedValueOnce(remoteOk(undefined)) - await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) + await expect(model.initializeDefault()) .resolves.toEqual({ ok: true, value: undefined }) expect(model.getSnapshot()).toBe(before) remote.initializeDefault.mockResolvedValueOnce(workspaceError( new RemoteError('gateway/bad-request', 'choose a folder', {}), )) - await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).resolves.toMatchObject({ ok: false }) + await expect(model.initializeDefault()).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot()).toBe(before) }) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 8d93e56851..b74ddfb394 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -318,8 +318,7 @@ describe('WorkspaceController', () => { const controller = new WorkspaceController(client.ctx, model) const before = model.getSnapshot() mock.remote.workspace.initializeDefault.mockResolvedValueOnce({ ok: true, value: undefined }) - await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) - .resolves.toBeUndefined() + await expect(controller.initializeDefault()).resolves.toBeUndefined() expect(model.getSnapshot()).toBe(before) }) @@ -331,7 +330,7 @@ describe('WorkspaceController', () => { expect(controller.list).toBe(model) expect(client.ctx.workspaces.list).toBe(model) - await expect(controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' }) + await expect(controller.initializeDefault(new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' }) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -344,7 +343,6 @@ describe('WorkspaceController', () => { await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() // Each command crosses the wire as one positional request object. - expect(mock.log.requests('workspace/initializeDefault')).toEqual([{ directoryName: '默认工作区', title: '默认工作区' }]) expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) @@ -364,7 +362,7 @@ describe('WorkspaceController', () => { const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) mock.remote.workspace.initializeDefault.mockResolvedValueOnce(err(new RemoteError('gateway/internal', 'directory denied', {}))) - await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).rejects.toBeInstanceOf(WorkspaceCreateError) + await expect(controller.initializeDefault()).rejects.toBeInstanceOf(WorkspaceCreateError) mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 95cbffb3f9..35158490f2 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -10,6 +10,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import WorkspaceController from '../src/index.ts' +import { DEFAULT_WORKSPACE_DIRECTORY } from '../src/default-workspace.ts' import { WorkspaceFeed } from '../src/feed.ts' import type { WorkspaceFollowFrame } from '../src/types.ts' import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' @@ -450,53 +451,30 @@ describe('WorkspaceController follow', () => { describe('first-use Remote', () => { it('reuses an initialized Workspace without looking up system Documents', async () => { const { controller, ctx, root } = await harness({ systemDocuments: true }) - const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' })) + const workspace = await ctx.workspaceRegistry.initializeDefault(async () => root) const signal = AbortSignal.abort() - await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)) - .resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } }) + await expect(controller.initializeDefault(signal)) + .resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: basename(root) } }) }) - it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( - 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { - const { controller, ctx } = await harness() - const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) - await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) - expect(initialize).not.toHaveBeenCalled() - }, - ) - - it('rejects a blank initial title before invoking the registry', async () => { - const { controller, ctx } = await harness() - const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) - await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) - expect(initialize).not.toHaveBeenCalled() - }) - - it('uses the requested display title independently of the directory name', async () => { - const { controller, root } = await harness() - const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal) - expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) - }) - - it('returns a durable Workspace without allocating a Session', async () => { + it('returns a durable Workspace named after its fixed directory without allocating a Session', async () => { const { controller, ctx, root } = await harness() const signal = new AbortController().signal - const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal) - expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) + const result = await controller.initializeDefault(signal) + expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', DEFAULT_WORKSPACE_DIRECTORY)) + expect(result!.workspace.title).toBe(DEFAULT_WORKSPACE_DIRECTORY) expect(existsSync(result!.workspace.path)).toBe(true) expect(ctx.sessions.list()).toEqual([]) - expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result) + expect(await controller.initializeDefault(signal)).toEqual(result) }) it('skips ineligible first use and propagates preparation failures', async () => { const { controller, ctx, root } = await harness() await ctx.workspaceRegistry.create(root) - await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + await expect(controller.initializeDefault(new AbortController().signal)) .resolves.toBeUndefined() vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied')) - await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + await expect(controller.initializeDefault(new AbortController().signal)) .rejects.toThrow('permission denied') }) }) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index 9979b769f6..b7174afc34 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -9,6 +9,7 @@ "src/client/index.ts", "src/client/model.ts", "src/client/service.ts", + "src/default-workspace.ts", "src/types.ts" ], "references": [ diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 17fda7ee38..ff1ca31080 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -9,6 +9,7 @@ "src/index.ts", "src/types.ts", "src/commands.ts", + "src/default-workspace.ts", "src/directory-picker.ts", "src/feed.ts", "src/default-directory.ts" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5ed86cceea..72e5f00004 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -220,6 +220,12 @@ - id: locale name: '@deepseek-ai/dsh-client-locale' + - id: shortcuts + name: '@deepseek-ai/dsh-client-shortcuts' + + - id: ui-shortcuts + name: '@deepseek-ai/dsh-client-ui-shortcuts' + - id: ui-layout name: '@deepseek-ai/dsh-client-ui-layout' @@ -346,9 +352,9 @@ - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' - # Read-only active Schedule catalog. The shipped Web graph resolves the - # client package but leaves it disabled; the explicit Schedule overlay - # enables this same row together with the host Schedule services. + # Task management and Session reminder catalog. The shipped Web graph + # resolves the client package but leaves it disabled; the explicit Schedule + # overlay enables this same row together with the Host Schedule services. - id: ui-schedule name: '@deepseek-ai/dsh-client-ui-schedule' disabled: true diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f12df13ed6..d3198e0a6e 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -172,7 +172,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-account-controller": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index bedab0a674..30722e5cf9 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: 107e62fc362740c8 - zh: b1361ab4ed38541a + en: 2a964f7daf3586d5 + zh: 726fe18e86157978 /client-web-gui-browser-half/related-documentation: en: 598d07b07449128f zh: e8c40708ac0e603e diff --git a/packages/client/README.md b/packages/client/README.md index 8a5f789a4e..a7e4228fac 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | | [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.md) | Registers application keyboard commands | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.md) | Presents the keyboard shortcut reference | — | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ae5dbb237f..d5c33dc99d 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,8 @@ kind: "package-group" | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | | [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.zh.md) | 注册应用键盘命令 | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.zh.md) | 展示快捷键速查 | — | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与快照存储原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index d21b7d67f7..b649bbc3dc 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,6 +34,7 @@ export const en = { 'expand': 'Expand', 'back': 'Back', 'brand.localBuild': 'DSH Local Build', + 'workspace.defaultName': 'Default workspace', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index d7b215babd..11ff789b8a 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,6 +32,7 @@ export const zh = { 'expand': '展开', 'back': '返回', 'brand.localBuild': 'DSH 本地构建', + 'workspace.defaultName': '默认工作区', 'unknown': '未知', 'none': '无', 'truncated': '已截断', diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml new file mode 100644 index 0000000000..60cc67c635 --- /dev/null +++ b/packages/client/shortcuts/README.i18n.yaml @@ -0,0 +1,37 @@ +# 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/client/shortcuts/README.md +/: + en: 7cd394124c35afae + zh: 087929192244cb41 +/deepseek-ai-dsh-client-shortcuts: + en: 1376e970ef97cf99 + zh: 2960026628cae868 +/deepseek-ai-dsh-client-shortcuts/summary: + en: 24e6f7d87be069fd + zh: ab228fd8c4f8c43e +/deepseek-ai-dsh-client-shortcuts/table-of-contents: + en: d152484eb41ac6b4 + zh: 0cd57b7b8d8c9b59 +/deepseek-ai-dsh-client-shortcuts/use-this-package: + en: 8df3db7ca8cc6110 + zh: e872f6333ead59c4 +/deepseek-ai-dsh-client-shortcuts/understand-the-implementation: + en: adfd258f490cacb1 + zh: 44c080babc54c6a9 +/deepseek-ai-dsh-client-shortcuts/further-exploration: + en: 8fe02ef5221e434d + zh: 3aabfcfeb6d49a0e +/deepseek-ai-dsh-client-shortcuts/model-experience: + en: 3120df665299c634 + zh: e2dc75ed76d271bc +/deepseek-ai-dsh-client-shortcuts/model-experience/kv-cache-effect: + en: ca75c51c89c2c9b0 + zh: a0e9f8d52b4b62ee +/deepseek-ai-dsh-client-shortcuts/known-limitations-and-deferred-work: + en: 1fe209aad4915c64 + zh: 078d33d3ca9e05ea +/deepseek-ai-dsh-client-shortcuts/known-limitations-and-deferred-work/dev-note: + en: 09894c06f9d76ac0 + zh: d166e1338ae660e4 diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md new file mode 100644 index 0000000000..0fbe59a53c --- /dev/null +++ b/packages/client/shortcuts/README.md @@ -0,0 +1,94 @@ +--- +description: "Customize application keyboard commands for each device" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +English | [中文](README.zh.md) + +## Summary + +Customize application keyboard commands for each device. Desktop and Web choose separate defaults for the receiving device. Custom bindings survive reloads on the same device. Commands disappear when their owning plugin unloads, while their saved overrides remain available for reinstallation. + +Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneous ordinary keys appear side by side, such as `F G`, on Windows and macOS Desktop, including tooltips and the shortcut reference. + +## 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 + +The Web app bundle mounts this package automatically. Feature plugins register commands through `ctx.shortcuts` inside `ctx.effect()`. Each command declares defaults for explicit `desktop:macos`, `desktop:windows`, `desktop:linux`, `web:macos`, `web:windows`, and `web:linux` profiles; an omitted profile is unbound. The service accepts physical `code` values, expands `primary` to the device modifier, and rejects duplicate ids and overlapping default combinations across supported profiles. Labels, keycaps, modified flags, and binding conflicts share one observable catalog. Command owners resolve the current target before executing an action. + +Feature plugins contribute read-only sequences through `registerFixed()` and observe locally arbitrated input through `observeFixedInput()`. Fixed rows follow the owning registration and locale; owners may use the `application` display group without making the action editable. Each action declares at least one physical combination and has no saved overrides. These combinations participate in conflict checks and cannot be assigned to editable commands. The Host's `stopSequenceMs` setting controls the maximum double-Escape interval, defaults to 500 milliseconds, and accepts integers from 1 to 2,147,483,646 so expiry stays within the browser timer limit; pages adopt the validated value on load. + +Preferences store only overrides: a missing command inherits its default, `null` clears its binding, and restoring a default removes its override. Restore All affects only the current runtime/platform profile. Web uses origin-local `dsh.keybindings.v1` storage; Desktop uses Electron's device-local `userData/keybindings.json`, independently of Harness home and workspace settings. The schema version is independent of Session data. macOS and Windows Desktop read version 1 without rewriting it and save version 2 on the next successful edit; version 2 adds optional `secondCode`. Web and Linux retain their existing binding restrictions. + +Windows and macOS Desktop accept one or two distinct supported non-modifier keys, with zero to four modifiers. Two-key bindings require overlapping presses in either order; sequential presses do not match. Modifier-only bindings are rejected. A single key and a pair containing that key conflict when their modifiers match, including keys reserved by fixed actions. Previously saved bindings that overlap a mounted fixed action remain visible but cannot execute. All effective bindings, including Command+C and Ctrl+C, take priority over native actions, editors, terminals, embedded pages, and modal controls. Recording and input-method composition remain protected. A pair takes priority only once both keys are held; the first key retains its normal behavior and may insert a character. + +Bindings activate after the first read. Failed reads preserve the last accepted configuration, or use defaults when none exists. Edits, including Restore All, remain disabled while preferences are unreadable. Repair the stored document and reopen the application; reset operations never replace unreadable or future-version data. Write failures preserve active bindings and drafts. Another browser tab's update invalidates open drafts; saving requires reviewing the latest configuration. Browser writes reread first, but simultaneous cross-tab writes remain last-writer-wins. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host build emits the Node entry and `./protocol` before Desktop bundles its keyboard integration; the Client pass emits the browser bundle. + +Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. `ctx.shortcuts.closeWindow()` owns the Desktop keyboard bridge and sends its current accepted revision; Web calls and bridge request failures reject. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller. + +DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input. + +Desktop native menus and embedded frames use the same command registry as DOM input. Preload and the client match the focused iframe name or browser webview lease against native input; the client rejects messages from an older configuration. Browser guest input ends when its lease is released, before guest destruction finishes. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. + +
+ +----- + + +## Further Exploration + +- [Web Client](../../../docs/subsystems/web-client.md) +- [UI primitives](../ui-primitives/README.md) +- [Web app bundle](../../bundle/web-app/README.md) + +----- + + +## Model Experience + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default to preserve browser-specific Command+Option+R actions; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The catalog and binding index are derived together from the private registry; there is no independently maintained runtime relationship to inspect. diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md new file mode 100644 index 0000000000..5b5e19bbdf --- /dev/null +++ b/packages/client/shortcuts/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "为每台设备自定义应用键盘命令" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +[English](README.md) | 中文 + +## 概述 + +为每台设备自定义应用键盘命令。桌面端和 Web 按接收输入的设备选择不同默认键位。自定义键位在同一设备重新加载后保留。命令随所属插件卸载而移除,保存的覆盖仍保留供重新安装使用。 + +Windows 在修饰键之间及第一个普通键之前使用 ` + `。Windows 和 macOS Desktop 上同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。每个命令为 `desktop:macos`、`desktop:windows`、`desktop:linux`、`web:macos`、`web:windows` 和 `web:linux` 显式声明默认值;省略的配置不绑定按键。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持配置中重叠的默认键位。同一可观察目录提供名称、键帽、已修改标记和键位冲突。命令 owner 在执行操作前解析当前目标。 + +功能插件通过 `registerFixed()` 贡献只读序列,通过 `observeFixedInput()` 观察局部控件处理后的输入。固定操作行随所属注册和语言变化;owner 可使用 `application` 展示分组,操作仍不可编辑。每个操作至少声明一个物理按键组合,不保存覆盖配置。这些组合参与冲突校验,不能分配给可编辑命令。Host 的 `stopSequenceMs` 配置控制连续两次 Esc 的最大间隔,默认为 500 毫秒,只接受 1 至 2,147,483,646 的整数,确保过期计时不超过浏览器定时器上限;页面加载时采用已校验的值。 + +偏好只保存覆盖:缺少命令表示继承默认,`null` 表示清除绑定,恢复默认则删除覆盖。全部恢复只影响当前运行端/平台配置。Web 使用同 origin 的 `dsh.keybindings.v1` 存储;Desktop 使用 Electron 设备本地的 `userData/keybindings.json`,与 Harness home 和工作区设置独立。其 schema 版本与 Session 数据无关。 macOS 和 Windows Desktop 读取版本 1 时不重写,在下次成功编辑时保存为版本 2;版本 2 增加可选的 `secondCode`。Web 和 Linux 保留原有绑定限制。 + +Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可搭配零至四个修饰键。双键绑定要求按住时间重叠,顺序不限;先后按键不匹配。仅含修饰键的绑定会被拒绝。同一修饰键集合下,单键与包含该键的双键组合冲突,包括固定操作占用的按键。已保存的绑定若与当前挂载的固定操作冲突,会保留显示但不能执行。包括 Command+C 和 Ctrl+C 在内的所有有效绑定优先于原生操作、编辑器、终端、内嵌页面和模态控件。录制和输入法组合状态仍受保护。只有两个键都按住时,双键组合才优先接管;第一个键仍正常处理,可能输入一个字符。 + +首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。偏好不可读时,编辑和全部恢复均保持禁用。修复存储文档后重新打开应用;重置操作不会替换不可读或未来版本的数据。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Host 构建在 Desktop 打包键盘集成之前生成 Node 入口和 `./protocol`;Client 阶段生成浏览器 bundle。 + +Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。`ctx.shortcuts.closeWindow()` 统一持有 Desktop 键盘桥接,并携带当前已接受的 revision;Web 调用和桥接请求错误均返回失败。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。 + +DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 + +Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 和 Client 将获焦 iframe 的名称或浏览器 webview 租约与原生输入匹配;Client 拒绝旧配置消息。浏览器 guest 的输入监听在租约释放时结束,无需等待 guest 销毁完成。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 + +
+ +----- + + +## 进一步阅读 + +- [Web Client](../../../docs/subsystems/web-client.zh.md) +- [UI primitives](../ui-primitives/README.zh.md) +- [Web app bundle](../../bundle/web-app/README.zh.md) + +----- + + +## 模型体验 + +无;此包是浏览器侧 UI 插件,不注册面向模型的内容。 + +#### KV Cache 影响 + +无;此包不组装或发送模型提供方请求。 + +## 已知限制与延期工作 + + + +- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,以保留浏览器自身的 Command+Option+R 操作;刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**Runtime invariant:** 不发布配套检查模块。目录和键位索引由私有注册表同步派生,没有独立维护、需要检查的运行时关系。 diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json new file mode 100644 index 0000000000..b3ea0b1fc2 --- /dev/null +++ b/packages/client/shortcuts/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-client-shortcuts", + "description": "Application keyboard command registry and physical-key routing", + "version": "0.1.7-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/shortcuts" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./protocol": { + "types": "./lib/types/protocol.d.ts", + "default": "./lib/protocol.js" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/protocol.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" + } +} diff --git a/packages/client/shortcuts/src/binding.ts b/packages/client/shortcuts/src/binding.ts new file mode 100644 index 0000000000..8a7bed818e --- /dev/null +++ b/packages/client/shortcuts/src/binding.ts @@ -0,0 +1,112 @@ +/** Physical-key protocol shared by browser commands and desktop adapters; no DOM or runtime state. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable command identity owned by the registering feature. */ +export type ShortcutCommandId = Branded<'ShortcutCommandId'> +/** Operating system of the device receiving input. */ +export type ShortcutPlatform = 'macos' | 'windows' | 'linux' +/** Application shell selecting the default bindings. */ +export type ShortcutRuntime = 'desktop' | 'web' +/** Runtime and operating system selecting one explicit default and preference profile. */ +export type ShortcutProfile = `${ShortcutRuntime}:${ShortcutPlatform}` +/** Logical primary expands to Meta on macOS and Control elsewhere. */ +export type ShortcutModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta' +/** One or two distinct physical keys held together, plus an exact set of modifiers. */ +export interface ShortcutBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ShortcutModifier[] +} +/** Normalized modifier order is also the keycap order. */ +export interface NormalizedBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ('control' | 'alt' | 'shift' | 'meta')[] +} + +const keyNames: Readonly> = { + Slash: '/', Comma: ',', Period: '.', Backslash: '\\', Backquote: '`', Minus: '-', Equal: '=', + BracketLeft: '[', BracketRight: ']', Semicolon: ';', Quote: "'", Enter: 'Enter', + Escape: 'Esc', Space: 'Space', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', + ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', +} +const modifierOrder = ['control', 'alt', 'shift', 'meta'] as const + +/** + * Expand logical modifiers, deduplicate, and validate the physical code. + * @param binding - declared binding. + * @param platform - receiving device platform. + * @returns canonical binding; unsupported codes throw during registration. + */ +export function normalizeBinding(binding: ShortcutBinding, platform: ShortcutPlatform): NormalizedBinding { + const codes: [string, ...string[]] = [binding.code, ...binding.secondCode === undefined ? [] : [binding.secondCode]] + codes.sort() + for (const code of codes) { + if (!/^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-9]|2[0-4]))$/u.test(code) + && !Object.hasOwn(keyNames, code)) throw new Error(`Unsupported shortcut code: ${code}`) + } + if (codes.length === 2 && codes[0] === codes[1]) throw new Error('Shortcut keys must be distinct') + const modifiers = new Set(binding.modifiers.map(value => value === 'primary' + ? platform === 'macos' ? 'meta' : 'control' : value)) + return { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), + modifiers: modifierOrder.filter(value => modifiers.has(value)) } +} + +/** + * Produce an exact-match index from a normalized binding. + * @param binding - normalized physical key and modifiers. + * @returns stable index used for both matching and conflict checks. + */ +export function bindingKey(binding: NormalizedBinding): string { + const codes = binding.secondCode === undefined ? [binding.code] : [binding.code, binding.secondCode].sort() + return [...binding.modifiers, ...codes].join('+') +} + +/** + * Format keycaps and ARIA; Windows separates modifiers with plus signs, while chord keys remain adjacent. + * @param binding - normalized binding, or null for an unbound command. + * @param platform - receiving device platform. + * @returns visible keycaps; two-key chords omit ARIA shortcuts, which only support one non-modifier key. + */ +export function presentBinding(binding: NormalizedBinding | null, platform: ShortcutPlatform): { + keys: readonly string[] + aria: string | undefined +} { + if (binding === null) return { keys: [], aria: undefined } + const key = keyNames[binding.code] ?? binding.code.replace(/^(Key|Digit)/u, '') + const symbols = platform === 'macos' + ? { control: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' } + : { control: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaNames = { control: 'Control', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaKey = binding.code === 'Space' ? 'Space' : binding.code === 'Escape' ? 'Escape' + : binding.code.startsWith('Arrow') ? binding.code : key + const second = binding.secondCode === undefined ? [] + : [keyNames[binding.secondCode] ?? binding.secondCode.replace(/^(Key|Digit)/u, '')] + const keys = [...binding.modifiers.map(value => symbols[value]), key] + return { keys: [...platform === 'windows' ? keys.flatMap((label, index) => index === 0 ? [label] : ['+', label]) : keys, ...second], + aria: binding.secondCode === undefined ? [...binding.modifiers.map(value => ariaNames[value]), ariaKey].join('+') : undefined } +} + +/** + * Check Web combinations: Windows and macOS also admit any three or four modifiers; Linux retains the limited set. + * @param binding - normalized candidate. + * @param platform - receiving device platform. + * @returns whether this combination is admitted; admission does not guarantee browser or system delivery. + */ +export function isWebBindingAllowed(binding: NormalizedBinding, platform: ShortcutPlatform): boolean { + if (binding.secondCode !== undefined) return false + if (platform === 'windows' || platform === 'macos') { + if (binding.modifiers.length >= 3) return true + const primary = platform === 'macos' ? 'meta' : 'control' + if (binding.modifiers.length === 1 + && ((['Comma', 'Backslash'].includes(binding.code) && binding.modifiers[0] === primary) + || (binding.code === 'Backquote' && binding.modifiers[0] === 'control'))) return true + if (binding.modifiers.length === 2 && binding.modifiers.includes(primary) + && (binding.modifiers.includes('alt') || binding.modifiers.includes('shift'))) return true + } + return [ + { code: 'Slash', modifiers: ['primary'] as const }, + { code: 'Comma', modifiers: ['primary', 'shift'] as const }, + { code: 'Period', modifiers: ['primary', 'shift'] as const }, + ].some(candidate => bindingKey(binding) === bindingKey(normalizeBinding(candidate, platform))) +} diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts new file mode 100644 index 0000000000..753646fed6 --- /dev/null +++ b/packages/client/shortcuts/src/client/dom.ts @@ -0,0 +1,111 @@ +/** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ +import { modalSelector, observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ShortcutContext, ShortcutFixedInput } from './types.ts' +import type { ShortcutRegistry } from './registry.ts' +import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +/** + * Detect the visiting device, never the server operating system. + * @param document - product document, marked by Electron preload when present. + * @param navigator - browser device identification. + * @returns explicit runtime and platform for default resolution. + */ +export function detectEnvironment(document: Document, navigator: Navigator): { + runtime: ShortcutRuntime + platform: ShortcutPlatform +} { + const desktop = document.documentElement.dataset.platform + const device = desktop ?? navigator.platform + return { runtime: desktop === undefined ? 'web' : 'desktop', + platform: /darwin|mac|iphone|ipad/iu.test(device) ? 'macos' : /win/iu.test(device) ? 'windows' : 'linux' } +} + +/** + * Install document composition tracking and application dispatch after local handlers. + * @param window - input window owned by the client plugin. + * @param shortcuts - command registry for this window. + * @param fixed - optional fixed-sequence consumer after local controls. + * @param native - native input owns configurable bindings; DOM delivery only feeds fixed actions. + * @returns disposer releasing every listener. + */ +export function installKeyboard(window: Window, shortcuts: Pick, + fixed?: (input: ShortcutFixedInput) => void, native = false): () => void { + const document = window.document + const composition = observeComposition(document) + let pending = false + let pendingTimer: number | undefined + const reset = (): void => { fixed?.({ type: 'reset' }) } + let deadKey = false + const blur = (): void => { deadKey = false; reset() } + const containsModal = (node: Node): boolean => node instanceof Element + && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) + const changedModals = (records: MutationRecord[]): void => { + if (records.some(record => record.type === 'attributes' + ? record.oldValue === 'dialog' || record.oldValue === 'true' || containsModal(record.target) + : [...record.addedNodes, ...record.removedNodes].some(containsModal))) reset() + } + const observer = fixed === undefined ? undefined : new MutationObserver(changedModals) + observer?.observe(document.documentElement, { childList: true, subtree: true, + attributes: true, attributeFilter: ['role', 'aria-modal'], attributeOldValue: true }) + const capture = (): void => { + if (pending) reset() + window.clearTimeout(pendingTimer) + if (observer !== undefined) changedModals(observer.takeRecords()) + pending = true + // Native event listeners can yield a microtask checkpoint before bubbling. + pendingTimer = window.setTimeout(() => { + pending = false + pendingTimer = undefined + reset() + }, 0) + } + const keydown = (event: KeyboardEvent): void => { + window.clearTimeout(pendingTimer) + pendingTimer = undefined + pending = false + const target = event.composedPath().find(value => value instanceof Element) + const element = target instanceof Element ? target : document.activeElement + const region = element?.closest('.xterm') ? 'terminal' + : element?.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' : 'page' + const dialogs = document.querySelectorAll(modalSelector) + const top = [...dialogs].at(-1) + const context: ShortcutContext = { region, modal: top === undefined ? null : top.dataset.shortcutModal ?? 'other', target: element } + const guarded = composition.guards(event) || deadKey + || event.getModifierState('AltGraph') + const isDead = event.key === 'Dead' + // macOS can report Option+Command+N as Dead outside input-method composition. + const commandDeadKey = isDead && shortcuts.runtime === 'web' && shortcuts.platform === 'macos' + && event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey + deadKey = isDead + const gesture = { code: event.code, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, + meta: event.metaKey, repeat: event.repeat, composing: guarded || isDead, defaultPrevented: event.defaultPrevented } + const consume = (): void => { + event.preventDefault() + if (commandDeadKey) deadKey = false + } + fixed?.({ type: 'keydown', gesture, context, consume }) + if (native) return + shortcuts.dispatch({ ...gesture, composing: guarded || (isDead && !commandDeadKey), + defaultPrevented: event.defaultPrevented }, context, consume) + } + document.addEventListener('compositionstart', reset, true) + document.addEventListener('compositionend', reset, true) + document.addEventListener('focusin', reset, true) + document.addEventListener('pointerdown', reset, true) + window.addEventListener('keydown', capture, true) + window.addEventListener('keydown', keydown) + window.addEventListener('blur', blur) + return () => { + pending = false + window.clearTimeout(pendingTimer) + observer?.disconnect() + composition.dispose() + document.removeEventListener('compositionstart', reset, true) + document.removeEventListener('compositionend', reset, true) + document.removeEventListener('focusin', reset, true) + document.removeEventListener('pointerdown', reset, true) + window.removeEventListener('keydown', capture, true) + window.removeEventListener('keydown', keydown) + window.removeEventListener('blur', blur) + } +} diff --git a/packages/client/shortcuts/src/client/index.ts b/packages/client/shortcuts/src/client/index.ts new file mode 100644 index 0000000000..bf4ee42a91 --- /dev/null +++ b/packages/client/shortcuts/src/client/index.ts @@ -0,0 +1,159 @@ +/** Browser command service, with one keyboard adapter per plugin lifetime. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ShortcutRegistry } from './registry.ts' +import { detectEnvironment, installKeyboard } from './dom.ts' +import { bindingIssue, initialShortcutConfig, normalizeBinding, overlappingBindings, presentBinding } from '../protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutSaveResult } from '../protocol.ts' +import { desktopShortcutStorage, webShortcutStorage } from './storage.ts' +import type { Shortcuts } from './types.ts' +import type { ShortcutFixedInput } from './types.ts' +import { installNativeKeyboard } from './native.ts' +import { Config } from '../config.ts' + +export type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, Shortcuts } from './types.ts' +export type { ShortcutFixedInput, ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' +export type { ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Window-local application commands and effective keycap catalog. */ + shortcuts: Shortcuts + } +} + +/** Cordis keyboard provider; Desktop startup requires its native keyboard bridge. */ +export default class ShortcutsService extends Service implements Shortcuts { + static inject = ['locale'] + readonly runtime: Shortcuts['runtime'] + readonly platform: Shortcuts['platform'] + readonly catalog: Shortcuts['catalog'] + readonly config: Shortcuts['config'] + readonly fixedCatalog: Shortcuts['fixedCatalog'] + readonly stopSequenceMs: number + private readonly fixedListeners = new Set<(input: ShortcutFixedInput) => void>() + private readonly adapter: DesktopShortcutsApi | undefined + private readonly keyboard: DesktopKeyboardApi | undefined + private active = true + private connected = false + private readonly registry: ShortcutRegistry + + constructor(ctx: Context) { + const environment = detectEnvironment(document, navigator) + const keyboard = environment.runtime === 'desktop' + ? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined + if (environment.runtime === 'desktop' && keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + super(ctx, 'shortcuts') + this.keyboard = keyboard + this.runtime = environment.runtime + this.platform = environment.platform + const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {}) + this.stopSequenceMs = config.stopSequenceMs + this.registry = new ShortcutRegistry(this.runtime, this.platform, initialShortcutConfig()) + this.catalog = this.registry.catalog + this.config = this.registry.config + this.fixedCatalog = this.registry.fixedCatalog + const publish = (snapshot: ReturnType): void => { + if (this.active && this.connected && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + } + const web = this.runtime === 'web' ? webShortcutStorage(window, this.platform, publish) : undefined + this.adapter = web ?? desktopShortcutStorage(window) + if (keyboard !== undefined) ctx.effect(() => installNativeKeyboard(window, keyboard, this.registry, + () => this.config.getSnapshot(), () => { this.fixedInput({ type: 'reset' }) }), 'shortcuts: native keyboard') + ctx.effect(() => { + const off = this.adapter?.subscribe(publish) + return () => { this.active = false; off?.(); web?.dispose() } + }, 'shortcuts: preferences') + this.syncDefinitions() + ctx.effect(() => { + const off = installKeyboard(window, this.registry, (input) => { this.fixedInput(input) }, + keyboard !== undefined && (this.platform === 'macos' || this.platform === 'windows')) + return () => { off(); this.fixedListeners.clear() } + }, 'shortcuts: keyboard') + ctx.effect(() => ctx.locale.subscribe(() => { this.registry.refreshLabels() }), 'shortcuts: locale') + } + + register(command: Parameters[0]): () => void { + const off = this.registry.register(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + registerFixed(command: Parameters[0]): () => void { + const off = this.registry.registerFixed(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void { + this.fixedListeners.add(listener) + return () => { this.fixedListeners.delete(listener) } + } + + private fixedInput(input: ShortcutFixedInput): void { + let consumed = false + for (const listener of [...this.fixedListeners]) { + if (!this.fixedListeners.has(listener)) continue + try { + listener(input.type === 'reset' ? input : { ...input, + gesture: { ...input.gesture, defaultPrevented: input.gesture.defaultPrevented || consumed }, + consume: () => { consumed = true; input.consume() } }) + } catch (error) { + console.error('Fixed shortcut handler failed', error) + } + } + } + + describeBinding(binding: Parameters[0]): ReturnType { + const normalized = binding === null ? null : normalizeBinding(binding, this.platform) + return { binding: normalized, + keys: presentBinding(normalized, this.platform).keys, + issue: normalized === null ? null : bindingIssue(normalized, this.runtime, this.platform), + conflicts: normalized === null ? [] : [...this.catalog.getSnapshot().filter(row => row.binding !== null + && overlappingBindings(row.binding, normalized)).map(row => row.id), + ...this.fixedCatalog.getSnapshot().filter(row => row.bindings.some(binding => overlappingBindings(binding, normalized))) + .map(row => row.id)] } + } + + private syncDefinitions(): void { + if (!this.active) return + if (this.adapter === undefined) { this.failRead(); return } + void this.adapter.get(this.registry.definitions()).then((snapshot) => { + this.connected = true + if (this.active && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + }, () => { this.failRead() }) + } + + private failRead(): void { + if (this.active) this.registry.configure({ ...this.config.getSnapshot(), status: 'unreadable', error: 'read' }) + } + + /** + * Persist one reviewed operation while retaining accepted bindings on failure. + * @param args - edit and expected revision supplied by the editor. + * @returns classified save outcome and accepted snapshot. + */ + async edit(...args: Parameters): ReturnType { + if (this.adapter === undefined) return { status: 'unreadable', snapshot: this.config.getSnapshot() } + let result: ShortcutSaveResult + try { + result = await this.adapter.edit(...args) + } catch (error) { + if (this.active) console.error('Shortcut preference save failed', error) + return { status: 'write-failed', snapshot: this.config.getSnapshot() } + } + if (this.active && result.snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(result.snapshot) + return result + } + + async recording(active: boolean): Promise { + if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable') + await this.adapter.recording(active) + } + + async closeWindow(): Promise { + if (this.keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + await this.keyboard.closeWindow(this.config.getSnapshot().revision) + } +} diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts new file mode 100644 index 0000000000..ff2c97f311 --- /dev/null +++ b/packages/client/shortcuts/src/client/native.ts @@ -0,0 +1,45 @@ +/** Desktop gestures use live focus, modal state, and verified embedding ownership. */ +import { modalSelector } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' +import type { ShortcutRegistry } from './registry.ts' + +/** + * Route native menu, main-frame, and embedded-frame input through the shared command registry. + * @param window - trusted product document. + * @param keyboard - top-frame preload capability. + * @param registry - window-local command owner. + * @param snapshot - latest accepted configuration. + * @param reset - clears pending fixed sequences when native input bypasses DOM delivery. + * @returns disposer releasing native input. + */ +export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardApi, registry: ShortcutRegistry, + snapshot: () => ShortcutConfigSnapshot, reset?: () => void): () => void { + return keyboard.subscribe((input) => { + if (input.revision !== snapshot().revision) return + reset?.() + let target = window.document.activeElement + while (target?.shadowRoot?.activeElement != null && !target.matches('webview[data-sidebar-browser-frame]')) { + target = target.shadowRoot.activeElement + } + const top = [...window.document.querySelectorAll(modalSelector)].at(-1) + const region = target?.closest('.xterm') ? 'terminal' as const + : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const + const context = { target, region, modal: top?.dataset.shortcutModal ?? (top === undefined ? null : 'other') } + if (input.kind === 'menu') { registry.invoke(input.commandId, context); return } + if (input.kind === 'keyboard') { + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, context, () => {}) + return + } + // Renderer focus and embedding identity may change after preload verification. + if (input.kind === 'webview') { + if (target?.matches('webview[data-sidebar-browser-frame]') !== true || !target.isConnected + || input.frameName === '' || target.getAttribute('name') !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'webview' }, () => {}) + return + } + if (!(target instanceof HTMLIFrameElement) || !target.isConnected + || !target.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]') + || input.frameName === '' || target.name !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'iframe' }, () => {}) + }) +} diff --git a/packages/client/shortcuts/src/client/registry.ts b/packages/client/shortcuts/src/client/registry.ts new file mode 100644 index 0000000000..b0c3be724d --- /dev/null +++ b/packages/client/shortcuts/src/client/registry.ts @@ -0,0 +1,180 @@ +/** Command registration, normalized default bindings, and synchronous dispatch. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindingIssue, bindingKey, effectiveShortcuts, initialShortcutConfig, isWebBindingAllowed, normalizeBinding, overlappingBindings, presentBinding, resolveShortcutDefault } from '../protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' +import type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, + ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' + +/** Dispatch consumption is independent of later business-operation success. */ +type ShortcutDispatch = { status: 'handled'; commandId: ShortcutCommandId } + | { status: 'blocked'; commandId: ShortcutCommandId; reason: string } + | { status: 'pass' } + +/** Application command registry; adapters own event listeners, feature plugins own actions. */ +export class ShortcutRegistry { + private readonly commands = new Map() + private readonly fixedCommands = new Map() + private readonly conflicts = new Map() + private readonly bindings = new Map() + private readonly state + /** Effective localized rows derived from the accepted configuration. */ + readonly catalog + /** Accepted preferences and visible read diagnostics. */ + readonly config + /** Fixed local operations whose owning plugins are mounted. */ + readonly fixedCatalog = createSnapshotStore([]) + + constructor(readonly runtime: ShortcutRuntime, readonly platform: ShortcutPlatform, + config: ShortcutConfigSnapshot = { ...initialShortcutConfig(), status: 'ready' }) { + this.state = createSnapshotStore<{ catalog: readonly ShortcutCatalogEntry[]; config: ShortcutConfigSnapshot }>({ catalog: [], config }) + this.catalog = { getSnapshot: () => this.state.getSnapshot().catalog, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + this.config = { getSnapshot: () => this.state.getSnapshot().config, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + } + + /** + * Return the serializable active catalog for storage validation. + * @returns definitions without callbacks or localized labels. + */ + definitions(): readonly ShortcutDefinition[] { + return [...[...this.commands.values()].map(({ id, defaults }) => ({ id, defaults })), + ...[...this.fixedCommands.values()].map(({ id, bindings }) => ({ id, defaults: {}, fixed: bindings }))] + } + + /** + * Register a read-only input action whose keys cannot be assigned to editable commands. + * @param command - owner-localized action and readable sequence. + * @returns idempotent disposer removing its reference row. + */ + registerFixed(command: ShortcutFixedCommand): () => void { + if (this.fixedCommands.has(command.id) || this.commands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const binding of command.bindings) normalizeBinding(binding, this.platform) + this.fixedCommands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.fixedCommands.get(command.id) !== command) return + this.fixedCommands.delete(command.id) + this.refreshLabels() + } + } + + private refreshFixedLabels(): void { + this.fixedCatalog.set([...this.fixedCommands.values()].map(command => ({ + id: command.id, label: command.label(), keys: command.keys, group: command.group, + bindings: command.bindings.map(binding => normalizeBinding(binding, this.platform)), + }))) + } + + /** + * Publish accepted preferences and all derived labels atomically. + * @param config - storage owner's latest accepted snapshot. + */ + configure(config: ShortcutConfigSnapshot): void { + const current = this.config.getSnapshot() + if (config.revision === current.revision && config.status === current.status && config.error === current.error) return + this.refreshLabels(config) + } + + /** + * Register atomically after checking defaults for all supported platforms and shells. + * @param command - feature-owned command definition. + * @returns idempotent disposer removing both matching and catalog entries. + */ + register(command: ShortcutCommand): () => void { + if (this.commands.has(command.id) || this.fixedCommands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const candidate = resolveShortcutDefault(command, runtime, platform) + if (candidate === undefined) continue + const binding = normalizeBinding(candidate, platform) + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) { + throw new Error(`Unsupported Web shortcut: ${command.id}`) + } + if (bindingIssue(binding, runtime, platform) !== null) throw new Error(`Reserved shortcut default: ${command.id}`) + for (const existing of this.commands.values()) { + const other = resolveShortcutDefault(existing, runtime, platform) + if (other !== undefined && overlappingBindings(binding, normalizeBinding(other, platform))) { + throw new Error(`Conflicting shortcut defaults: ${command.id} and ${existing.id} (${runtime}:${platform})`) + } + } + } + } + this.commands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.commands.get(command.id) !== command) return + this.commands.delete(command.id) + this.refreshLabels() + } + } + + /** + * Recompute effective bindings when preferences, commands, or locale change. + * @param config - accepted configuration, defaulting to the current snapshot. + */ + refreshLabels(config: ShortcutConfigSnapshot = this.config.getSnapshot()): void { + this.bindings.clear() + this.conflicts.clear() + const rows = effectiveShortcuts(this.definitions(), config.document, this.runtime, this.platform) + const catalog = rows.map((row) => { + const command = this.commands.get(row.id) as ShortcutCommand + const enabled = config.status !== 'loading' && row.issue === null && row.conflicts.length === 0 + if (enabled && row.binding !== null) this.bindings.set(bindingKey(row.binding), command) + if (config.status !== 'loading' && row.issue === null && row.conflicts.length > 0 && row.binding !== null) { + this.conflicts.set(bindingKey(row.binding), command) + } + return { ...row, label: command.label(), aliases: command.aliases, + ...presentBinding(row.binding, this.platform), + aria: enabled ? presentBinding(row.binding, this.platform).aria : undefined } + }) + this.state.set({ config, catalog }) + this.refreshFixedLabels() + } + + /** + * Invoke a native menu selection independently of its optional key binding. + * @param id - registered product command. + * @param context - live input owner and modal state. + */ + invoke(id: ShortcutCommandId, context: ShortcutContext): void { + const command = this.commands.get(id) + if (command === undefined || (context.modal !== null && !command.modals.includes(context.modal))) return + const result = command.resolve({ ...context, source: 'menu' }) + if (result.status === 'handled') result.run() + } + + /** + * Windows/macOS Desktop bindings override local regions and modal controls independently of their configuration source. + * @param gesture - normalized DOM/native input facts. + * @param context - synchronous input and modal owner. + * @param consume - adapter's preventDefault, called before business execution. + * @returns handled, blocked with a reason, or pass for local/system input. + */ + dispatch(gesture: ShortcutGesture, context: ShortcutContext, consume: () => void): ShortcutDispatch { + if (gesture.defaultPrevented || gesture.composing) return { status: 'pass' } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(value => gesture[value]) + const pairKey = bindingKey({ code: gesture.code, modifiers, + ...(gesture.secondCode === undefined ? {} : { secondCode: gesture.secondCode }) }) + const key = this.bindings.has(pairKey) || this.conflicts.has(pairKey) ? pairKey : bindingKey({ code: gesture.code, modifiers }) + const command = this.bindings.get(key) ?? this.conflicts.get(key) + const priority = this.runtime === 'desktop' && (this.platform === 'windows' || this.platform === 'macos') + if (command === undefined || (!priority && !command.regions.includes(context.region))) return { status: 'pass' } + if (!priority && context.region === 'terminal' && gesture.control && !gesture.meta && !gesture.alt && !gesture.shift + && (gesture.code === 'KeyW' || gesture.code === 'KeyR')) return { status: 'pass' } + if (!this.bindings.has(key)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'conflict' } + } + if (!priority && context.modal !== null && !command.modals.includes(context.modal)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'modal' } + } + const resolution = command.resolve(context) + if (resolution.status === 'pass') return resolution + consume() + if (resolution.status === 'blocked') return { ...resolution, commandId: command.id } + if (!gesture.repeat) resolution.run() + return { status: 'handled', commandId: command.id } + } +} diff --git a/packages/client/shortcuts/src/client/storage.ts b/packages/client/shortcuts/src/client/storage.ts new file mode 100644 index 0000000000..2810833fee --- /dev/null +++ b/packages/client/shortcuts/src/client/storage.ts @@ -0,0 +1,40 @@ +/** Explicit browser and Desktop preference adapters; Desktop never falls back to browser storage. */ +import { ShortcutPersistence } from '../protocol.ts' +import type { DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform } from '../protocol.ts' + +/** Browser-profile and origin-local preference key. */ +export const SHORTCUT_STORAGE_KEY = 'dsh.keybindings.v1' + +/** + * Connect localStorage and same-origin external updates to the shared transaction coordinator. + * @param window - owning browser window. + * @param platform - visiting device platform. + * @param publish - accepts complete configuration snapshots. + * @returns adapter and lifecycle disposal. + */ +export function webShortcutStorage(window: Window, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): DesktopShortcutsApi & { dispose(): void } { + const persistence = new ShortcutPersistence({ + read: () => window.localStorage.getItem(SHORTCUT_STORAGE_KEY), + write: (raw) => { window.localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) }, + }, 'web', platform, true, publish) + const changed = (event: StorageEvent): void => { + if (event.key === null || event.key === SHORTCUT_STORAGE_KEY) void persistence.readCurrent() + } + window.addEventListener('storage', changed) + return { + get: (definitions: readonly ShortcutDefinition[]) => { persistence.setDefinitions(definitions); return persistence.readCurrent() }, + edit: (edit, revision) => persistence.edit(edit, revision), + subscribe: () => () => {}, recording: async () => {}, + dispose: () => { window.removeEventListener('storage', changed); persistence.dispose() }, + } +} + +/** + * Read the origin-scoped preload capability; a missing bridge is an explicit configuration failure. + * @param window - product window. + * @returns the restricted Desktop API, or undefined while the preload is unavailable. + */ +export function desktopShortcutStorage(window: Window): DesktopShortcutsApi | undefined { + return (window as Window & { dshDesktop?: { shortcuts?: DesktopShortcutsApi } }).dshDesktop?.shortcuts +} diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts new file mode 100644 index 0000000000..565b907694 --- /dev/null +++ b/packages/client/shortcuts/src/client/types.ts @@ -0,0 +1,138 @@ +/** Command contributions and immutable catalog values. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutEdit, ShortcutRevision, ShortcutSaveResult, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from '../protocol.ts' + +/** Local input owner resolved before an application command. */ +export type ShortcutRegion = 'page' | 'editable' | 'terminal' +/** Synchronous DOM context; owners capture business targets in resolve(). */ +export interface ShortcutContext { + readonly source?: 'keyboard' | 'menu' | 'iframe' | 'webview' + readonly region: ShortcutRegion + readonly modal: string | null + /** Original input element, or null when no document target is available. */ + readonly target: Element | null +} +/** A resolved action captures its target; failures remain the owner's responsibility. */ +export type ShortcutResolution = { status: 'handled'; run(): void } + | { status: 'blocked'; reason: string } + | { status: 'pass' } +/** One command's labels, defaults, input ownership, and target resolver. */ +export interface ShortcutCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly aliases: readonly string[] + readonly defaults: Readonly>> + readonly regions: readonly ShortcutRegion[] + /** + * Allowed modal identifiers for Web/Linux keyboard input and explicit menu actions; + * Windows/macOS Desktop keyboard bindings take priority. + */ + readonly modals: readonly string[] + resolve(context: ShortcutContext): ShortcutResolution +} +/** JSON-compatible catalog row, shared by reference, tooltips, and controls. */ +export interface ShortcutCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly aliases: readonly string[] + readonly keys: readonly string[] + readonly aria: string | undefined + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** Keyboard adapter input, with composition and local consumption already resolved. */ +export interface ShortcutGesture { + readonly code: string + /** Second physical key currently held with code; matching ignores pair order. */ + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean + readonly composing: boolean + readonly defaultPrevented: boolean +} +/** Local fixed input delivered after controls, or a sequence-invalidating interaction. */ +export type ShortcutFixedInput = { + readonly type: 'keydown' + readonly gesture: ShortcutGesture + readonly context: ShortcutContext + consume(): void +} | { readonly type: 'reset' } +/** Read-only input action contributed while its owning feature is mounted. */ +export interface ShortcutFixedCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly keys: readonly string[] + /** One or more physical combinations reserved by this action, including individual steps of a sequence. */ + readonly bindings: readonly [ShortcutBinding, ...ShortcutBinding[]] + readonly group: 'application' | 'input' | 'menus' | 'approval' +} +/** Localized fixed-action row; its key sequence is not an editable binding. */ +export interface ShortcutFixedCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly keys: readonly string[] + readonly bindings: readonly NormalizedBinding[] + readonly group: ShortcutFixedCommand['group'] +} +/** Feature-facing keyboard registry. */ +export interface Shortcuts { + readonly runtime: ShortcutRuntime + readonly platform: ShortcutPlatform + readonly catalog: ObservableSnapshot + readonly config: ObservableSnapshot + readonly fixedCatalog: ObservableSnapshot + readonly stopSequenceMs: number + /** + * Contribute one fixed action and reserve its keys against editable bindings. + * @param command - owner-localized name, group, readable keys, and reserved physical combinations. + * @returns idempotent disposer removing the fixed row. + */ + registerFixed(command: ShortcutFixedCommand): () => void + /** + * Observe locally arbitrated input and interaction resets for fixed sequences. + * @param listener - owner handler; composing or consumed input must not trigger actions. + * @returns disposer releasing the handler. + */ + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void + /** + * Describe a candidate using the device's physical-key and reservation rules; invalid codes throw. + * @param binding - candidate combination, or null for an unbound command. + * @returns canonical binding, visible keys, rejection reason, and overlapping editable/fixed command IDs. + */ + describeBinding(binding: ShortcutBinding | null): { + binding: NormalizedBinding | null + keys: readonly string[] + issue: BindingIssue | null + conflicts: readonly ShortcutCommandId[] + } + /** + * Save a reviewed edit; failures preserve both accepted preferences and the caller's draft. + * @param edit - set, clear, reset, or reset-all operation. + * @param revision - accepted revision reviewed by the user. + * @returns persistence outcome and current accepted configuration. + * @throws when publishing the accepted command catalog fails. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + /** + * Suppress native menu accelerators while the recording layer owns keyboard input; reject if Desktop cannot acknowledge it. + * @param active - whether the recording layer is mounted. + * @returns completion of the native interception update. + */ + recording(active: boolean): Promise + /** + * Request closure of the Desktop window using the currently accepted shortcut revision. + * @returns completion of the native request; rejects outside Desktop or when the bridge request fails. + */ + closeWindow(): Promise + /** + * Register a command; duplicate ids and overlapping default bindings throw. + * @param command - feature-owned labels, defaults, and target resolver. + * @returns disposer removing the binding and catalog entry. + */ + register(command: ShortcutCommand): () => void +} diff --git a/packages/client/shortcuts/src/config.ts b/packages/client/shortcuts/src/config.ts new file mode 100644 index 0000000000..ca8c838d0c --- /dev/null +++ b/packages/client/shortcuts/src/config.ts @@ -0,0 +1,13 @@ +/** Window-local timing accepted by the Host and browser keyboard service. */ +import z from '@deepseek-ai/schemastery' + +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} + +/** Validated deployment settings for fixed keyboard sequences. */ +export const Config: z, Config> = z.object({ + stopSequenceMs: z.natural().min(1).max(2_147_483_646).default(500), +}) diff --git a/packages/client/shortcuts/src/configuration.ts b/packages/client/shortcuts/src/configuration.ts new file mode 100644 index 0000000000..9a63ec87e1 --- /dev/null +++ b/packages/client/shortcuts/src/configuration.ts @@ -0,0 +1,244 @@ +/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */ +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts' +import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutModifier, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from './binding.ts' + +/** Overrides are platform-local; absent commands inherit defaults and null explicitly unbinds. */ +export interface ShortcutDocument { + readonly schemaVersion: 1 | 2 + readonly profiles: Readonly>>>> +} +/** Serializable command definitions accepted from the trusted product frame. */ +export interface ShortcutDefinition { + readonly id: ShortcutCommandId + readonly defaults: Readonly>> + /** Fixed actions reserve one or more combinations instead of exposing an editable default. */ + readonly fixed?: readonly [ShortcutBinding, ...ShortcutBinding[]] +} +/** Validation failures select localized copy in the UI. */ +export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key' +/** A conflicting row keeps its requested binding visible but cannot execute it. */ +export interface EffectiveShortcut { + readonly id: ShortcutCommandId + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** One revision-checked preference edit, limited to the current runtime and platform. */ +export type ShortcutEdit = { type: 'set'; id: ShortcutCommandId; binding: ShortcutBinding | null } + | { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' } + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u + +/** + * Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites. + * @param value - file or IPC input. + * @returns a binding with a supported physical code, or null for explicit removal. + */ +export function parseBinding(value: unknown): ShortcutBinding | null { + if (value === null) return null + if (!record(value) || Object.keys(value).some(key => key !== 'code' && key !== 'secondCode' && key !== 'modifiers') + || typeof value.code !== 'string' || !Array.isArray(value.modifiers) + || (Object.hasOwn(value, 'secondCode') && typeof value.secondCode !== 'string') + || !value.modifiers.every((modifier: unknown): modifier is ShortcutModifier => + typeof modifier === 'string' && ['primary', 'control', 'alt', 'shift', 'meta'].includes(modifier))) { + throw new Error('Invalid shortcut binding') + } + const binding: ShortcutBinding = { + code: value.code, modifiers: value.modifiers, + ...(typeof value.secondCode === 'string' ? { secondCode: value.secondCode } : {}), + } + normalizeBinding(binding, 'windows') + return binding +} + +/** + * Decode the complete document while preserving dormant command overrides. + * @param raw - stored JSON, or null for a missing document. + * @returns the accepted document or a classified read failure. + */ +export function parseShortcutDocument(raw: string | null): ShortcutDocument | 'invalid' | 'future' { + if (raw === null) return { schemaVersion: 1, profiles: {} } + try { + const value: unknown = JSON.parse(raw) + if (!record(value)) return 'invalid' + if (typeof value.schemaVersion === 'number' && value.schemaVersion > 2) return 'future' + if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || !record(value.profiles) + || Object.keys(value).some(key => key !== 'schemaVersion' && key !== 'profiles')) return 'invalid' + const profiles: Record> = {} + for (const [profile, overrides] of Object.entries(value.profiles)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return 'invalid' + const bindings: Record = {} + for (const [id, binding] of Object.entries(overrides)) { + if (!commandPattern.test(id)) return 'invalid' + const parsed = parseBinding(binding) + if (value.schemaVersion === 1 && parsed?.secondCode !== undefined) return 'invalid' + bindings[id] = parsed + } + profiles[profile] = bindings + } + return { schemaVersion: value.schemaVersion, profiles } + } catch (_error) { + // Invalid JSON and invalid binding fields both preserve the original document. + return 'invalid' + } +} + +/** + * Check system, editor, and browser reservations using expanded physical modifiers. + * @param binding - normalized candidate. + * @param runtime - receiving application shell. + * @param platform - receiving device. + * @returns the rejection reason, or null when this combination is allowed. + */ +export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntime, platform: ShortcutPlatform): BindingIssue | null { + const { code, modifiers } = binding + if (runtime === 'desktop' && (platform === 'windows' || platform === 'macos')) return null + if (binding.secondCode !== undefined) return 'unsupported-key' + if (modifiers.length === 0 || modifiers.every(modifier => modifier === 'shift')) return 'modifier-required' + if ((platform === 'windows' || platform === 'macos') && modifiers.length >= 3) return null + if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && isWebBindingAllowed(binding, platform)) return null + const primary = modifiers.includes(platform === 'macos' ? 'meta' : 'control') + if (['Escape', 'Tab', 'Space', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code) + || (code === 'Enter' && !modifiers.includes('alt')) + || (primary && ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'KeyH'].includes(code)) + || (primary && code === 'KeyA' && !modifiers.includes('shift')) + || (platform !== 'macos' && (modifiers.includes('meta') || (modifiers.includes('alt') && ['F4', 'F2'].includes(code)))) + || (platform === 'macos' && modifiers.includes('control') && modifiers.includes('meta')) + || (platform === 'macos' && modifiers.includes('alt') && !primary)) return 'reserved' + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) return 'unsupported-browser' + return null +} + +/** + * Select the command owner's explicit default for one device profile. + * @param definition - command identity and per-profile defaults. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns the declared physical binding, or undefined for an unbound action. + */ +export function resolveShortcutDefault(definition: ShortcutDefinition, runtime: ShortcutRuntime, + platform: ShortcutPlatform): ShortcutBinding | undefined { + return definition.defaults[`${runtime}:${platform}`] +} + +/** + * Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults. + * @param definitions - active commands. + * @param document - accepted preferences. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns every active command, including unavailable conflicting bindings. + */ +export function effectiveShortcuts(definitions: readonly ShortcutDefinition[], document: ShortcutDocument, + runtime: ShortcutRuntime, platform: ShortcutPlatform): readonly EffectiveShortcut[] { + const overrides = document.profiles[`${runtime}:${platform}`] ?? {} + const fixed = definitions.flatMap(row => row.fixed?.map(binding => ({ id: row.id, binding: normalizeBinding(binding, platform) })) ?? []) + const rows = definitions.filter(row => row.fixed === undefined).map(({ id, defaults }) => { + const modified = Object.hasOwn(overrides, id) + const candidate = modified ? overrides[id] : resolveShortcutDefault({ id, defaults }, runtime, platform) + const binding = candidate == null ? null : normalizeBinding(candidate, platform) + return { id, binding, modified, issue: binding === null ? null : bindingIssue(binding, runtime, platform) } + }) + return rows.map((row) => { + const binding = row.binding + return { ...row, conflicts: binding === null ? [] : [...new Set([...rows.filter(other => other.id !== row.id + && other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding) + && (!row.modified || other.modified)).map(other => other.id), + ...fixed.filter(other => overlappingBindings(other.binding, binding)).map(other => other.id)])] } + }) +} + +/** + * Detect identical combinations or a single key contained in a two-key chord. + * @param left - normalized candidate. + * @param right - normalized occupied binding. + * @returns whether both bindings require the same modifiers and overlap. + */ +export function overlappingBindings(left: NormalizedBinding, right: NormalizedBinding): boolean { + if (left.modifiers.join('+') !== right.modifiers.join('+')) return false + if (left.secondCode !== undefined && right.secondCode !== undefined) return bindingKey(left) === bindingKey(right) + return [left.code, left.secondCode].some(code => code !== undefined && (code === right.code || code === right.secondCode)) +} + +/** + * Apply an edit without modifying other profiles or dormant overrides. + * @param document - accepted document. + * @param edit - validated operation. + * @param runtime - current shell. + * @param platform - current device. + * @returns the candidate document, pending conflict checks and durable storage. + */ +export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutEdit, + runtime: ShortcutRuntime, platform: ShortcutPlatform): ShortcutDocument { + const schemaVersion = runtime === 'desktop' && (platform === 'macos' || platform === 'windows') ? 2 : document.schemaVersion + const profile = `${runtime}:${platform}` as const + let overrides = { ...document.profiles[profile] } + switch (edit.type) { + case 'set': overrides[edit.id] = edit.binding; break + case 'reset': { + const { [edit.id]: _removed, ...remaining } = overrides + overrides = remaining + break + } + case 'reset-all': overrides = {}; break + /* v8 ignore next -- parseShortcutEdit validates this closed union before persistence. */ + default: return assertNever(edit, 'shortcut edit') + } + return { schemaVersion, profiles: { ...document.profiles, [profile]: overrides } } +} + +/** + * Validate a preference edit at the Desktop IPC boundary. + * @param value - untrusted renderer request. + * @returns the constrained operation; malformed requests throw. + */ +export function parseShortcutEdit(value: unknown): ShortcutEdit { + if (!record(value)) throw new Error('Invalid shortcut edit') + if (value.type === 'reset-all' && Object.keys(value).length === 1) return { type: value.type } + if (typeof value.id !== 'string' || !commandPattern.test(value.id)) throw new Error('Invalid shortcut command') + if (value.type === 'reset' && Object.keys(value).length === 2) return { type: 'reset', id: value.id as ShortcutCommandId } + if (value.type === 'set' && Object.keys(value).length === 3) return { type: 'set', id: value.id as ShortcutCommandId, binding: parseBinding(value.binding) } + throw new Error('Invalid shortcut edit') +} + +/** + * Validate the trusted product's serializable command catalog at IPC ingress. + * @param value - renderer-supplied active command definitions. + * @returns validated definitions; duplicate IDs, overlapping defaults, and unsupported combinations throw. + */ +export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefinition[] { + if (!Array.isArray(value)) throw new Error('Invalid shortcut catalog') + const ids = new Set() + for (const entry of value) { + if (!record(entry) || typeof entry.id !== 'string' || !commandPattern.test(entry.id) || ids.has(entry.id) + || !record(entry.defaults) || Object.keys(entry).some(key => key !== 'id' && key !== 'defaults' && key !== 'fixed')) throw new Error('Invalid shortcut definition') + ids.add(entry.id) + if (Object.hasOwn(entry, 'fixed')) { + if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error('Invalid fixed shortcut definition') + for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error('Invalid fixed shortcut binding') + } + for (const [profile, candidate] of Object.entries(entry.defaults)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error('Invalid shortcut profile') + const binding = parseBinding(candidate) + if (binding === null) throw new Error('Invalid shortcut default') + } + } + const definitions = value as ShortcutDefinition[] + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const bindings: NormalizedBinding[] = [] + for (const entry of definitions) { + const binding = resolveShortcutDefault(entry, runtime, platform) + if (binding === undefined) continue + const normalized = normalizeBinding(binding, platform) + if (bindings.some(other => overlappingBindings(other, normalized)) + || bindingIssue(normalized, runtime, platform) !== null) throw new Error('Conflicting or reserved shortcut default') + bindings.push(normalized) + } + } + } + return definitions +} diff --git a/packages/client/shortcuts/src/index.ts b/packages/client/shortcuts/src/index.ts new file mode 100644 index 0000000000..75fb62db79 --- /dev/null +++ b/packages/client/shortcuts/src/index.ts @@ -0,0 +1,20 @@ +/** Host configuration for window-local keyboard sequences. */ +// Host configuration injection stays with each plugin's validated Config and browser global. +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { Config } from './config.ts' + +export { Config } from './config.ts' + +/** + * Embed validated keyboard settings in product pages. + * @param ctx - Host context serving browser pages. + * @param config - sequence timing adopted when the page loads. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: config }) + }) +} +/* jscpd:ignore-end */ diff --git a/packages/client/shortcuts/src/native.ts b/packages/client/shortcuts/src/native.ts new file mode 100644 index 0000000000..45e0817488 --- /dev/null +++ b/packages/client/shortcuts/src/native.ts @@ -0,0 +1,29 @@ +/** Trusted product-window input exchanged with Electron's top-frame preload. */ +import type { ShortcutRevision } from './persistence.ts' +import type { ShortcutCommandId } from './binding.ts' + +/** Native input carries the accepted configuration identity; frame names identify the focused iframe or browser guest embedding element. */ +export type DesktopShortcutInput = { + readonly revision: ShortcutRevision +} & ({ readonly kind: 'menu'; readonly commandId: ShortcutCommandId } + | { readonly kind: 'keyboard' | 'iframe' | 'webview' + readonly frameName: string + readonly code: string + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean }) + +/** Window-scoped capabilities exposed only to the product main frame. */ +export interface DesktopKeyboardApi { + /** Subscribe to verified native input. @param listener - current document consumer. @returns listener disposer. */ + subscribe(listener: (input: DesktopShortcutInput) => void): () => void + /** + * Close the owning window if its configuration is still current. + * @param revision - accepted configuration identity. + * @returns IPC completion. + */ + closeWindow(revision: ShortcutRevision): Promise +} diff --git a/packages/client/shortcuts/src/persistence.ts b/packages/client/shortcuts/src/persistence.ts new file mode 100644 index 0000000000..49335a4935 --- /dev/null +++ b/packages/client/shortcuts/src/persistence.ts @@ -0,0 +1,144 @@ +/** Serialized preference transactions; storage owners publish only accepted writes or read diagnostics. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { Branded } from '@deepseek-ai/dsh-brand' +import { editShortcutDocument, effectiveShortcuts, parseShortcutDocument } from './configuration.ts' +import type { BindingIssue, ShortcutDefinition, ShortcutDocument, ShortcutEdit } from './configuration.ts' +import type { ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from './binding.ts' + +/** Opaque accepted-state identity, invalidated by configuration and catalog changes. */ +export type ShortcutRevision = Branded<'ShortcutRevision'> +/** Read state retains the last accepted document on failure; loading does not enable commands. */ +export interface ShortcutConfigSnapshot { + readonly revision: ShortcutRevision + /** Monotonic within one storage-owner lifetime; clients discard out-of-order IPC replies. */ + readonly sequence: number + readonly document: ShortcutDocument + readonly status: 'loading' | 'ready' | 'unreadable' + readonly error: 'read' | 'invalid' | 'future' | null + readonly usingDefaults: boolean +} +/** Save failures never replace effective preferences. */ +export interface ShortcutSaveResult { + readonly status: 'saved' | 'stale' | 'unreadable' | 'write-failed' | 'not-ready' | 'conflict' + readonly snapshot: ShortcutConfigSnapshot + readonly issue?: BindingIssue + readonly conflicts?: readonly ShortcutCommandId[] +} +/** Storage adapter owns path/origin isolation and complete replacement on successful writes. */ +export interface ShortcutStorage { + read(): string | null | Promise + write(raw: string): void | Promise +} +/** Restricted Desktop preload API; file paths and accelerators never cross from Renderer. */ +export interface DesktopShortcutsApi { + get(definitions: readonly ShortcutDefinition[]): Promise + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + subscribe(listener: (snapshot: ShortcutConfigSnapshot) => void): () => void + recording(active: boolean): Promise +} + +/** + * Create a disabled initial snapshot for asynchronous adapter startup. + * @returns a fresh configuration with no accepted persisted state. + */ +export function initialShortcutConfig(): ShortcutConfigSnapshot { + return { revision: randomUUID() as ShortcutRevision, sequence: 0, document: { schemaVersion: 1, profiles: {} }, + status: 'loading', error: null, usingDefaults: true } +} + +/** Single-writer coordinator shared by localStorage and Electron's atomic file adapter. */ +export class ShortcutPersistence { + private snapshot = initialShortcutConfig() + private raw: string | null | undefined + private queue: Promise = Promise.resolve() + private definitions: readonly ShortcutDefinition[] | null = null + private active = true + + constructor(private readonly storage: ShortcutStorage, private readonly runtime: ShortcutRuntime, + private readonly platform: ShortcutPlatform, private readonly rereadBeforeWrite: boolean, + private readonly publish: (snapshot: ShortcutConfigSnapshot) => void) {} + + /** + * Install or revoke a product catalog and invalidate drafts from its previous lifetime. + * @param definitions - current trusted definitions, or null while the product is not ready. + */ + setDefinitions(definitions: readonly ShortcutDefinition[] | null): void { + this.definitions = definitions + this.accept({ ...this.snapshot }) + } + + /** Stop accepting edits or publishing late completions. */ + dispose(): void { this.active = false } + + /** + * Read the current file; failures retain the last accepted document and disable ordinary writes. + * @returns the accepted snapshot or diagnostic snapshot. + */ + readCurrent(): Promise { + return this.serialize(() => this.read()) + } + + /** + * Compare the draft revision, validate the complete candidate, then persist before publishing. + * @param edit - constrained preference operation. + * @param revision - state against which the user reviewed the edit. + * @returns a classified outcome and the currently accepted snapshot. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise { + return this.serialize(async () => { + if (this.rereadBeforeWrite) await this.read() + const result = (status: ShortcutSaveResult['status']): ShortcutSaveResult => ({ status, snapshot: this.snapshot }) + if (!this.active || this.definitions === null || this.snapshot.status === 'loading') return result('not-ready') + if (revision !== this.snapshot.revision) return result('stale') + if (this.snapshot.status === 'unreadable') return result('unreadable') + if ((edit.type === 'set' || edit.type === 'reset') && !this.definitions.some(row => row.id === edit.id && row.fixed === undefined)) return result('not-ready') + const document = editShortcutDocument(this.snapshot.document, edit, this.runtime, this.platform) + const rows = effectiveShortcuts(this.definitions, document, this.runtime, this.platform) + const invalid = rows.find(row => (edit.type === 'reset-all' || row.id === edit.id) + && (row.issue !== null || row.conflicts.length > 0)) + // An explicit edit must not silently disable another command's default binding. + const displaced = edit.type === 'set' ? rows.find(row => row.conflicts.includes(edit.id)) : undefined + if (invalid !== undefined || displaced !== undefined) return { ...result('conflict'), + ...(invalid?.issue ? { issue: invalid.issue } : {}), + conflicts: invalid?.conflicts.length ? invalid.conflicts : displaced === undefined ? [] : [displaced.id] } + try { + const raw = `${JSON.stringify(document, null, 2)}\n` + await this.storage.write(raw) + this.raw = raw + this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: false }) + return result('saved') + } catch (_error) { + // The adapter retains the previous complete document when a write fails. + return result('write-failed') + } + }) + } + + private serialize(operation: () => Promise): Promise { + const next = this.queue.then(operation) + this.queue = next.catch(() => undefined) + return next + } + + private accept(snapshot: ShortcutConfigSnapshot): void { + this.snapshot = { ...snapshot, sequence: this.snapshot.sequence + 1, revision: randomUUID() as ShortcutRevision } + if (!this.active) return + try { this.publish(this.snapshot) } catch (error) { console.error('Shortcut configuration subscriber failed:', error) } + } + + private async read(): Promise { + let raw: string | null + try { + raw = await this.storage.read() + } catch (_error) { + if (this.snapshot.error !== 'read') this.accept({ ...this.snapshot, status: 'unreadable', error: 'read' }) + return this.snapshot + } + if (raw === this.raw && this.snapshot.error !== 'read') return this.snapshot + this.raw = raw + const document = parseShortcutDocument(raw) + if (typeof document === 'string') this.accept({ ...this.snapshot, status: 'unreadable', error: document }) + else this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: raw === null }) + return this.snapshot + } +} diff --git a/packages/client/shortcuts/src/protocol.ts b/packages/client/shortcuts/src/protocol.ts new file mode 100644 index 0000000000..fad8ab60bd --- /dev/null +++ b/packages/client/shortcuts/src/protocol.ts @@ -0,0 +1,5 @@ +/** Physical-key and versioned preference protocol shared by Client and Desktop main. */ +export * from './binding.ts' +export * from './configuration.ts' +export * from './persistence.ts' +export * from './native.ts' diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts new file mode 100644 index 0000000000..13ad7bc00a --- /dev/null +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -0,0 +1,17 @@ +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { expect, it } from 'vitest' +import { Config, apply } from '../src/index.ts' + +it('validates positive sequence timing and publishes it through the product page injection', async () => { + expect(Config({}).stopSequenceMs).toBe(500) + expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) + for (const stopSequenceMs of [0, -1, 1.5, 2_147_483_647, Infinity, NaN]) expect(() => Config({ stopSequenceMs })).toThrow() + const ctx = new Context() + try { + apply(ctx, Config({ stopSequenceMs: 800 })) + const table: IndexInjection[] = [] + await ctx.parallel('webserver/index-inject', table) + expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/configuration.client.spec.ts b/packages/client/shortcuts/tests/configuration.client.spec.ts new file mode 100644 index 0000000000..63d49dc38e --- /dev/null +++ b/packages/client/shortcuts/tests/configuration.client.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { bindingIssue, editShortcutDocument, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDefinitions, + parseShortcutDocument, parseShortcutEdit } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutDefinition, ShortcutDocument } from '../src/protocol.ts' + +const id = (value: string) => value as ShortcutCommandId +it('reserves the macOS Web control-command combination', () => { + expect(bindingIssue({ code: 'KeyB', modifiers: ['control', 'meta'] }, 'web', 'macos')).toBe('reserved') +}) +const primary = (code: string) => ({ code, modifiers: ['primary'] as const }) +const definitions: readonly ShortcutDefinition[] = [ + { id: id('test.one'), defaults: { + 'desktop:macos': primary('KeyB'), + 'desktop:windows': primary('KeyB'), + 'desktop:linux': primary('KeyB'), + } }, + { id: id('test.two'), defaults: { + 'desktop:macos': primary('KeyJ'), + 'desktop:windows': primary('KeyJ'), + 'desktop:linux': primary('KeyJ'), + } }, +] + +describe('preference document validation and conflict resolution', () => { + it('distinguishes inherited, cleared, reset, and dormant preferences without touching other profiles', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { + 'desktop:macos': { 'dormant.command': primary('KeyU'), 'test.one': null }, + 'web:windows': { 'test.one': primary('Slash') }, + } } + expect(parseShortcutDocument(JSON.stringify(document))).toEqual(document) + expect(effectiveShortcuts(definitions, document, 'desktop', 'macos')[0]).toMatchObject({ modified: true, binding: null }) + const reset = editShortcutDocument(document, { type: 'reset', id: id('test.one') }, 'desktop', 'macos') + expect(reset.profiles['desktop:macos']).toEqual({ 'dormant.command': primary('KeyU') }) + expect(effectiveShortcuts(definitions, reset, 'desktop', 'macos')[0]).toMatchObject({ modified: false, binding: { code: 'KeyB' } }) + expect(editShortcutDocument(document, { type: 'reset-all' }, 'desktop', 'macos').profiles) + .toEqual({ 'desktop:macos': {}, 'web:windows': document.profiles['web:windows'] }) + }) + + it.each(['{', 'null', '[]', '{"schemaVersion":0,"profiles":{}}', '{"schemaVersion":1,"profiles":{"alien":{}}}', + '{"schemaVersion":1,"profiles":{"web:macos":{"bad":null}}}', '{"schemaVersion":1,"profiles":{},"extra":1}', + '{"schemaVersion":1,"profiles":{"web:macos":{"test.one":{"code":"KeyB","modifiers":["bogus"]}}}}'])('retains invalid documents: %s', (raw) => { + expect(parseShortcutDocument(raw)).toBe('invalid') + }) + it('refuses future versions without interpreting their fields', () => { + expect(parseShortcutDocument('{"schemaVersion":3,"profiles":17}')).toBe('future') + expect(parseShortcutDocument(null)).toEqual({ schemaVersion: 1, profiles: {} }) + expect(() => parseBinding({ code: 'KeyB', modifiers: ['primary'], extra: true })).toThrow() + expect(() => parseBinding({ code: 'Unknown', modifiers: [] })).toThrow() + }) + + it('lets explicit overrides displace new defaults, but disables both conflicting explicit overrides', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { 'desktop:windows': { 'test.one': primary('KeyJ') } } } + const rows = effectiveShortcuts(definitions, document, 'desktop', 'windows') + expect(rows.map(row => row.conflicts)).toEqual([[], [id('test.one')]]) + const both = editShortcutDocument(document, { type: 'set', id: id('test.two'), binding: { code: 'KeyJ', modifiers: ['control'] } }, 'desktop', 'windows') + expect(effectiveShortcuts([...definitions].reverse(), both, 'desktop', 'windows').map(row => row.conflicts)) + .toEqual([[id('test.one')], [id('test.two')]]) + }) + + it('keeps Linux desktop and browser reservations', () => { + const platform = 'linux' + const check = (code: string, modifiers: readonly ('primary' | 'control' | 'alt' | 'shift' | 'meta')[], runtime: 'web' | 'desktop' = 'desktop') => + bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform) + for (const code of ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'Tab', 'Escape', 'Space', 'Enter']) expect(check(code, ['primary'])).toBe('reserved') + expect(check('KeyA', ['primary', 'shift'])).toBeNull() + expect(check('KeyA', ['primary'])).toBe('reserved') + expect(check('KeyJ', [])).toBe('modifier-required') + expect(check('KeyJ', ['shift'])).toBe('modifier-required') + expect(check('KeyJ', ['primary'])).toBeNull() + for (const code of ['KeyN', 'KeyP', 'KeyW', 'KeyR', 'KeyO', 'KeyJ']) expect(check(code, ['primary'], 'web')).toBe('unsupported-browser') + for (const code of ['Comma', 'Period']) expect(check(code, ['primary', 'shift'], 'web')).toBeNull() + expect(check('Slash', ['primary'], 'web')).toBeNull() + expect(check('F4', ['alt'], 'desktop')).toBe('reserved') + expect(check('KeyL', ['meta', 'control'], 'desktop')).toBe('reserved') + }) + + it.each(['macos', 'windows'] as const)('accepts %s desktop editing and system combinations, and single keys', (platform) => { + for (const [code, modifiers] of [ + ...['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyA', 'KeyQ', 'KeyH', 'Tab', 'Space'].map(code => [code, ['primary']] as const), + ['KeyC', ['control']], ['KeyX', ['control']], ['KeyN', ['shift']], ['Tab', ['control']], ['Tab', ['shift']], + ['Escape', ['alt']], ['F4', ['alt']], ['KeyL', ['meta']], ['Delete', ['control', 'alt']], + ['KeyL', ['control', 'meta']], ['ArrowLeft', ['alt']], + ] as const) expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding({ code: 'KeyC', modifiers: [] }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding(primary('KeyC'), platform), 'web', platform)).toBe('reserved') + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding: primary('KeyC') }, 'desktop', platform) + expect(effectiveShortcuts(definitions, document, 'desktop', platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + }) + + it.each([ + ['web', 'macos'], ['web', 'windows'], ['desktop', 'macos'], ['desktop', 'windows'], + ] as const)('accepts three and four distinct modifiers in %s on %s and still detects conflicts', (runtime, platform) => { + for (const modifiers of [ + ['control', 'alt', 'shift'], ['control', 'alt', 'meta'], ['control', 'shift', 'meta'], + ['alt', 'shift', 'meta'], ['control', 'alt', 'shift', 'meta'], + ] as const) { + for (const code of ['KeyN', 'KeyR', 'KeyX', 'Tab', 'F4']) { + expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform)).toBeNull() + } + } + const binding = { code: 'KeyX', modifiers: ['primary', 'alt', 'shift'] as const } + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding }, runtime, platform) + expect(effectiveShortcuts(definitions, document, runtime, platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: [] }, platform), runtime, platform)).toBe(runtime === 'desktop' ? null : 'modifier-required') + }) + + it('counts expanded distinct modifiers and keeps Linux reservations', () => { + expect(normalizeBinding({ code: 'KeyX', modifiers: ['primary', 'meta', 'shift'] }, 'macos').modifiers).toEqual(['shift', 'meta']) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['primary', 'control', 'control'] }, 'windows'), 'web', 'windows')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['control', 'alt', 'shift'] }, 'linux'), 'web', 'linux')).toBe('unsupported-browser') + }) + + it('selects each owner-declared profile without inheriting or rewriting another profile', () => { + const entry: ShortcutDefinition = { id: id('page.refresh'), defaults: { + 'desktop:macos': primary('KeyR'), 'desktop:windows': primary('KeyR'), + 'web:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] }, + 'web:windows': { code: 'KeyJ', modifiers: ['primary', 'alt'] }, + } } + const document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + const effective = (runtime: 'desktop' | 'web', platform: 'macos' | 'windows' | 'linux', source = document) => + effectiveShortcuts([entry], source, runtime, platform)[0]?.binding + expect(effective('desktop', 'macos')).toEqual({ code: 'KeyR', modifiers: ['meta'] }) + expect(effective('desktop', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control'] }) + expect(effective('web', 'macos')).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(effective('web', 'windows')).toEqual({ code: 'KeyJ', modifiers: ['control', 'alt'] }) + expect(effective('desktop', 'linux')).toBeNull() + expect(effective('web', 'linux')).toBeNull() + const override = editShortcutDocument(document, { type: 'set', id: entry.id, binding: null }, 'web', 'macos') + expect(effective('web', 'macos', override)).toBeNull() + const reset = editShortcutDocument(override, { type: 'reset', id: entry.id }, 'web', 'macos') + expect(effective('web', 'macos', reset)).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(() => parseShortcutDefinitions([entry])).not.toThrow() + expect(() => parseShortcutDefinitions([entry, { ...entry, id: id('other.refresh') }])).toThrow('Conflicting') + }) + + it('keeps browser admission separate from per-profile default selection', () => { + for (const platform of ['macos', 'windows'] as const) { + const primary = platform === 'macos' ? 'meta' : 'control' + for (const other of ['alt', 'shift'] as const) { + expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers: [primary, other] }, platform), 'web', platform)).toBeNull() + } + } + expect(bindingIssue(normalizeBinding(primary('Backquote'), 'macos'), 'web', 'macos')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyB', modifiers: ['alt'] }, 'macos'), 'web', 'macos')).toBe('reserved') + expect(bindingIssue(normalizeBinding(primary('KeyN'), 'windows'), 'web', 'windows')).toBe('unsupported-browser') + }) + + it('validates IPC edits and active definitions rather than trusting renderer fields', () => { + expect(parseShortcutDefinitions(definitions)).toEqual(definitions) + expect(parseShortcutEdit({ type: 'set', id: 'test.one', binding: null })).toEqual({ type: 'set', id: 'test.one', binding: null }) + expect(parseShortcutEdit({ type: 'reset', id: 'test.one' }).type).toBe('reset') + expect(parseShortcutEdit({ type: 'reset-all' }).type).toBe('reset-all') + for (const value of [null, {}, { type: 'recover' }, { type: 'reset', id: '../x' }, { type: 'reset-all', path: '/tmp' }, { type: 'other', id: 'test.one' }]) expect(() => parseShortcutEdit(value)).toThrow() + for (const value of [null, [{ ...definitions[0], defaults: { + 'desktop:macos': null, + 'desktop:windows': null, + 'desktop:linux': null, + } }], [...definitions, definitions[0]], + [{ ...definitions[0], defaults: { other: primary('KeyJ') } }], [{ ...definitions[0], defaults: { + 'web:macos': primary('KeyN'), + 'web:windows': primary('KeyN'), + 'web:linux': primary('KeyN'), + } }], + [{ ...definitions[0], defaults: { + 'desktop:macos': primary('KeyC'), + 'desktop:windows': primary('KeyC'), + 'desktop:linux': primary('KeyC'), + } }]]) expect(() => parseShortcutDefinitions(value)).toThrow() + }) +}) diff --git a/packages/client/shortcuts/tests/desktop-chords.client.spec.ts b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts new file mode 100644 index 0000000000..e926f5f0ac --- /dev/null +++ b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts @@ -0,0 +1,119 @@ +/** Physical shortcut chords share normalization, conflicts, and persistence rules. */ +import { expect, it, onTestFinished, vi } from 'vitest' +import { bindingIssue, bindingKey, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDocument, + parseShortcutDefinitions, presentBinding, ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutBinding, ShortcutCommandId, ShortcutDefinition } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' + +const id = 'test.action' as ShortcutCommandId +const other = 'test.other' as ShortcutCommandId +const pair = { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } as const +const definitions = [{ id, defaults: {} }, { id: other, defaults: {} }] + +it.each(['macos', 'windows'] as const)('rejects overlapping %s defaults at registration and IPC ingress without disabling the active command', (platform) => { + const modifier = platform === 'macos' ? 'meta' : 'control' + for (const code of ['KeyA', 'KeyB']) { + const single: ShortcutBinding = { code, modifiers: ['primary'] } + const chord: ShortcutBinding = { ...pair, modifiers: [modifier] } + for (const [first, second] of [[single, chord], [chord, single]] as const) { + const catalog: ShortcutDefinition[] = [ + { id, defaults: { [`desktop:${platform}`]: first } }, + { id: other, defaults: { [`desktop:${platform}`]: second } }, + ] + expect(() => parseShortcutDefinitions(catalog)).toThrow('Conflicting') + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + const register = (definition: ShortcutDefinition) => registry.register({ ...definition, label: () => definition.id, + aliases: [], regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + register(catalog[0]!) + const accepted = registry.catalog.getSnapshot() + expect(() => register(catalog[1]!)).toThrow('Conflicting') + expect(registry.catalog.getSnapshot()).toBe(accepted) + expect(registry.dispatch({ ...first, meta: platform === 'macos', control: platform === 'windows', + alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false }, + { region: 'page', modal: null, target: null }, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(parseShortcutDefinitions([catalog[0], { id: other, defaults: { [`desktop:${platform}`]: { + ...second, modifiers: [modifier, 'shift'], + } } }])).toHaveLength(2) + } + } +}) + +it.each([['web', 'macos'], ['web', 'windows'], ['web', 'linux'], ['desktop', 'linux']] as const)( + 'rejects bare keys and chords in %s on %s', (runtime, platform) => { + for (const binding of [pair, { code: 'KeyA', modifiers: [] }, { ...pair, modifiers: ['control', 'alt', 'shift', 'meta'] }] as const) { + expect(bindingIssue(normalizeBinding(binding, platform), runtime, platform)).not.toBeNull() + } + }) + +it('canonicalizes unordered pairs, rejects invalid main keys, and omits unsupported ARIA accelerators', () => { + const reversed = normalizeBinding({ ...pair, code: 'KeyB', secondCode: 'KeyA' }, 'macos') + expect(reversed).toEqual(pair) + expect(bindingKey(reversed)).toBe('KeyA+KeyB') + expect(presentBinding(reversed, 'macos')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding(reversed, 'windows')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding({ ...reversed, modifiers: ['control', 'shift'] }, 'windows').keys) + .toEqual(['Ctrl', '+', 'Shift', '+', 'A', 'B']) + for (const input of [ + { ...pair, secondCode: 'KeyA' }, { ...pair, secondCode: 'ShiftLeft' }, { ...pair, thirdCode: 'KeyC' }, + { ...pair, secondCode: ['KeyB', 'KeyC'] }, { code: 'MetaLeft', modifiers: ['control'] }, { modifiers: ['meta'] }, + ]) expect(() => parseBinding(input)).toThrow() +}) + +it.each(['macos', 'windows'] as const)('migrates %s preferences on write and rejects both directions of single/chord conflicts', async (platform) => { + let raw = JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { 'dormant.web': null } } }) + const original = raw + const store = new ShortcutPersistence({ read: () => raw, write: (next) => { raw = next } }, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(definitions) + let state = await store.readCurrent() + expect(raw).toBe(original) + const saved = await store.edit({ type: 'set', id, binding: pair }, state.revision) + expect(saved.status).toBe('saved') + expect(JSON.parse(raw)).toEqual({ schemaVersion: 2, profiles: { 'web:macos': { 'dormant.web': null }, [`desktop:${platform}`]: { [id]: pair } } }) + expect(parseShortcutDocument(raw)).toEqual(saved.snapshot.document) + expect(parseShortcutDocument(raw.replace('"schemaVersion": 2', '"schemaVersion": 1'))).toBe('invalid') + state = saved.snapshot + for (const code of ['KeyA', 'KeyB']) { + const rejected = await store.edit({ type: 'set', id: other, binding: { code, modifiers: [] } }, state.revision) + expect(rejected).toMatchObject({ status: 'conflict', conflicts: [id] }) + } + const cleared = await store.edit({ type: 'reset-all' }, state.revision) + const single = await store.edit({ type: 'set', id, binding: { code: 'KeyB', modifiers: [] } }, cleared.snapshot.revision) + expect((await store.edit({ type: 'set', id: other, binding: pair }, single.snapshot.revision)).status).toBe('conflict') + const overlapping = { schemaVersion: 2 as const, profiles: { [`desktop:${platform}`]: { [id]: pair, [other]: { code: 'KeyA', modifiers: [] } } } } + expect(effectiveShortcuts(definitions, overlapping, 'desktop', platform).map(row => row.conflicts)).toEqual([[other], [id]]) +}) + +it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fixed actions and disables previously saved conflicts', async (platform) => { + const fixedId = 'menu.dismiss' as ShortcutCommandId + const catalog = parseShortcutDefinitions([...definitions, + { id: fixedId, defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(catalog) + const state = await store.readCurrent() + for (const binding of [{ code: 'Escape', modifiers: [] }, { code: 'KeyA', secondCode: 'Escape', modifiers: [] }] as const) { + expect(await store.edit({ type: 'set', id, binding }, state.revision)).toMatchObject({ status: 'conflict', conflicts: [fixedId] }) + expect(effectiveShortcuts(catalog, { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { [id]: binding } } }, + 'desktop', platform).find(row => row.id === id)?.conflicts).toEqual([fixedId]) + } + expect(storage.write).not.toHaveBeenCalled() + expect((await store.edit({ type: 'set', id: fixedId, binding: null }, state.revision)).status).toBe('not-ready') + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: ['control', 'alt'] } }, state.revision)).status).toBe('saved') + store.setDefinitions(definitions) + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: [] } }, (await store.readCurrent()).revision)).status).toBe('saved') +}) + +it('rejects malformed fixed reservations at catalog IPC ingress', () => { + for (const fixed of [[], null, [null], [{ code: 'MetaLeft', modifiers: [] }], [{ code: 'Escape', modifiers: [], extra: true }]]) { + expect(() => parseShortcutDefinitions([{ id, defaults: {}, fixed }])).toThrow() + } + expect(() => parseShortcutDefinitions([{ id, defaults: { + 'desktop:macos': pair, + 'desktop:windows': pair, + 'desktop:linux': pair, + }, fixed: [{ code: 'Escape', modifiers: [] }] }])).toThrow() +}) diff --git a/packages/client/shortcuts/tests/dom.client.spec.ts b/packages/client/shortcuts/tests/dom.client.spec.ts new file mode 100644 index 0000000000..ee0f5ac9a7 --- /dev/null +++ b/packages/client/shortcuts/tests/dom.client.spec.ts @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { installKeyboard, detectEnvironment } from '../src/client/dom.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks() }) + +function mount(runtime: 'desktop' | 'web' = 'web', platform: 'macos' | 'windows' = 'windows') { + const registry = new ShortcutRegistry(runtime, platform) + const run = vi.fn() + registry.register({ id: 'shortcuts.open' as ShortcutCommandId, label: () => 'Shortcuts', aliases: [], + defaults: { + 'desktop:macos': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Slash', modifiers: ['primary'] }, + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + }, + regions: ['page', 'editable'], modals: ['shortcuts'], resolve: () => ({ status: 'handled', run }) }) + dispose = installKeyboard(window, registry) + const input = document.createElement('input'); document.body.append(input); input.focus() + return { registry, input, run } +} +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: '/', code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} + +it.each(['macos', 'windows'] as const)('leaves configurable %s shortcuts to native input while retaining DOM fixed-action observation', (platform) => { + const { registry, input, run } = mount('desktop', platform) + dispose?.() + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + dispose = installKeyboard(window, registry, fixed, true) + expect(press(input, { ctrlKey: platform === 'windows', metaKey: platform === 'macos' }).defaultPrevented).toBe(false) + registry.configure({ ...registry.config.getSnapshot(), revision: 'pair' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'shortcuts.open': { code: 'KeyF', secondCode: 'KeyG', modifiers: [] }, + } } } }) + press(input, { code: 'KeyF', key: 'f', ctrlKey: false }) + press(input, { code: 'KeyG', key: 'g', ctrlKey: false }) + expect(run).not.toHaveBeenCalled() + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { code: 'KeyG' } }) +}) + +const optionCommandN = { key: 'Dead', code: 'KeyN', ctrlKey: false, altKey: true, metaKey: true } as const +const commandSlash = { ctrlKey: false, metaKey: true } as const +function mountMacNewSession() { + const fixture = mount('web', 'macos') + const newSession = vi.fn() + const unregister = fixture.registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New session', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyN', modifiers: ['primary', 'alt'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: newSession }) }) + return { ...fixture, newSession, unregister } +} + +it('consumes bound macOS Web Option+Command+N without leaving dead-key state on repeats or subsequent commands', () => { + const { input, newSession, run } = mountMacNewSession() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(press(input, { ...optionCommandN, repeat: true }).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledOnce() + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledTimes(2) +}) + +it('preserves an unbound Dead key and the following input', () => { + const { input, unregister, newSession, run } = mountMacNewSession() + unregister() + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(newSession).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledOnce() +}) + +it('keeps real composition, composition-ending input, legacy IME, AltGraph and accent input guarded for Option+Command+N', () => { + const { input, newSession } = mountMacNewSession() + fireEvent.compositionStart(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + for (const extra of [ + { isComposing: true }, + { keyCode: 229 }, + { metaKey: false }, { code: 'KeyE' }, { ctrlKey: true }, { shiftKey: true }, + ]) { + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, ...extra }).defaultPrevented).toBe(false) + } + fireEvent.blur(window) + const altGraph = new KeyboardEvent('keydown', { ...optionCommandN, bubbles: true, cancelable: true }) + vi.spyOn(altGraph, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(altGraph) + expect(altGraph.defaultPrevented).toBe(false) + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, metaKey: false }).defaultPrevented).toBe(false) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(newSession).not.toHaveBeenCalled() +}) + +it.each([['desktop', 'macos'], ['web', 'windows']] as const)('keeps Dead key protection in %s on %s', (runtime, platform) => { + const { input, run } = mount(runtime, platform) + const keys = { metaKey: platform === 'macos', ctrlKey: platform === 'windows' } + expect(press(input, { ...keys, key: 'Dead' }).defaultPrevented).toBe(false) + expect(press(input, keys).defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + expect(press(input, keys).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + +it('detects the client OS and separates Web from the preload marker', () => { + expect(detectEnvironment(document, { platform: 'MacIntel' } as Navigator)).toEqual({ runtime: 'web', platform: 'macos' }) + expect(detectEnvironment(document, { platform: 'Win32' } as Navigator)).toEqual({ runtime: 'web', platform: 'windows' }) + const html = document.documentElement + const previous = html.dataset.platform + try { + html.dataset.platform = 'linux' + expect(detectEnvironment(document, navigator)).toEqual({ runtime: 'desktop', platform: 'linux' }) + } finally { + if (previous === undefined) delete html.dataset.platform + else html.dataset.platform = previous + } +}) + +it('passes browser navigation/editing and local consumption; disposes the listener', () => { + const { input, run } = mount() + for (const key of ['N', 'P', 'O', 'R', 'W', 'C', 'V', 'Z']) expect(press(input, { code: `Key${key}` }).defaultPrevented).toBe(false) + const local = (event: Event) => { event.preventDefault() } + input.addEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + input.removeEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(press(input, { repeat: true }).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + dispose?.() + expect(press(input).defaultPrevented).toBe(false) +}) + +it('preserves composition, late closing keys, 229, AltGraph, and dead-key input', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input).defaultPrevented).toBe(false) + expect(press(input, { isComposing: true }).defaultPrevented).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise legacy IME input. + expect(press(input, { keyCode: 229 }).defaultPrevented).toBe(false) + expect(press(input, { key: 'Dead' }).defaultPrevented).toBe(false) + const event = new KeyboardEvent('keydown', { code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true }) + vi.spyOn(event, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + fireEvent.keyUp(input) + expect(press(input).defaultPrevented).toBe(true) +}) + +it('accepts a shortcut after the composition-closing key is released inside a local control', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + fireEvent.compositionEnd(input) + input.addEventListener('keyup', (event) => { event.stopPropagation() }, { once: true }) + fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' }) + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + +it('blocks background commands during dialogs and gives terminal input ownership', () => { + const { input, run } = mount() + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + dialog.dataset.shortcutModal = 'shortcuts' + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + const terminal = document.createElement('div'); terminal.className = 'xterm'; terminal.append(input); document.body.append(terminal) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.blur(window) +}) + +it('routes body focus and window-delivered input through the same application command', () => { + const { input, run } = mount() + input.remove() + expect(press(document.body).defaultPrevented).toBe(true) + expect(press(window).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledTimes(2) +}) + +it('passes the original input element to a feature target resolver', () => { + const { registry, input } = mount() + const dispatch = vi.spyOn(registry, 'dispatch') + press(input) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'editable', modal: null, target: input }, expect.any(Function)) + input.remove() + press(window) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'page', modal: null, target: document.body }, expect.any(Function)) +}) diff --git a/packages/client/shortcuts/tests/fixed-input.client.spec.ts b/packages/client/shortcuts/tests/fixed-input.client.spec.ts new file mode 100644 index 0000000000..71eedaae0c --- /dev/null +++ b/packages/client/shortcuts/tests/fixed-input.client.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { installKeyboard } from '../src/client/dom.ts' +import ShortcutsService from '../src/client/index.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedCommand, ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear() }) +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} +function mount() { + const dispatch = vi.fn(() => ({ status: 'pass' as const })) + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + const input = document.createElement('input') + document.body.append(input) + input.focus() + dispose = installKeyboard(window, { dispatch, runtime: 'web', platform: 'windows' }, fixed) + return { input, dispatch, fixed } +} + +it('runs fixed handlers after local ownership and preserves their consumption for application dispatch', () => { + const { input, dispatch, fixed } = mount() + input.addEventListener('keydown', (event) => { event.preventDefault() }, { once: true }) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { defaultPrevented: true } }) + fixed.mockImplementation((event) => { if (event.type === 'keydown') event.consume() }) + expect(press(input).defaultPrevented).toBe(true) + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrevented: true }), expect.anything(), expect.any(Function)) + fireEvent.compositionStart(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) + fireEvent.compositionEnd(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) +}) + +it('resets sequences when local controls stop propagation, including two inputs in the same task', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + expect(fixed).not.toHaveBeenCalled() + press(input) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'keydown']) + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + await vi.runOnlyPendingTimersAsync() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) +}) + +it('resets on focus, pointer, blur, and modal transitions even when focus stays unchanged', async () => { + const { input, fixed } = mount() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'reset', 'reset']) + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + const wrapper = document.createElement('div'); wrapper.append(dialog); document.body.append(wrapper) + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(4) + dialog.setAttribute('aria-modal', 'false') + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(5) + dialog.setAttribute('aria-modal', 'true') + press(input) + expect(fixed.mock.calls.slice(-2).map(([event]) => event.type)).toEqual(['reset', 'keydown']) + wrapper.remove() + await Promise.resolve() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) + fixed.mockClear() + document.body.append(document.createTextNode('stream')) + await Promise.resolve() + expect(fixed).not.toHaveBeenCalled() +}) + +it('releases listeners, modal observation, and pending local-input resets on disposal', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + dispose?.() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window); press(input) + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + await vi.runOnlyPendingTimersAsync() + expect(fixed).not.toHaveBeenCalled() +}) + +it('publishes fixed reservations and removes them with their owning registration', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Stop reply' + const command: ShortcutFixedCommand = { id: 'response.stop' as ShortcutCommandId, label: () => label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application' } + const off = registry.registerFixed(command) + expect(registry.fixedCatalog.getSnapshot()).toEqual([{ id: 'response.stop', label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application' }]) + expect(registry.definitions()).toEqual([{ id: 'response.stop', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(() => registry.registerFixed(command)).toThrow('Duplicate') + expect(() => registry.register({ ...command, aliases: [], defaults: {}, regions: [], modals: [], resolve: () => ({ status: 'pass' }) })).toThrow('Duplicate') + label = '停止回复'; registry.refreshLabels() + expect(registry.fixedCatalog.getSnapshot()[0]?.label).toBe(label) + off(); off() + expect(registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(registry.definitions()).toEqual([]) +}) + +it('contains observer failures and makes prior consumption visible to later observers', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = new ShortcutsService(ctx) + const offFixed = service.registerFixed({ id: 'response.stop' as ShortcutCommandId, label: () => 'Stop', keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }) + expect(service.stopSequenceMs).toBe(500) + expect(service.fixedCatalog.getSnapshot()).toHaveLength(1) + service.observeFixedInput(() => { throw new Error('owner failure') }) + const off = service.observeFixedInput((event) => { if (event.type === 'keydown') event.consume() }) + const last = vi.fn<(input: ShortcutFixedInput) => void>() + service.observeFixedInput(last) + expect(press(document.body).defaultPrevented).toBe(true) + expect(last.mock.lastCall?.[0]).toMatchObject({ gesture: { defaultPrevented: true } }) + expect(error).toHaveBeenCalled() + off(); offFixed() + expect(press(document.body).defaultPrevented).toBe(false) + await ctx.fiber.dispose() + last.mockClear(); press(document.body) + expect(last).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('delivers resets and skips an observer removed earlier in the same dispatch', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + try { + const service = new ShortcutsService(ctx) + let off = (): void => {} + const first = vi.fn(() => { off() }) + service.observeFixedInput(first) + const removed = vi.fn() + off = service.observeFixedInput(removed) + fireEvent.focusIn(document.body) + expect(first).toHaveBeenLastCalledWith({ type: 'reset' }) + expect(removed).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/native.client.spec.ts b/packages/client/shortcuts/tests/native.client.spec.ts new file mode 100644 index 0000000000..03bd2d378a --- /dev/null +++ b/packages/client/shortcuts/tests/native.client.spec.ts @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +/** Trusted native inputs preserve current configuration, frame identity, and modal arbitration. */ +import { afterEach, expect, it, vi } from 'vitest' +import { installNativeKeyboard } from '../src/client/native.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { DesktopShortcutInput, ShortcutCommandId } from '../src/protocol.ts' + +afterEach(() => { document.body.replaceChildren() }) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom native bindings across editable and terminal regions and modals with revision checks', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 1, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyC', modifiers: ['primary'] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const reset = vi.fn() + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot(), reset) + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyC', control: platform === 'windows', alt: false, shift: false, meta: platform === 'macos', repeat: false } + const target = document.createElement('textarea') + const terminal = document.createElement('div') + terminal.className = 'xterm'; terminal.append(target); document.body.append(terminal) + target.focus() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + deliver({ ...input, repeat: true }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(2) + terminal.className = '' + deliver(input) + expect(run).toHaveBeenCalledTimes(2) + terminal.setAttribute('role', 'dialog'); terminal.setAttribute('aria-modal', 'true') + deliver(input) + expect(run).toHaveBeenCalledTimes(3) + terminal.remove() + deliver(input) + expect(run).toHaveBeenCalledTimes(4) + off() +}) + +it('rejects old configuration and iframe identities, consumes repeats, and invokes unbound menu actions', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const offCommand = registry.register({ id: 'page.close' as ShortcutCommandId, label: () => 'Close', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyW', modifiers: ['primary'] }, + }, regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const release = vi.fn() + const off = installNativeKeyboard(window, { + subscribe: (listener) => { deliver = listener; return release }, closeWindow: vi.fn() }, registry, + () => registry.config.getSnapshot()) + const revision = registry.config.getSnapshot().revision + const input = { kind: 'iframe' as const, revision, frameName: 'current', code: 'KeyW', meta: true, + control: false, alt: false, shift: false, repeat: false } + deliver(input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.focus() + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.name = 'current' + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + deliver({ ...input, revision: 'old' as typeof revision }) + deliver({ ...input, repeat: true }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + deliver(input) + deliver({ kind: 'menu', revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(2) + modal.remove() + registry.configure({ ...registry.config.getSnapshot(), revision: 'unbound' as typeof revision, + document: { schemaVersion: 1, profiles: { 'desktop:macos': { 'page.close': null } } } }) + deliver({ ...input, revision: 'unbound' as typeof revision }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + offCommand() + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + off() + expect(release).toHaveBeenCalledTimes(1) +}) + +it.each(['macos', 'windows'] as const)('runs a custom %s chord across text fields and verified embedded pages, once per press', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], defaults: {}, + regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'chord' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + try { + const field = document.createElement('textarea'); document.body.append(field); field.focus() + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyA', secondCode: 'KeyB', control: false, alt: false, shift: false, meta: false, repeat: false } + deliver(input); deliver({ ...input, repeat: true }) + expect(run).toHaveBeenCalledOnce() + const frame = document.createElement('iframe'); frame.name = 'browser'; frame.setAttribute('data-sidebar-browser-frame', '') + document.body.append(frame); frame.focus() + deliver({ ...input, kind: 'iframe', frameName: 'browser' }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ ...input, kind: 'iframe', frameName: 'old' }) + expect(run).toHaveBeenCalledTimes(2) + } finally { off() } +}) + +it('checks the focused browser guest lease before dispatch and retains its embedding context across shadow focus', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const resolve = vi.fn(() => ({ status: 'handled' as const, run })) + registry.register({ id: 'browser.new' as ShortcutCommandId, label: () => 'Browser', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyT', modifiers: ['primary'] } }, regions: ['page'], modals: [], resolve }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + const input: DesktopShortcutInput = { kind: 'webview', frameName: 'guest', revision: registry.config.getSnapshot().revision, + code: 'KeyT', control: false, alt: false, shift: false, meta: true, repeat: false } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + const shadow = document.createElement('div').attachShadow({ mode: 'open' }) + Object.defineProperty(shadow, 'activeElement', { value: document.createElement('input') }) + const shadowRoot = vi.spyOn(frame, 'shadowRoot', 'get').mockReturnValue(shadow) + try { + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + deliver({ ...input, frameName: '' }) + deliver({ ...input, frameName: 'old' }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ source: 'webview', target: frame })) + deliver({ ...input, repeat: true }) + frame.remove() + deliver(input) + expect(run).toHaveBeenCalledOnce() + } finally { + shadowRoot.mockRestore() + off() + } +}) diff --git a/packages/client/shortcuts/tests/persistence.client.spec.ts b/packages/client/shortcuts/tests/persistence.client.spec.ts new file mode 100644 index 0000000000..41698ba551 --- /dev/null +++ b/packages/client/shortcuts/tests/persistence.client.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutEdit } from '../src/protocol.ts' + +const one = 'test.one' as ShortcutCommandId +it('rejects an unsupported Web binding without writing preferences', async () => { + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'web', 'linux', false, () => {}) + store.setDefinitions([{ id: one, defaults: {} }]) + const snapshot = await store.readCurrent() + expect(await store.edit({ type: 'set', id: one, binding: { code: 'KeyB', modifiers: ['control'] } }, snapshot.revision)) + .toMatchObject({ status: 'conflict', issue: 'unsupported-browser', conflicts: [] }) + expect(storage.write).not.toHaveBeenCalled() +}) +const two = 'test.two' as ShortcutCommandId +const binding = { code: 'KeyJ', modifiers: ['primary'] as const } +const definitions: readonly ShortcutDefinition[] = [{ id: one, defaults: { + 'desktop:macos': { ...binding, code: 'KeyB' }, + 'desktop:windows': { ...binding, code: 'KeyB' }, + 'desktop:linux': { ...binding, code: 'KeyB' }, +} }, +{ id: two, defaults: { + 'desktop:macos': { ...binding, code: 'Comma' }, + 'desktop:windows': { ...binding, code: 'Comma' }, + 'desktop:linux': { ...binding, code: 'Comma' }, +} }] +const edit: ShortcutEdit = { type: 'set', id: one, binding } + +function fixture(reread = false) { + let raw: string | null = null + let state!: ShortcutConfigSnapshot + const publish = vi.fn((snapshot: ShortcutConfigSnapshot) => { state = snapshot }) + const storage = { read: vi.fn(async () => raw), + write: vi.fn(async (next: string) => { raw = next }) } + const store = new ShortcutPersistence(storage, 'desktop', 'windows', reread, publish) + store.setDefinitions(definitions) + return { store, storage, publish, state: () => state, raw: () => raw, external: (value: string) => { raw = value } } +} + +describe('serialized preference persistence', () => { + it('publishes a save once and only after durable completion; rejects the competing old revision', async () => { + const f = fixture() + const initial = await f.store.readCurrent() + let release!: () => void + let started!: () => void + const writing = new Promise((resolve) => { started = resolve }) + const barrier = new Promise((resolve) => { release = resolve }) + f.storage.write.mockImplementationOnce(async () => { started(); await barrier }) + const first = f.store.edit(edit, initial.revision) + const second = f.store.edit({ type: 'reset-all' }, initial.revision) + await writing + f.publish.mockClear() + expect(f.state()).toBe(initial) + release() + expect((await first).status).toBe('saved') + expect((await second).status).toBe('stale') + expect(f.publish).toHaveBeenCalledOnce() + expect(f.state().document.profiles['desktop:windows']?.[one]).toEqual(binding) + }) + + it('keeps the previous configuration and revision on write failure, allowing the same draft to retry', async () => { + const f = fixture(); const initial = await f.store.readCurrent() + f.storage.write.mockRejectedValueOnce(new Error('disk full')) + expect((await f.store.edit(edit, initial.revision)).status).toBe('write-failed') + expect(f.state()).toBe(initial) + expect(f.raw()).toBeNull() + expect((await f.store.edit(edit, initial.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(f.raw()!) + expect(stored).toMatchObject({ profiles: { 'desktop:windows': { [one]: binding } } }) + const restarted = fixture(); restarted.external(f.raw()!) + expect((await restarted.store.readCurrent()).document).toEqual(f.state().document) + }) + + it.each(['{broken', '{"schemaVersion":3,"profiles":{}}'])('retains accepted bindings and original bytes after reading %s', async (raw) => { + const f = fixture(); await f.store.readCurrent(); await f.store.edit(edit, f.state().revision) + const accepted = f.state().document + f.external(raw) + const bad = await f.store.readCurrent() + expect(bad.status).toBe('unreadable'); expect(bad.document).toBe(accepted) + for (const operation of [edit, { type: 'reset-all' }, { type: 'reset', id: one }] as ShortcutEdit[]) { + expect((await f.store.edit(operation, bad.revision)).status).toBe('unreadable') + } + expect(f.raw()).toBe(raw) + + }) + + it('uses defaults on the first bad read and prevents writes until the source becomes readable', async () => { + const f = fixture(); f.storage.read.mockRejectedValueOnce(new Error('denied')) + expect((await f.store.readCurrent()).usingDefaults).toBe(true) + f.storage.read.mockRejectedValueOnce(new Error('still denied')) + expect((await f.store.readCurrent()).error).toBe('read') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('unreadable') + expect(f.storage.write).not.toHaveBeenCalled() + f.external('{'); expect((await f.store.readCurrent()).error).toBe('invalid') + f.external('{"schemaVersion":1,"profiles":{}}'); expect((await f.store.readCurrent()).status).toBe('ready') + }) + + it('rereads before a browser write and requires an explicit review of external updates', async () => { + const f = fixture(true); const initial = await f.store.readCurrent() + f.external('{"schemaVersion":1,"profiles":{"web:macos":{"sleeping.command":null}}}') + expect((await f.store.edit(edit, initial.revision)).status).toBe('stale') + expect(f.storage.write).not.toHaveBeenCalled() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('saved') + expect(f.state().document.profiles['web:macos']).toEqual({ 'sleeping.command': null }) + }) + + it('accepts Windows single keys and editing combinations but rejects explicit/default conflicts', async () => { + const f = fixture(); await f.store.readCurrent() + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: ['control'] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: [] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { ...binding, code: 'Comma' } }, f.state().revision)).status).toBe('conflict') + await f.store.edit(edit, f.state().revision) + expect((await f.store.edit({ ...edit, id: two }, f.state().revision)).conflicts).toEqual([one]) + expect((await f.store.edit({ type: 'set', id: two, binding: { ...binding, code: 'KeyB' } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ type: 'reset', id: one }, f.state().revision)).status).toBe('conflict') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('saved') + }) + + it('rejects early/unknown/stale-owner edits and does not publish a late read after disposal', async () => { + const f = fixture() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + await f.store.readCurrent() + expect((await f.store.edit({ ...edit, id: 'missing.command' as ShortcutCommandId }, f.state().revision)).status).toBe('not-ready') + f.store.setDefinitions(null) + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + f.store.dispose(); f.publish.mockClear(); f.external('{') + await f.store.readCurrent() + expect(f.publish).not.toHaveBeenCalled() + }) +}) + + +it('continues transactions after a rejected unsupported code and contains subscriber failures', async () => { + const f = fixture(); await f.store.readCurrent() + await expect(f.store.edit({ ...edit, binding: { ...binding, code: 'Unidentified' } }, f.state().revision)).rejects.toThrow('Unsupported') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + f.publish.mockImplementationOnce(() => { throw new Error('subscriber failed') }) + const result = await f.store.edit(edit, f.state().revision) + expect(result.status).toBe('saved') + expect(error).toHaveBeenCalledWith('Shortcut configuration subscriber failed:', expect.any(Error)) + expect((await f.store.readCurrent()).document).toEqual(result.snapshot.document) + } finally { error.mockRestore() } +}) diff --git a/packages/client/shortcuts/tests/registry.client.spec.ts b/packages/client/shortcuts/tests/registry.client.spec.ts new file mode 100644 index 0000000000..b87f40c361 --- /dev/null +++ b/packages/client/shortcuts/tests/registry.client.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import { bindingKey, initialShortcutConfig, normalizeBinding, presentBinding } from '../src/protocol.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import { isWebBindingAllowed } from '../src/binding.ts' +import type { ShortcutCommand, ShortcutGesture } from '../src/client/types.ts' + +const context = { region: 'page', modal: null, target: null } as const +it('admits modified Web combinations and refuses two-key chords', () => { + expect(isWebBindingAllowed({ code: 'KeyB', modifiers: ['control', 'alt', 'shift'] }, 'windows')).toBe(true) + expect(isWebBindingAllowed({ code: 'KeyB', secondCode: 'KeyJ', modifiers: [] }, 'macos')).toBe(false) +}) + +it('leaves Linux terminal control-W and control-R with the terminal while dispatching other bindings', () => { + for (const [code, modifiers, status] of [ + ['KeyW', ['control'], 'pass'], ['KeyR', ['control'], 'pass'], ['KeyB', ['control'], 'handled'], + ['KeyB', ['alt'], 'handled'], + ['KeyB', ['control', 'alt'], 'handled'], ['KeyB', ['control', 'shift'], 'handled'], + ] as const) { + const registry = new ShortcutRegistry('desktop', 'linux') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'desktop:linux': { code, modifiers } }, regions: ['terminal'], + resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code, control: modifiers.some(value => value === 'control'), + meta: false, alt: modifiers.some(value => value === 'alt'), + shift: modifiers.some(value => value === 'shift') }, { ...context, region: 'terminal' }, consume).status).toBe(status) + expect(run).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + expect(consume).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + } +}) + +it('dispatches a Windows Web terminal binding with Control, Meta, and Shift', () => { + const registry = new ShortcutRegistry('web', 'windows') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'web:windows': { code: 'KeyB', modifiers: ['control', 'meta', 'shift'] } }, + regions: ['terminal'], resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, control: true, shift: true }, { ...context, region: 'terminal' }, consume).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consume).toHaveBeenCalledOnce() +}) + +it.each(['pass', 'blocked'] as const)('does not execute a menu command whose owner returns %s', (status) => { + const registry = new ShortcutRegistry('desktop', 'macos') + const resolve = vi.fn(() => status === 'pass' ? { status } : { status, reason: 'unavailable' }) + registry.register(command({ resolve })) + registry.invoke('test.toggle' as ShortcutCommandId, context) + expect(resolve).toHaveBeenCalledWith({ ...context, source: 'menu' }) +}) +const gesture: ShortcutGesture = { code: 'KeyB', control: false, alt: false, shift: false, meta: true, + repeat: false, composing: false, defaultPrevented: false } +function command(overrides: Partial = {}): ShortcutCommand { + return { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: ['toggle'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => {} }), ...overrides } +} + +describe('physical key registry', () => { + it('normalizes primary/control together on Windows and presents platform-specific keys', () => { + const binding = { code: 'KeyB', modifiers: ['primary', 'control', 'shift'] as const } + expect(bindingKey(normalizeBinding(binding, 'windows'))).toBe('control+shift+KeyB') + expect(presentBinding(normalizeBinding(binding, 'macos'), 'macos')).toEqual({ keys: ['⌃', '⇧', '⌘', 'B'], aria: 'Control+Shift+Meta+B' }) + expect(presentBinding(normalizeBinding({ code: 'Slash', modifiers: ['primary'] }, 'windows'), 'windows')) + .toEqual({ keys: ['Ctrl', '+', '/'], aria: 'Control+/' }) + expect(presentBinding(null, 'linux')).toEqual({ keys: [], aria: undefined }) + expect(() => normalizeBinding({ code: 'Unidentified', modifiers: [] }, 'macos')).toThrow('Unsupported') + }) + + it('rejects duplicate ids, cross-platform default conflicts, and unverified Web combinations atomically', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const off = registry.register(command()) + expect(() => registry.register(command())).toThrow('Duplicate') + expect(() => registry.register(command({ id: 'other.toggle' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['control'] }, + } }))).toThrow('windows') + expect(() => registry.register(command({ id: 'web.new' as ShortcutCommandId, + defaults: { + 'web:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'web:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'web:linux': { code: 'KeyN', modifiers: ['primary'] }, + } }))).toThrow('Unsupported Web') + expect(() => registry.register(command({ id: 'system.copy' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyC', modifiers: ['primary'] }, + } }))).toThrow('Reserved') + expect(registry.catalog.getSnapshot()).toHaveLength(1) + const changed = vi.fn() + registry.catalog.subscribe(changed) + off(); off() + expect(changed).toHaveBeenCalledOnce() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch(gesture, context, vi.fn())).toEqual({ status: 'pass' }) + }) + + it('consumes before executing, ignores repeats, and matches every modifier exactly', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const run = vi.fn(() => { expect(consumed).toHaveBeenCalled() }) + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, consumed).status).toBe('handled') + expect(registry.dispatch({ ...gesture, repeat: true }, context, consumed).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consumed).toHaveBeenCalledTimes(2) + const passed = [{ shift: true }, { alt: true }, { control: true }, { meta: false }, { composing: true }, { defaultPrevented: true }] + for (const extra of passed) { + expect(registry.dispatch({ ...gesture, ...extra }, context, consumed).status).toBe('pass') + } + expect(consumed).toHaveBeenCalledTimes(2) + }) + + it('prioritizes desktop bindings in terminals and modals while retaining owner refusal reasons', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const resolve = vi.fn(() => ({ status: 'blocked', reason: 'No target' })) + registry.register(command({ resolve })) + expect(registry.dispatch(gesture, { ...context, modal: 'settings' }, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + expect(resolve).toHaveBeenCalledOnce() + expect(registry.dispatch(gesture, { region: 'terminal', modal: null, target: null }, consumed).status).toBe('blocked') + expect(registry.dispatch(gesture, context, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + resolve.mockReturnValue({ status: 'pass' }) + expect(registry.dispatch(gesture, context, consumed).status).toBe('pass') + expect(consumed).toHaveBeenCalledTimes(3) + }) + + it('publishes stable effective rows with Windows Web three-key defaults', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Settings' + registry.register(command({ label: () => label, defaults: { 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] } } })) + const first = registry.catalog.getSnapshot() + expect(registry.catalog.getSnapshot()).toBe(first) + expect(first[0]?.keys).toEqual(['Ctrl', '+', 'Alt', '+', 'B']) + label = '设置' + registry.refreshLabels() + expect(registry.catalog.getSnapshot()[0]?.label).toBe('设置') + expect(registry.dispatch({ ...gesture, control: true, meta: false }, context, vi.fn()).status).toBe('pass') + }) +}) + +it.each(['Space', 'Escape', 'ArrowUp'])('uses standard ARIA names for %s', (code) => { + expect(presentBinding(normalizeBinding({ code, modifiers: [] }, 'windows'), 'windows').aria).toBe(code) +}) + + +it('keeps commands disabled during startup and publishes config/catalog together before dispatch changes', () => { + const config = initialShortcutConfig() + const registry = new ShortcutRegistry('desktop', 'macos', config) + const run = vi.fn() + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + const snapshot = { ...config, status: 'ready' as const, document: { schemaVersion: 1 as const, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] as const } } } } } + registry.catalog.subscribe(() => { + expect(registry.config.getSnapshot()).toBe(snapshot) + expect(registry.catalog.getSnapshot()[0]?.keys).toEqual(['⌘', 'J']) + }) + registry.configure(snapshot) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() +}) + +it('blocks conflicting dormant overrides when their plugins return, then releases every row on unload', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + registry.configure({ ...initialShortcutConfig(), status: 'ready', document: { schemaVersion: 1, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] }, 'other.toggle': { code: 'KeyJ', modifiers: ['primary'] } } } } }) + const run = vi.fn() + const off = registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + const other = registry.register(command({ id: 'other.toggle' as ShortcutCommandId, defaults: {}, resolve: () => ({ status: 'handled', run }) })) + const consumed = vi.fn() + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, consumed).status).toBe('blocked') + expect(consumed).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + other(); off() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('pass') +}) + +it.each(['macos', 'windows'] as const)('keeps %s priority identical before saving, after saving the same default, and after resetting', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register(command({ regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) })) + for (const [index, modified] of [false, true, false].entries()) { + registry.configure({ ...registry.config.getSnapshot(), revision: `revision-${index}` as never, + document: { schemaVersion: 2, profiles: modified ? { + [`desktop:${platform}`]: { 'test.toggle': { code: 'KeyB', modifiers: ['primary'] } }, + } : {} } }) + expect(registry.catalog.getSnapshot()[0]?.modified).toBe(modified) + const input = { ...gesture, meta: platform === 'macos', control: platform === 'windows' } + for (const region of ['page', 'editable', 'terminal'] as const) { + for (const modal of [null, 'settings']) { + expect(registry.dispatch(input, { region, modal, target: null }, vi.fn()).status).toBe('handled') + } + } + } + expect(run).toHaveBeenCalledTimes(18) +}) diff --git a/packages/client/shortcuts/tests/service.client.spec.ts b/packages/client/shortcuts/tests/service.client.spec.ts new file mode 100644 index 0000000000..103ef2e9fd --- /dev/null +++ b/packages/client/shortcuts/tests/service.client.spec.ts @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import ShortcutsService from '../src/client/index.ts' +import { initialShortcutConfig } from '../src/protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutCommandId } from '../src/protocol.ts' + +const disposers: (() => Promise)[] = [] +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + delete document.documentElement.dataset.platform + Reflect.deleteProperty(window, 'dshDesktop') + localStorage.clear() + vi.restoreAllMocks() +}) +const snapshot = (sequence: number): ShortcutConfigSnapshot => ({ ...initialShortcutConfig(), sequence, status: 'ready' }) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +function desktop() { + document.documentElement.dataset.platform = 'darwin' + let publish!: (value: ShortcutConfigSnapshot) => void + const unsubscribe = vi.fn() + const api = { + get: vi.fn().mockResolvedValue(snapshot(1)), + edit: vi.fn().mockResolvedValue({ status: 'saved', snapshot: snapshot(3) }), + recording: vi.fn().mockResolvedValue(), + subscribe: vi.fn((listener) => { publish = listener; return unsubscribe }), + } + const keyboard = { + closeWindow: vi.fn().mockResolvedValue(), + subscribe: vi.fn(() => () => {}), + } + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api, keyboard } }) + return { api, keyboard, publish: (value: ShortcutConfigSnapshot) => { publish(value) }, unsubscribe } +} +function mount() { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const dispose = async () => { await ctx.fiber.dispose() } + disposers.push(dispose) + const service = new ShortcutsService(ctx) + return { ctx, service, dispose } +} + +it('closes the Desktop window with the latest accepted revision and propagates bridge errors', async () => { + const f = desktop() + const { service } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const latest = snapshot(10) + f.publish(latest) + await service.closeWindow() + expect(f.keyboard.closeWindow).toHaveBeenCalledExactlyOnceWith(latest.revision) + const failure = new Error('Window unavailable') + f.keyboard.closeWindow.mockRejectedValueOnce(failure) + await expect(service.closeWindow()).rejects.toBe(failure) +}) + +it('rejects a native window close outside Desktop', async () => { + const { service } = mount() + await expect(service.closeWindow()).rejects.toThrow('Desktop keyboard bridge unavailable') +}) + +it('describes registered binding conflicts on Linux Desktop', async () => { + desktop() + document.documentElement.dataset.platform = 'linux' + const { service } = mount() + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([command.id]) + off() + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([]) +}) +const command = { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const }, + }, regions: ['page'] as const, modals: [], + resolve: () => ({ status: 'handled' as const, run() {} }) } + +it('waits for the Desktop handshake and ignores older get, edit, and broadcast replies', async () => { + const f = desktop(), first = deferred() + f.api.get.mockReturnValueOnce(first.promise) + const { service } = mount() + f.publish(snapshot(20)) + expect(service.config.getSnapshot().status).toBe('loading') + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + f.publish(snapshot(10)); first.resolve(snapshot(1)); await first.promise + expect(service.config.getSnapshot().sequence).toBe(10) + f.publish(snapshot(9)) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(10) + f.api.edit.mockResolvedValueOnce({ status: 'saved', snapshot: snapshot(12) }) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(12) + await service.recording(true); expect(f.api.recording).toHaveBeenCalledWith(true) + off() + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it.each(['darwin', 'win32', 'linux'])('rejects %s Desktop startup without its native keyboard bridge', (platform) => { + const { api } = desktop() + document.documentElement.dataset.platform = platform + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + expect(() => mount()).toThrow('Desktop keyboard bridge unavailable') + expect(api.get).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('reports unavailable Desktop storage without reading or writing localStorage', async () => { + const { keyboard } = desktop() + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { keyboard } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + const { service } = mount() + expect(service.config.getSnapshot().status).toBe('unreadable') + expect((await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)).status).toBe('unreadable') + await expect(service.recording(true)).rejects.toThrow('bridge unavailable') + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('retains accepted bindings when the Desktop bridge rejects reads and writes', async () => { + const f = desktop(); const { service } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('IPC disconnected') + f.api.edit.mockRejectedValueOnce(failure) + const accepted = service.config.getSnapshot() + expect((await service.edit({ type: 'reset-all' }, accepted.revision)).status).toBe('write-failed') + expect(service.config.getSnapshot()).toBe(accepted) + expect(error).toHaveBeenCalledExactlyOnceWith('Shortcut preference save failed', failure) + f.api.get.mockRejectedValueOnce(new Error('handshake failed')) + service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().error).toBe('read') }) +}) + +it.each(['resolve', 'reject'] as const)('suppresses %s completions after disposal and releases the bridge subscription', async (outcome) => { + const f = desktop(), pending = deferred() + f.api.get.mockReturnValue(pending.promise) + f.api.edit.mockImplementation(async () => ({ status: 'saved', snapshot: await pending.promise })) + const { service, dispose } = mount() + const off = service.register(command), edit = service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + const accepted = service.config.getSnapshot() + await dispose(); off(); f.publish(snapshot(50)) + if (outcome === 'resolve') pending.resolve(snapshot(30)) + else pending.reject(new Error('window closed')) + await edit + expect(service.config.getSnapshot()).toBe(accepted) + expect(f.unsubscribe).toHaveBeenCalledOnce() +}) + +it('persists Web edits and presents normalized bindings through the public service', async () => { + const { service, ctx } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding(null)).toEqual({ binding: null, keys: [], issue: null, conflicts: [] }) + expect(service.describeBinding({ code: 'Slash', modifiers: ['primary'] })) + .toMatchObject({ issue: null, binding: { code: 'Slash', modifiers: ['control'] } }) + const off = service.register({ ...command, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + } }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const changed = vi.fn(); service.catalog.subscribe(changed) + ctx.locale.setLocale('zh'); expect(changed).toHaveBeenCalled() + expect((await service.edit({ type: 'set', id: command.id, binding: null }, service.config.getSnapshot().revision)).status).toBe('saved') + expect(service.catalog.getSnapshot()[0]?.keys).toEqual([]) + await service.recording(true); off() +}) + +it('publishes fixed reservations to Desktop and reports their conflicts while their owner is mounted', () => { + const f = desktop(), { service } = mount() + const off = service.registerFixed({ id: 'menu.close' as ShortcutCommandId, label: () => 'Close', keys: ['Esc'], + bindings: [{ code: 'Escape', modifiers: [] }], group: 'menus' }) + expect(f.api.get).toHaveBeenLastCalledWith([{ id: 'menu.close', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'KeyA', secondCode: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'Escape', modifiers: ['alt'] }).conflicts).toEqual([]) + off() + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual([]) + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it('propagates catalog publication failures after a successful preference save', async () => { + const f = desktop() + const { service } = mount() + const label = vi.fn(() => 'Toggle') + service.register({ ...command, label }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + label.mockImplementationOnce(() => { throw new Error('Command label unavailable') }) + await expect(service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)) + .rejects.toThrow('Command label unavailable') + expect(f.api.edit).toHaveBeenCalledOnce() +}) diff --git a/packages/client/shortcuts/tests/storage.client.spec.ts b/packages/client/shortcuts/tests/storage.client.spec.ts new file mode 100644 index 0000000000..1c4afce2a9 --- /dev/null +++ b/packages/client/shortcuts/tests/storage.client.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { webShortcutStorage, SHORTCUT_STORAGE_KEY } from '../src/client/storage.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '../src/protocol.ts' + +afterEach(() => { localStorage.clear(); vi.restoreAllMocks() }) +const id = 'shortcuts.open' as ShortcutCommandId +const definitions = [{ id, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] as const }, +} }] + +it('syncs same-origin updates, rejects stale drafts, and releases the storage listener', async () => { + let latest!: ShortcutConfigSnapshot + const publish = vi.fn((value: ShortcutConfigSnapshot) => { latest = value }) + const adapter = webShortcutStorage(window, 'macos', publish) + try { + const initial = await adapter.get(definitions) + adapter.subscribe(publish)(); await adapter.recording(true) + publish.mockClear() + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated' })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { [id]: null } } })) + window.dispatchEvent(new StorageEvent('storage', { key: SHORTCUT_STORAGE_KEY })) + await vi.waitFor(() => { expect(latest.document.profiles['web:macos']?.[id]).toBeNull() }) + expect((await adapter.edit({ type: 'reset', id }, initial.revision)).status).toBe('stale') + expect((await adapter.edit({ type: 'reset', id }, latest.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(localStorage.getItem(SHORTCUT_STORAGE_KEY)!) + expect(stored).toMatchObject({ profiles: { 'web:macos': {} } }) + adapter.dispose(); publish.mockClear() + localStorage.removeItem(SHORTCUT_STORAGE_KEY) + window.dispatchEvent(new StorageEvent('storage', { key: null })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + } finally { adapter.dispose() } +}) + +it('retains drafts after quota failure and refuses to replace future data', async () => { + let latest!: ShortcutConfigSnapshot + const adapter = webShortcutStorage(window, 'windows', (value) => { latest = value }) + try { + await adapter.get(definitions) + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new Error('quota') }) + expect((await adapter.edit({ type: 'set', id, binding: null }, latest.revision)).status).toBe('write-failed') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBeNull() + write.mockRestore() + const raw = '{"schemaVersion":9}' + localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) + await adapter.get(definitions) + expect((await adapter.edit({ type: 'reset-all' }, latest.revision)).status).toBe('unreadable') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBe(raw) + + } finally { adapter.dispose() } +}) diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json new file mode 100644 index 0000000000..6157c1a154 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.client.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/values" + } + ], + "exclude": [ + "src/index.ts" + ] +} diff --git a/packages/client/shortcuts/tsconfig.host.json b/packages/client/shortcuts/tsconfig.host.json new file mode 100644 index 0000000000..21e5b991ed --- /dev/null +++ b/packages/client/shortcuts/tsconfig.host.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "include": [ + "src/protocol.ts", + "src/native.ts", + "src/binding.ts", + "src/configuration.ts", + "src/persistence.ts", + "src/index.ts", + "src/config.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../util/values" + } + ] +} diff --git a/packages/client/shortcuts/tsconfig.json b/packages/client/shortcuts/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts new file mode 100644 index 0000000000..4f32cd61c0 --- /dev/null +++ b/packages/client/shortcuts/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-client-shortcuts', + ['lib/types/index.js', 'lib/types/protocol.js'], + { hostPhase: true }, +) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 99e647d2a1..215001dbb9 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -61,7 +61,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d15cb50649..ec23cac75e 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -16,7 +16,7 @@ export interface AgentPresetSectionInjected { hooks: { agentPresetSection: SnapshotStore /** Shared preference controlling the picker-policy row. */ - developerTools: ObservableSnapshot + showPickerPolicy: ObservableSnapshot } /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void @@ -63,10 +63,10 @@ function CardDescription({ text }: { text: string }): ReactNode { */ export function AgentPresetSection({ useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft, - close: closeSettings, useDeveloperTools, t, + close: closeSettings, useShowPickerPolicy, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) - const developerTools = useDeveloperTools(enabled => enabled) + const showPickerPolicy = useShowPickerPolicy(enabled => enabled) const [guide, setGuide] = useState<{ content: NonNullable> page: PresetGuidePage @@ -102,7 +102,7 @@ export function AgentPresetSection({ return

{t('nav')}

{t('sectionIntro')}

- {developerTools && ( + {showPickerPolicy && (
diff --git a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx index 310d397dbc..2811594e13 100644 --- a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx @@ -95,11 +95,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: { const content = useRef(null) useLayoutEffect(() => { - const previous = document.activeElement content.current?.querySelector('[role="tab"][aria-selected="true"]')?.focus() - return () => { - if (previous instanceof HTMLElement) previous.focus() - } }, []) // This reader sits above Settings. Keep keyboard navigation in the reader, diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index a253ac3888..700da800ff 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -198,7 +198,7 @@ export function apply(ctx: ClientContext): void { } const sectionInjected = (): AgentPresetSectionInjected => ({ - hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled }, + hooks: { agentPresetSection: section.store, showPickerPolicy: ctx.configForms.developerTools.enabled }, load: () => section.load(), view: (id: string) => section.view(id), closeView: () => { section.closeView() }, diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 8bad88af50..b357d09004 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -12,7 +12,7 @@ const translations: ReadonlyMap = new Map(Object.entries(en)) function unusedHook(): never { throw new Error('This section does not read global slot sources') } -function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true, +function view(partial: Partial = {}, startCreatorDraft?: () => void, showPickerPolicy = true, outerClose?: () => void) { const store = createSnapshotStore({ status: 'ready', error: null, showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], @@ -24,7 +24,7 @@ function view(partial: Partial = {}, startCreatorDraft? usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, useAgentPresetSection: bindSnapshotSelector(store), - useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)), + useShowPickerPolicy: bindSnapshotSelector(createSnapshotStore(showPickerPolicy)), t: key => translations.get(key) ?? key } render(outerClose === undefined ? : ) @@ -181,11 +181,12 @@ it.each([ expect(actions.makeDefault).not.toHaveBeenCalled() }) it('keeps keyboard focus in help and dismisses only the reader on Escape', () => { - const actions = view() + const closeSettings = vi.fn() + const actions = view({}, undefined, true, closeSettings) const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) trigger.focus() fireEvent.click(trigger) - const dialog = screen.getByRole('dialog') + const dialog = screen.getByRole('dialog', { name: en.presetStandardName }) const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) const panel = within(dialog).getByRole('tabpanel', { name: en.modeExplanation }) const close = within(dialog).getByRole('button', { name: en.close }) @@ -197,8 +198,10 @@ it('keeps keyboard focus in help and dismisses only the reader on Escape', () => fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) expect(document.activeElement).toBe(panel) fireEvent.keyDown(panel, { key: 'Escape' }) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('dialog', { name: en.presetStandardName })).toBeNull() + expect(screen.getByRole('dialog', { name: 'Settings' })).toBeTruthy() expect(document.activeElement).toBe(trigger) + expect(closeSettings).not.toHaveBeenCalled() expect(actions.close).not.toHaveBeenCalled() }) it('connects keyboard selection to the visible guide panel', () => { @@ -230,7 +233,13 @@ it('leaves help usable when mode selection is disabled', () => { expect(actions.setPickerVisible).not.toHaveBeenCalled() }) it('closes help even when the browser reports no previously focused element', () => { - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + const descriptor: TypedPropertyDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement')! + const readActiveElement = descriptor.get!.bind(document) + // Only the initial unfocused body is absent; modal controls must observe subsequent focus. + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockImplementation(() => { + const focused = readActiveElement() + return focused === document.body ? null : focused + }) try { view() fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index 167ac6479d..efbfdf2cb6 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -12,8 +12,8 @@ en: 8d6c7c6e63f849f6 zh: d89fa273e30b99d3 /deepseek-ai-dsh-client-ui-approval/table-of-contents: - en: b4683d2a53256fda - zh: 4e65ff63dc8c48fe + en: 2bfa677a50bd924c + zh: 2f4b55ab42747bea /deepseek-ai-dsh-client-ui-approval/model-experience: en: 8161dce49e9f899c zh: a17401e1e78ba6be diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index 1d5a4a5683..ce2ccdbbec 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -18,6 +18,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The ----- +Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement. + ## Model Experience diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index f5f932ed59..d7a94ae915 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -18,6 +18,8 @@ kind: "package-reference" ----- +聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。 + ## 模型体验 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index c832a37287..294db17fa3 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -60,7 +60,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index a7c0b33477..9dce13573c 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,5 +1,5 @@ /** Composer takeover for one pending approval waterfall. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -23,12 +23,43 @@ function ApprovalFlow({ pending, detail, t }: { t: ApprovalComposerProps['t'] }) { const [answered, setAnswered] = useState(false) + const waiting = useRef(false) + const active = useRef(true) + const composing = useRef(false) + const compositionEnded = useRef(false) + useEffect(() => { + active.current = true + return () => { active.current = false } + }, []) const answer = (outcome: 'allowed-once' | 'rejected'): void => { + if (waiting.current || !pending.answerable) return + waiting.current = true setAnswered(true) - void pending.answer(outcome).catch(() => { setAnswered(false) }) + void pending.answer(outcome).catch(() => { + if (!active.current || !pending.answerable) return + waiting.current = false + setAnswered(false) + }) + } + const keydown = (event: KeyboardEvent): void => { + const element = event.target as Element + if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) + || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return + if (event.key !== 'Enter' && event.key !== 'Escape') return + if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + event.preventDefault() + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return + answer(event.key === 'Enter' ? 'allowed-once' : 'rejected') } return ( -
+
{ compositionEnded.current = false }} + onCompositionStartCapture={() => { composing.current = true }} + onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}>
{t('waiting')}
ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + ctx.inject(['shortcuts'], (scope) => { + const t = ctx.locale.bind(NS) + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.allow' as ShortcutCommandId, label: () => t('allowOnce'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed allow reference') + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.reject' as ShortcutCommandId, label: () => t('reject'), keys: ['Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed reject reference') + }) const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( () => 0, ) diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 7ae0dbc11a..6e18ace13c 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' @@ -40,7 +40,7 @@ interface PluginBench { } } -function setupPlugin(): PluginBench { +async function setupPlugin(): Promise { const ctx = new Context() let listener: ApprovalListener | undefined let registration: { @@ -84,7 +84,8 @@ function setupPlugin(): PluginBench { register: vi.fn(() => disposeLocale), } as never) - apply(ctx) + const fiber = ctx.plugin({ apply }) + await fiber.await() if (listener === undefined) throw new Error('approval listener was not registered') return { ctx, @@ -198,7 +199,7 @@ describe('PendingApproval', () => { describe('approval Remote Event consumer', () => { it('delegates an event that has no Agent scope', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next)) @@ -209,7 +210,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -245,7 +246,7 @@ describe('approval Remote Event consumer', () => { }) it('propagates request cancellation after removing the pending object', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -265,7 +266,7 @@ describe('approval Remote Event consumer', () => { }) it('delegates an active request when its interaction domain unloads', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) @@ -281,7 +282,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes a scoped request without optional request metadata', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable')) @@ -297,7 +298,7 @@ describe('approval Remote Event consumer', () => { }) it('removes stable registrations with the plugin lifetime', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() await bench.ctx.fiber.dispose() expect(bench.disposeSlot).toHaveBeenCalledOnce() expect(bench.disposeLocale).toHaveBeenCalledOnce() @@ -361,6 +362,102 @@ describe('ApprovalPanel', () => { await expect(pending.result).resolves.toBe('allowed-once') }) + it.each([['Enter', 'allowed-once'], ['Escape', 'rejected']] as const)( + 'answers the focused container with %s through the pending request', async (key, outcome) => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + const bubbled = vi.fn() + window.addEventListener('keydown', bubbled) + try { + fireEvent.keyDown(group, { key, code: key }) + await expect(pending.result).resolves.toBe(outcome) + expect(bubbled).not.toHaveBeenCalled() + expect(pending.answerable).toBe(false) + } finally { window.removeEventListener('keydown', bubbled) } + }, + ) + + it('leaves button Enter to its native click and rejects from the reject button', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const reject = screen.getByRole('button', { name: 'Reject' }) + reject.focus() + expect(fireEvent.keyDown(reject, { key: 'Enter', code: 'Enter' })).toBe(true) + expect(pending.answerable).toBe(true) + // jsdom does not synthesize the browser's native button activation. + fireEvent.click(reject) + await expect(pending.result).resolves.toBe('rejected') + }) + + it('ignores unowned input, modified keys, repeats and IME candidate keys', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash', callId: 'call-1' as ToolCallId }) + const renderSlot = () => + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + expect(pending.answerable).toBe(true) + const input = screen.getByRole('textbox', { name: 'Approval input' }) + input.focus() + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }) + group.focus() + expect(fireEvent.keyDown(group, { key: 'Tab', code: 'Tab' })).toBe(true) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', ctrlKey: true }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape', repeat: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', isComposing: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', keyCode: 229 }) + fireEvent.compositionStart(group) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.compositionEnd(group) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + expect(pending.answerable).toBe(true) + fireEvent.keyUp(group, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + await expect(pending.result).resolves.toBe('allowed-once') + }) + + it('locks keyboard and click submissions synchronously while the answer is pending', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + const answer = vi.spyOn(pending, 'answer').mockImplementation(() => gate.promise) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).toHaveBeenCalledExactlyOnceWith('allowed-once') + await act(async () => { gate.resolve(undefined); await gate.promise }) + pending.abort(new Error('test cleanup')) + await pending.result.catch(() => {}) + }) + + it('does not settle an aborted request or let its late rejection unlock its replacement', async () => { + const first = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + vi.spyOn(first, 'answer').mockImplementation(() => gate.promise) + const view = render() + let group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + first.abort(new Error('request withdrawn')) + await first.result.catch(() => {}) + const second = new PendingApproval(id('s1'), { toolName: 'read' }) + view.rerender() + await act(async () => { gate.reject(new Error('late transport failure')); await gate.promise.catch(() => {}) }) + expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(false) + second.abort(new Error('request withdrawn')) + await second.result.catch(() => {}) + const answer = vi.spyOn(second, 'answer') + group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).not.toHaveBeenCalled() + }) + it('re-enables actions when answering fails', async () => { const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed')) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json index 6c10dfc080..5bad85d9c5 100644 --- a/packages/client/ui-approval/tsconfig.json +++ b/packages/client/ui-approval/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../ui-slots" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c421ea42bd..b7cba8646d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,8 +18,8 @@ en: 3fda3d75483ce7c1 zh: 4d96ba654f70c021 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: 4e4ab65647a6dbfb - zh: 2ec5cd425c13440a + en: 5706ae03ceacf251 + zh: 2e59d0815351349f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: en: e2e86d9eec9130b5 zh: 6ee831c996670630 @@ -45,8 +45,8 @@ en: 15a7151ff9268f15 zh: 6eae68c718be579b /deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work: - en: 69dd0f47c88ef020 - zh: 0ed3b048ed0c96d5 + en: 1e7e55adf5c87246 + zh: dd5befdf58ea084f /deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work/dev-note: en: b8b7eeb4ae675d2a zh: fbe7022074ba81b9 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f6924e91b2..9949d6d50f 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -40,7 +40,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat. +Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat. When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. @@ -154,7 +154,7 @@ None; Chat presentation does not assemble or mutate provider requests. -- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row. +- **Tool-change presentation** — The `developer-message` Definition shares context presentation with `input-message`. Tool-only developer messages name a single added or removed tool inline without expansion. Multiple changes show added/removed counts and expand to comma-separated tool lists, one line per change kind. Mixed content uses the generic context presentation. - **Opening echoes predict local order** — several submissions made before the running update can all remain in Chat. Their initial order follows local submission order, not Host queue order; admission can reposition them when the Host receives requests in a different order. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 0834d1f556..35fe11dff6 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## Chat 隐藏的行 -Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -154,7 +154,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 -- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。 +- **工具变更展示** — `developer-message` Definition 与 `input-message` 共用上下文展示。仅包含工具变更的 developer 消息在单个工具新增或移除时直接显示工具名,不提供展开操作。多个变更显示新增、移除数量,展开后按变更类型分别以逗号分隔显示一行工具列表。混合内容使用通用上下文展示。 - **开场回显预测本地顺序**——运行状态更新前连续发出的多条消息可能都留在 Chat。初始排列遵循本地提交顺序,而非 Host 队列顺序;Host 按不同顺序接收请求时,入档可能调整它们的位置。 diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css index 788322535c..961163f488 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css @@ -62,3 +62,7 @@ /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */ font: 400 11px/16px var(--ds-font-family-code); } + +.toolChanges { + white-space: nowrap; +} diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index 49704cca03..742b746090 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconBrowseOutlineRegular, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ContextMessageNode } from '../contract/snapshot.ts' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -34,15 +34,33 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont // the opaque body, and the marker must say what the row actually shows. const { rendered, summary, body } = contextBody(form, { content, source, t }) + const toolBlocks = content.length > 0 + && content.every(block => block.type === 'tool-addition' || block.type === 'tool-removal') + ? content : undefined + const added = toolBlocks?.flatMap(block => block.type === 'tool-addition' ? [block.toolName] : []) ?? [] + const removed = toolBlocks?.flatMap(block => block.type === 'tool-removal' ? [block.toolName] : []) ?? [] + const single = toolBlocks?.length === 1 ? toolBlocks[0] : undefined + const toolSummary = toolBlocks === undefined || single !== undefined ? null + : added.length > 0 && removed.length > 0 + ? t('message.toolsChanged', { added: added.length, removed: removed.length }) + : added.length > 0 + ? t('message.toolsAddedCount', { count: added.length }) + : t('message.toolsRemovedCount', { count: removed.length }) + return ( : producer.role === 'recall' ? : } chevronClassName={css.chevron} - title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} - collapsedContent={producer.label === null ? undefined : ( + title={single !== undefined ? t(single.type === 'tool-addition' ? 'message.toolAdded' : 'message.toolRemoved', { name: single.toolName }) : t(toolBlocks !== undefined ? 'message.toolsUpdated' : producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} + collapsedContent={toolSummary !== null ? ( + <> + + {toolSummary} + + ) : toolBlocks !== undefined || producer.label === null ? undefined : ( /* ToolRow's separator shape: an aria-hidden dot, so the accessible name stays the two readable parts and the two disclosure rows expose one name shape. A source that names no producer drops the dot with it. */ @@ -58,13 +76,18 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont )} keepContentWhenOpen - open={open} - expandable + open={open && single === undefined} + expandable={single === undefined} expandOnRowClick onToggle={() => { setOpen(value => !value) }} >
- {body} + {toolBlocks === undefined ? body : ( +
+ {added.length > 0 &&
{t('message.toolsAdded', { names: added.join(', ') })}
} + {removed.length > 0 &&
{t('message.toolsRemoved', { names: removed.join(', ') })}
} +
+ )}
) diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts index b3273f77ae..2fd60b4b1d 100644 --- a/packages/client/ui-chat/src/client/contract/chat-visibility.ts +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -3,12 +3,14 @@ import type { ChatNode } from './chat-nodes.ts' /** * Exclude system prompts, ordinary Context, and permission commands from visible Chat rows. + * Context containing tool changes retains its notice row. * @param node - projected Chat node. * @returns whether the node contributes a visible Chat row. */ export function isVisibleChatNode(node: ChatNode): boolean { return node.visibility === 'visible' && node.kind !== 'system-prompt' - && node.kind !== 'context' + && (node.kind !== 'context' + || node.data.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) && !(node.kind === 'command' && node.data.name === 'permission') } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 4181a40bcb..ddfffb0cca 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -42,6 +42,22 @@ function isCompactionCheckpoint(event: Parameters, + message: Pick, +): ContextMessageNode { + return { + kind: 'context', + seq: event.seq, + time: event.time, + content: message.content, + source: message.source, + producer: contextProducer(message.source), + form: contextForm(message.source), + } +} + /** User, steering, and injected-context message classification Definition. */ export const messageDefinition: ConversationNodeDefinition = { kind: 'input-message', @@ -52,13 +68,11 @@ export const messageDefinition: ConversationNodeDefinition = { ? { id: String(event.data.id), role: 'start' } : null } - // Developer history is persisted for V4; presentation is intentionally deferred. - if (event.type === 'developer/message') throw new Error('Chat developer messages are not supported yet') return null }, start: (_context, match, reader) => { - if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') const event = match.event + if (event.type !== 'user/message') throw new Error('input-message start requires user/message') if (event.data.source.kind !== 'user') { const nextTurn = reader.previous('inbox-next-turn')?.state const nextStep = reader.previous('inbox-next-step')?.state @@ -72,14 +86,8 @@ export const messageDefinition: ConversationNodeDefinition = { && nextStep.currentClaimed.has(String(event.data.id)) return { - kind: 'context', + ...contextMessage(event, event.data), waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer, - seq: event.seq, - time: event.time, - content: event.data.content, - source: event.data.source, - producer: contextProducer(event.data.source), - form: contextForm(event.data.source), } } const claimed = reader.previous('inbox-next-step') @@ -111,10 +119,25 @@ export const messageDefinition: ConversationNodeDefinition = { }, } +/** Developer history uses the input-message lifecycle and context presentation. */ +export const developerMessageDefinition: ConversationNodeDefinition = { + ...messageDefinition, + kind: 'developer-message', + match: event => event.type === 'developer/message' + ? { id: String(event.data.message.id), role: 'start' } + : null, + start: (_context, match) => { + const event = match.event + if (event.type !== 'developer/message') throw new Error('developer-message start requires developer/message') + return contextMessage(event, event.data.message) + }, +} + /** - * Register the user, steering, and injected-context message contribution. + * Register user, steering, injected-context, and developer message contributions. * @param ctx - owning UI Conversation context. */ export function registerMessageConversationNode(ctx: Context): void { ctx.uiConversation.events.register(messageDefinition) + ctx.uiConversation.events.register(developerMessageDefinition) } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 0f060f74da..2007ebbc18 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -111,6 +111,14 @@ export const zh = { 'message.extraBlock': '附加内容块', 'message.systemPrompt': '系统提示词', 'message.systemPromptUpdate': '系统提示词更新', + 'message.toolAdded': '已添加工具:{name}', + 'message.toolRemoved': '已移除工具:{name}', + 'message.toolsAdded': '新增:{names}', + 'message.toolsAddedCount': '新增 {count} 个', + 'message.toolsChanged': '新增 {added} 个,移除 {removed} 个', + 'message.toolsRemoved': '移除:{names}', + 'message.toolsRemovedCount': '移除 {count} 个', + 'message.toolsUpdated': '工具已更新', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', @@ -295,6 +303,14 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.systemPrompt': 'System prompt', 'message.systemPromptUpdate': 'System prompt update', + 'message.toolAdded': 'Tool added: {name}', + 'message.toolRemoved': 'Tool removed: {name}', + 'message.toolsAdded': 'Added: {names}', + 'message.toolsAddedCount': '{count} added', + 'message.toolsChanged': '{added} added, {removed} removed', + 'message.toolsRemoved': 'Removed: {names}', + 'message.toolsRemovedCount': '{count} removed', + 'message.toolsUpdated': 'Tools updated', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index e3261380b2..f948d14450 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -22,7 +22,7 @@ import { commandDefinition } from '../src/client/conversation-nodes/command.ts' import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' -import { messageDefinition } from '../src/client/conversation-nodes/message.ts' +import { developerMessageDefinition, messageDefinition } from '../src/client/conversation-nodes/message.ts' import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts' import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' @@ -39,6 +39,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ nextStepInboxDefinition, nextTurnInboxDefinition, messageDefinition, + developerMessageDefinition, systemMessageDefinition(inspectSystemPrompt), requestPromptDefinition(inspectRequestPrompt), assistantDefinition, @@ -224,11 +225,51 @@ function toolResult(callId: string, text: string, isError = false) { } describe('built-in conversation node Definitions', () => { - it('rejects developer history until presentation is implemented', () => { - expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: { + it.each(['replay', 'live', 'prepend'] as const)('keeps developer tool changes visible while hiding ordinary Context (%s)', (mode) => { + const entries = [ + at(0, 'request/header', { reason: 'initial', header: { + config: { provider: 'test', model: 'test' }, + tools: ['search', 'read_file'].map(name => ({ name, description: '', parameters: {} })), + } }), + at(1, 'user/message', { + ...textMessage('context', 'workspace context'), + source: { kind: 'context' }, + }, { surfaceOp: 'append' }), + at(2, 'developer/message', { turn: 1, step: 1, message: { + id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, + content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }], + } }, { surfaceOp: 'append' }), + at(3, 'request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [] } }), + ] + const value = assembler(mode === 'replay' ? entries : []) + if (mode === 'live') { + for (const entry of entries) { + value.append(entry) + value.flush() + } + } else if (mode === 'prepend') { + value.replaceWindow(entries.slice(2), true) + value.prepend(entries.slice(0, 2), false) + value.flush() + } + const current = snapshot(value) + const visible = current.order.map(key => current.nodes.get(key)) + expect(visible.map(candidate => candidate?.kind)).toEqual(['context']) + expect(visible[0]?.data).toMatchObject({ + kind: 'context', content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }], + }) + expect(current.nodes.values().filter(candidate => candidate.kind === 'context')).toHaveLength(2) + }) + + it('preserves tool removals without a loaded header', () => { + const value = assembler([at(1, 'developer/message', { turn: 1, step: 1, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, - content: [{ type: 'tool-addition', toolName: 'search' }], - } }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet') + content: [{ type: 'tool-removal', toolName: 'old_search' }], + } }, { surfaceOp: 'append' })], true) + const current = snapshot(value) + const removal = node(current, 'context') + expect(removal?.data).toMatchObject({ content: [{ type: 'tool-removal', toolName: 'old_search' }] }) + expect(current.order).toContain(removal?.key) }) it('rejects an unrelated event passed directly to the request-prompt start', () => { diff --git a/packages/client/ui-chat/tests/tool-updates.client.spec.tsx b/packages/client/ui-chat/tests/tool-updates.client.spec.tsx new file mode 100644 index 0000000000..82e9301339 --- /dev/null +++ b/packages/client/ui-chat/tests/tool-updates.client.spec.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +/** Tool updates share the context disclosure while keeping names and counts readable. */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { ContextInjectionRow } from '../src/client/chat/ContextInjectionRow.tsx' +import { en, zh } from '../src/client/locale.ts' + +afterEach(cleanup) + +describe.each([ + { locale: 'en', t: makeTranslate(en, commonEn), title: 'Tools updated', summary: '2 added, 1 removed', added: 'Added: search, read_file', removed: 'Removed: old_search' }, + { locale: 'zh', t: makeTranslate(zh, commonZh), title: '工具已更新', summary: '新增 2 个,移除 1 个', added: '新增:search, read_file', removed: '移除:old_search' }, +])('tool update disclosure ($locale)', ({ t, title, summary, added, removed }) => { + it('shows counts collapsed and compact name lists when expanded', () => { + render() + const row = screen.getByRole('button', { name: new RegExp(title) }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText(summary)).toBeTruthy() + expect(screen.queryByText('tool-registry')).toBeNull() + fireEvent.click(row) + expect(screen.getByText(added)).toBeTruthy() + expect(screen.getByText(removed)).toBeTruthy() + expect(screen.queryByText(/Active tools:/)).toBeNull() + }) +}) + +it.each([ + ['tool-addition', 'Tool added: last_tool'], + ['tool-removal', 'Tool removed: last_tool'], +] as const)('names a single %s without a disclosure', (type, label) => { + render() + expect(screen.getByText(label)).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + fireEvent.click(screen.getByText(label)) + expect(screen.queryByText(/Active tools:/)).toBeNull() +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2dc34ab2f7..8b219abf38 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -15,11 +15,11 @@ en: 9c1c4905952fea5e zh: d1506dd6fc1018d1 /deepseek-ai-dsh-client-ui-conversation/conversation-assembly: - en: 8fb6c43d85343eab - zh: 501ec3f392b10898 + en: 13ae68865fa315d7 + zh: c152f020abf20f35 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 9e842e5f1e311b86 - zh: cb0103d380a52629 + en: 8de4fbd9df016e06 + zh: 06b15ab7855120cb /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4283d902f5..7c7739fee9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. +`ConversationBinding.openTurn` is an identity-stable, read-only source of the latest turn number when its start is loaded and it remains open, or `undefined` otherwise. Observed turn changes publish synchronously even when no View is active. Fixed stop input reads this source alongside Session running and pending-interaction state. Unloading the Session binding detaches its event-source subscription. + The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Definitions use the same match/start/update interface for durable and transient events. The earliest loaded start initializes State; every later Match, including another start for that identity, updates it. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and recomputes the affected Contexts from their remaining earliest start, refreshing predecessor indexes and dependents without replacing unrelated target nodes. A Context with no remaining start has no State. Its key and previously published nodes remain available to later evidence until a full window rebuild; the owning Definition may hide those nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. @@ -50,7 +52,7 @@ With Developer tools off, the shell hides only Trajectory; other plugin-contribu The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. -`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. +`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. @@ -70,6 +72,8 @@ The width-handle indicator follows the pointer only during a captured drag; ordi The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. + Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. @@ -80,6 +84,8 @@ File chips and editable skill references share a whole-reference hover backgroun When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry. +Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. The plugin registers Stop as a fixed action in the `application` display group. Its registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip. + ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 72493ef348..88ac5cb99f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 +`ConversationBinding.openTurn` 是对象标识稳定的只读来源:最新轮次的开始事件已加载且尚未结束时提供其编号,否则为 `undefined`。观察到的轮次变化同步发布,即使没有活动视图也是如此。固定停止输入同时读取这个来源、会话运行状态和待处理交互状态。卸载会话绑定会解除其事件源订阅。 + 适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。持久与瞬态事件使用同一组 match/start/update 接口。当前最早的 start 初始化 State;后续所有 Match,包括同一身份的其他 start,都用于更新。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并从剩余最早的 start 重算受影响的 Context,刷新前序索引及 dependent,不替换无关 target node。没有剩余 start 的 Context 不保留 State,但其 key 和已发布节点仍保留,供后续证据复用,直到完整窗口重建;所属 Definition 可以隐藏这些节点。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 @@ -50,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 -`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 +`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 @@ -70,6 +72,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 + 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 @@ -80,6 +84,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切 当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。 +在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `application` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。 + ## 临时 composer entry diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 42491afb46..4eabebefdf 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -95,7 +95,8 @@ "@lexical/plain-text": "^0.49.0", "@lexical/history": "^0.49.0", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-settings": "workspace:*" + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f4a5ff4780..550515deda 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { @@ -38,7 +39,8 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' -import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' +import { installStopShortcut } from './stop-shortcut.ts' +import { TRAJECTORY_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -179,7 +181,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- list registration validates id at load. */ if (entry.options.id === undefined) continue - if (!ctx.configForms.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue + if (!ctx.configForms.developerTools.enabled.getSnapshot() && entry.options.id === TRAJECTORY_VIEW_ID) continue tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id, @@ -225,6 +227,43 @@ export function apply(ctx: Context, config: Config = Config({})): void { } }, 'ui-conversation: View selection') + const stop = (sessionId: SessionId): void => { + scopedConversation(sessions, sessionId).cancel().catch((_error: unknown) => { + // Stop failure is published through Session promptError. + }) + } + const stopShortcut = createSnapshotStore([]) + ctx.inject(['shortcuts'], (scope) => { + const fixedInputs: readonly ShortcutFixedCommand[] = [ + { id: 'fixed.send' as ShortcutCommandId, label: () => t('input.send'), keys: ['Enter'], + bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' }, + { id: 'fixed.newline' as ShortcutCommandId, label: () => t('shortcut.newline'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['shift'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['shift'] }], group: 'input' }, + { id: 'fixed.complementary' as ShortcutCommandId, label: () => t('shortcut.complementary'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['primary'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['control'] }, { code: 'Enter', modifiers: ['meta'] }], group: 'input' }, + { id: 'fixed.slash' as ShortcutCommandId, label: () => t('shortcut.slash'), keys: ['/'], + bindings: [{ code: 'Slash', modifiers: [] }], group: 'input' }, + { id: 'fixed.mention' as ShortcutCommandId, label: () => t('shortcut.mention'), keys: ['@'], + bindings: [{ code: 'Digit2', modifiers: ['shift'] }], group: 'input' }, + ] + for (const command of fixedInputs) { + scope.effect(() => scope.shortcuts.registerFixed(command), `ui-conversation: ${command.id}`) + } + scope.effect(() => installStopShortcut( + scope.shortcuts, sessions, binding => uiConversation.binding(binding).openTurn, ctx.uiSession, stop, + ), 'ui-conversation: fixed stop input') + scope.effect(() => { + const command: ShortcutFixedCommand = { + id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application', + } + const dispose = scope.shortcuts.registerFixed(command) + stopShortcut.set(command.keys) + return () => { stopShortcut.set([]); dispose() } + }, 'ui-conversation: fixed stop reference') + }) + const inputHub = new InputHub(ctx, t) const composerBlocks = new ComposerBlockRegistry() @@ -402,6 +441,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, @@ -475,12 +515,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { span: { ...selection, draftRev: snapshot.draftRev }, }) }, - stop: () => { - scopedConversation(sessions, sessionId).cancel().catch(() => { - // Stop failure is published through Session promptError. - }) - }, + stop: () => { stop(sessionId) }, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 001becdfde..61f7d8ff8b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -391,6 +391,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Readable sequence while the fixed Stop command is registered. */ + stopShortcut: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 0ab42da1c2..6466208dde 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -202,6 +202,17 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.resetViewBuilders() } + /** + * Read the current open turn without activating a View. + * @returns the latest turn number when its start is loaded and it remains open, otherwise undefined. + */ + openTurn(): number | undefined { + const snapshot = this.locationIndex.snapshot() + const latest = snapshot.turnOrder.at(-1) + const turn = latest === undefined ? undefined : snapshot.turns.get(latest) + return turn?.status === 'open' && turn.start !== undefined ? turn.turn : undefined + } + /** * Replace the complete loaded window after open, resync, or gap repair. * @param entries - complete contiguous window. diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index fe4d49505c..edc182f530 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -28,6 +28,11 @@ import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { readonly snapshot: ObservableSnapshot + /** + * Identity-stable source of the latest turn number, undefined unless its start is loaded and it remains open. + * Turn changes publish synchronously, including without an active View. + */ + readonly openTurn: ObservableSnapshot /** * Add one selected target to the Session's monotonic active set. * @param target - registered or subsequently registered Conversation target. @@ -47,6 +52,7 @@ export interface ConversationBinding { class BoundConversation implements ConversationBinding { readonly snapshot: SnapshotStore + readonly openTurn: SnapshotStore private readonly viewStore: ConversationViewSnapshotStore private readonly targetSources = new Map>() private revision = -1 @@ -59,6 +65,7 @@ class BoundConversation implements ConversationBinding { ) { this.viewStore = assembler this.snapshot = createSnapshotStore(this.currentSnapshot()) + this.openTurn = createSnapshotStore(assembler.openTurn()) this.replace(feed.getSnapshot()) this.disposeFeed = feed.subscribe(() => { this.accept(feed.getSnapshot()) @@ -86,6 +93,7 @@ class BoundConversation implements ConversationBinding { activate(target: string): void { if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + this.openTurn.set(this.assembler.openTurn()) } rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } @@ -157,6 +165,7 @@ class BoundConversation implements ConversationBinding { private flush(): void { if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) + this.openTurn.set(this.assembler.openTurn()) } private currentSnapshot(): ConversationSnapshot { @@ -223,6 +232,7 @@ export class UiConversation extends Service { * Resolve the Conversation binding for one Controller binding or Session id. * @param source - Session binding or identity. * @returns stable Conversation binding. + * @throws if the Session is unknown or its binding is no longer current. */ binding(source: SessionBinding | SessionId): ConversationBinding { const sessionId = typeof source === 'string' ? source : source.sessionId diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 7291f2c25a..e105a1d6c0 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -32,7 +32,7 @@ export interface ComposerKeymapHandlers { dismissPopup(): void /** Whether Enter may submit right now (locked/busy states refuse). */ canSubmit(): boolean - /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + /** Plain Enter submits; exactly Ctrl+Enter or Cmd+Enter selects accelerated delivery. */ submit(accelerated: boolean): void /** * Pasted files with directory metadata supplied by the clipboard entry API. @@ -133,8 +133,11 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer return false }, COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ENTER_COMMAND, (event) => { - // Shift+Enter is the native line break UNCONDITIONALLY — decided before - // the IME guard so a composition-closing Shift+Enter still breaks the line. + // Returning true stops Lexical's fallback line break without consuming + // the DOM event that application shortcuts need. + if (event !== null && (event.altKey || event.getModifierState('AltGraph') + || (event.ctrlKey && event.metaKey) || (event.shiftKey && (event.ctrlKey || event.metaKey)))) return true + // Plain Shift+Enter keeps its native line break, including during composition. if (event?.shiftKey === true) return false if (event !== null && isComposingEvent(event, recentlyComposing)) { // The IME consumes this Enter (candidate pick); neither submit nor diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index f59a6d947d..cf83316fea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -3,7 +3,8 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { LexicalEditor, NodeKey } from 'lexical' import { $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, - CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' @@ -65,8 +66,23 @@ export class DraftEditorRuntime { * @returns unregister callback that also detaches the editor root. */ register(): () => void { + // A retained draft selection must not move keyboard focus back from another control. + const preserveExternalSelection = (): false => { + const root = this.editor.getRootElement() + if (root !== null && !root.contains(root.ownerDocument.activeElement)) { + $addUpdateTag(SKIP_DOM_SELECTION_TAG) + } + return false + } const unregister = mergeRegister( registerPlainText(this.editor), + this.editor.registerCommand(BLUR_COMMAND, () => { + // Finish this batch before an explicit focus can restore its updated selection. + this.editor.update(preserveExternalSelection, { discrete: true }) + return false + }, COMMAND_PRIORITY_CRITICAL), + this.editor.registerCommand(SELECTION_CHANGE_COMMAND, preserveExternalSelection, COMMAND_PRIORITY_CRITICAL), + this.editor.registerNodeTransform(RootNode, preserveExternalSelection), registerReferenceActivation(this.editor, (source, reference) => this.deps.openReference(source, reference)), registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a988d7b012..070d9e352f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,11 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': '换行', + 'shortcut.complementary': '使用互补的 Queue/Steer 方式', + 'shortcut.slash': '打开命令菜单', + 'shortcut.mention': '打开引用菜单', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -89,6 +94,7 @@ export const zh = { 'tool.title.createSchedule': '创建定时任务', 'tool.title.listSchedules': '查看定时任务', 'tool.title.deleteSchedule': '删除定时任务', + 'tool.title.updateSchedule': '修改定时任务', 'detail.state': '状态', 'detail.todo.completed': '已完成', 'detail.todo.in_progress': '进行中', @@ -127,6 +133,17 @@ export const zh = { 'detail.schedule.empty': '没有定时任务', 'detail.schedule.deleted': '已删除', 'detail.schedule.count': '{count} 个定时任务', + 'detail.schedule.daily': '每天 {time}({zone})', + 'detail.schedule.weekly': '每周 {days} {time}({zone})', + 'detail.schedule.cron': 'Cron {expression}({zone})', + 'detail.weekday.1': '周一', + 'detail.weekday.2': '周二', + 'detail.weekday.3': '周三', + 'detail.weekday.4': '周四', + 'detail.weekday.5': '周五', + 'detail.weekday.6': '周六', + 'detail.weekday.7': '周日', + 'detail.weekday.join': '、', 'tool.title.inspectProviders': '检查提供方', 'tool.title.queryRuntime': '查询运行时', 'tool.title.inspectPlugins': '检查动态插件', @@ -363,6 +380,11 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': 'New line', + 'shortcut.complementary': 'Use the complementary Queue / Steer action', + 'shortcut.slash': 'Open command menu', + 'shortcut.mention': 'Open reference menu', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', @@ -443,6 +465,7 @@ export const en = { 'tool.title.createSchedule': 'Create reminder', 'tool.title.listSchedules': 'List reminders', 'tool.title.deleteSchedule': 'Delete reminder', + 'tool.title.updateSchedule': 'Update reminder', 'detail.state': 'Status', 'detail.todo.completed': 'Completed', 'detail.todo.in_progress': 'In progress', @@ -481,6 +504,17 @@ export const en = { 'detail.schedule.empty': 'No reminders', 'detail.schedule.deleted': 'Deleted', 'detail.schedule.count': '{count} reminders', + 'detail.schedule.daily': 'Every day at {time} ({zone})', + 'detail.schedule.weekly': 'Weekly on {days} at {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'Mon', + 'detail.weekday.2': 'Tue', + 'detail.weekday.3': 'Wed', + 'detail.weekday.4': 'Thu', + 'detail.weekday.5': 'Fri', + 'detail.weekday.6': 'Sat', + 'detail.weekday.7': 'Sun', + 'detail.weekday.join': ', ', 'tool.title.inspectProviders': 'Inspect providers', 'tool.title.queryRuntime': 'Query runtime', 'tool.title.inspectPlugins': 'Inspect plugins', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..15bfdd8732 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { workspaceDisplayTitle } from '@deepseek-ai/dsh-api-workspace-controller/default-workspace' import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' @@ -93,13 +94,18 @@ export function ConversationContent(props: ConversationContentProps) { // flash on refresh (empty cwd → placeholder); // 5. list ready but no owning workspace (deleted from the sidebar) → // placeholder, never the deleted folder's name via cwd. - const chipTitle = pendingWorkspace?.title + // A title still automatic reads in the reader's language, matching the + // sidebar row the same Workspace has there. + const storedChipTitle = pendingWorkspace?.title ?? (sessionId === undefined ? undefined : sessionWorkspace?.title ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' ? undefined : workspaceLabel(cwd))) + const chipTitle = storedChipTitle === undefined + ? undefined + : workspaceDisplayTitle(storedChipTitle, t('workspace.defaultName')) const heroWorkspaceRow = (
@@ -174,7 +180,7 @@ export function ConversationContent(props: ConversationContentProps) { // on the fallback alone would leave a business-owned takeover at the content // end off-screen when the user is not pinned to the floor. const composerSeat = ( -
+
{composer}
) @@ -184,6 +190,8 @@ export function ConversationContent(props: ConversationContentProps) { ref={setBody} className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)} data-conversation-content="" + data-conversation-session={sessionId} + data-conversation-region="chat" data-content-phase={phase} >
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index dea8a86934..9139aa9ac8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -46,7 +46,7 @@ export const InputBar = memo(function InputBar({ useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, - renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, + renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -54,6 +54,7 @@ export const InputBar = memo(function InputBar({ const input = useInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) + const stopKeys = useStopShortcut(keys => keys) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const [activity, setActivity] = useState(false) @@ -310,6 +311,7 @@ export const InputBar = memo(function InputBar({ : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue') : t('input.send') + const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop') const onPrimary = (): void => { if (primaryStops) { stop?.() @@ -457,7 +459,7 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} {interruptible && ( - + {(canCloseTab?.(tab.id) ?? true) && ( - - + + + )} {menu?.tabId === tabId && ( {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( - - + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the @@ -421,6 +428,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
{ if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 02871e4faa..03fd4ec98c 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -215,6 +215,11 @@ overflow: hidden; } +.pane:focus, +.float:focus { + outline: none; +} + /* A 38px row whose 28px content band sits at its bottom: chips fill the band, so they end flush with the strip's bottom edge, and the 28px add control, the 28px split control, and the embedder's 28px chrome centre on the band's one diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 81239e816e..b35fbb18d7 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -19,12 +19,20 @@ export interface DockLabels { readonly emptyPane: string /** The split control, while splitting is allowed. */ readonly splitPane: string + /** Effective localized action and shortcut tooltip, when supplied. */ + readonly splitPaneTooltip?: string + /** ARIA combination from the effective binding, when bound. */ + readonly splitPaneShortcut?: string | undefined /** The split control, once the pane budget is spent. */ readonly splitPaneDisabled: string /** The split control, while the pane is too narrow for two working halves. */ readonly splitPaneNarrow: string /** Destroy a tab: the chip's close control and the menu's close item. */ readonly closeTab: string + /** Effective close label and keycaps supplied by the embedder. */ + readonly closeTabTooltip?: string + /** Effective close combination, when bound. */ + readonly closeTabShortcut?: string | undefined /** The strip's add control, which seats the embedder's seeded tab. */ readonly addTab: string /** Send a floating panel back into the docked tree. */ diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 54d42476be..fe6fce2203 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -278,7 +278,7 @@ describe('DockSurface', () => { renderSurface(controller, intents, false) const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(disabled.hasAttribute('disabled')).toBe(true) - expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) + expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled) expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) @@ -358,7 +358,7 @@ describe('DockSurface', () => { expect(narrowButton).toBeNull() } else { expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneNarrow) expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') } }) @@ -838,6 +838,64 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it.each(['tab', 'item'])('Escape dismisses a reopened menu from the %s without closing its tab', (focus) => { + const intents = spyIntents() + const { unmount } = renderSurface(seededController(), intents) + const tab = screen.getByRole('tab') + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.contextMenu(tab) + const target = focus === 'tab' ? tab : screen.getByRole('menuitem') + target.focus() + expect(fireEvent.keyDown(target, { key: 'Escape', code: 'Escape' })).toBe(false) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(tab) + expect(intents.closeTab).not.toHaveBeenCalled() + } + unmount() + expect(fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })).toBe(true) + }) + + it('keeps the menu during composition, modified Escape and key repeat', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + for (const modifier of ['ctrlKey', 'altKey', 'metaKey', 'shiftKey', 'isComposing']) { + expect(fireEvent.keyDown(tab, { key: 'Escape', [modifier]: true })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + } + fireEvent.compositionStart(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.compositionEnd(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyUp(tab, { key: 'Escape' }) + expect(fireEvent.keyDown(tab, { key: 'Escape', repeat: true })).toBe(false) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('yields Escape to a foreground modal and consumes it before a parent handler', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + onTestFinished(() => { modal.remove() }) + expect(fireEvent.keyDown(modal, { key: 'Escape' })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + modal.remove() + const parent = vi.fn((event: KeyboardEvent) => { expect(event.defaultPrevented).toBe(true) }) + document.addEventListener('keydown', parent) + onTestFinished(() => { document.removeEventListener('keydown', parent) }) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(parent).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + }) + it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { const controller = seededController() let left = 10 @@ -1141,6 +1199,18 @@ describe('keyboard tabs', () => { expect(intents.focusTab).toHaveBeenCalledTimes(2) }) + it('leaves modified and composing keys available to their owners', () => { + const { intents, chip } = threeChips() + const tab = chip('a.txt') + tab.focus() + for (const modifiers of [{ ctrlKey: true, altKey: true }, { metaKey: true, altKey: true }, { shiftKey: true }, { isComposing: true }]) { + expect(fireEvent.keyDown(tab, { key: 'Enter', ...modifiers })).toBe(true) + expect(fireEvent.keyDown(tab, { key: 'ArrowRight', ...modifiers })).toBe(true) + expect(document.activeElement).toBe(tab) + } + expect(intents.focusTab).not.toHaveBeenCalled() + }) + it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { const { intents, chip } = threeChips() fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) @@ -1506,7 +1576,7 @@ describe('surface chrome', () => { const role = (child: Element): string => { if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' if (child.hasAttribute('data-dockkit-add-tab')) return 'add' - if (child.hasAttribute('data-dockkit-split-button')) return 'split' + if (child.hasAttribute('data-dockkit-split-button') || child.querySelector('[data-dockkit-split-button]')) return 'split' if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' return 'fill' } diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index ddb8150c07..24a9220917 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-layout/use-this-package: - en: 922ec8985baeee66 - zh: cb88919d73bb0364 + en: 7d135dd3dbd7ff96 + zh: 43ac4d9c5a12f741 /deepseek-ai-dsh-client-ui-layout/use-this-package/window-chrome-seat: en: e55c831b460b40e4 zh: 8670e583bec8c50b @@ -24,8 +24,8 @@ en: 1e1d3361dac7d8b0 zh: b6b3d210d65738f0 /deepseek-ai-dsh-client-ui-layout/understand-the-implementation: - en: 0824b7a56a1927c7 - zh: 08fac7254486585b + en: 8af800fa55786c71 + zh: 7421c12eb3585c21 /deepseek-ai-dsh-client-ui-layout/further-exploration: en: b6c728b6ed70f2a7 zh: 8ee2003b98695323 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 092ce2c231..e1fab07fff 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,6 +25,8 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package +Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. + The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. @@ -49,7 +51,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 3b6a7314b5..29d0633b7f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 + 本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 @@ -49,7 +51,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 1c2ba62342..7ddde92f06 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-theme" + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-shortcuts" ], "platform": "web" } @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 045fcb8a39..410c8b0977 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -17,6 +17,8 @@ import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { en, zh } from './shortcut-locales.ts' import { ThemePresenter } from './theme-presenter.ts' // Contract exports only (export-convergence rule: cross-package consumers @@ -38,6 +40,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Layout keyboard command labels. */ + 'shortcuts.layout': keyof typeof zh + } + interface GlobalStandardProps { /** Subscribe to the selected main panel independently of parent renders. */ usePanelInfo: UsePanelInfo @@ -133,7 +140,7 @@ export interface RightbarOwnerProps { } /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */ -export const inject = ['slots', 'theme', 'locale'] +export const inject = ['slots', 'theme', 'locale', 'shortcuts'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -142,12 +149,13 @@ export const inject = ['slots', 'theme', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en }), 'layout: command labels') + const t = ctx.locale.bind('shortcuts.layout') + ctx.effect(() => { const handle = createLayoutStore() const instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } - const layout = new LayoutController(instance.actions, id => - ctx.slots.entries('main').some(entry => entry.options.key === id)) const retainMainPanels = (): void => { instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => entry.options.key === undefined ? [] : [entry.options.key])) @@ -156,7 +164,9 @@ export function apply(ctx: ClientContext): void { getSnapshot: () => instance.getSnapshot().panelInfo, subscribe: listener => instance.subscribe(listener), } - const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo) + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', @@ -170,9 +180,22 @@ export function apply(ctx: ClientContext): void { }, store, }, AppFrame) + const disposeShortcut = ctx.shortcuts.register({ + id: 'sidebar.left.toggle' as ShortcutCommandId, label: () => t('toggle'), aliases: ['sidebar', 'toggle left sidebar'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }), + }) const disposePanels = ctx.slots.subscribe('main', retainMainPanels) retainMainPanels() return () => { + disposeShortcut() layout.dispose() disposePanels() disposeRegistration() diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 14a3924d63..22dd77a4e6 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -8,7 +8,7 @@ * right-panel show/hide from ui-sidebar-right) — writes stay inside the * store's declared action set, shared with the root registration. */ -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { BoundActions, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' @@ -26,6 +26,8 @@ export type PanelActions = BoundActions> /** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** Selected central panel from the same root store used by `usePanelInfo`. */ + readonly panelInfo: HostObservable /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. @@ -58,10 +60,12 @@ export class LayoutController implements ILayout { /** * @param panels - actions of the instance shared with the root entry. * @param hasMainPanel - checks the live main-slot registry for a panel id. + * @param panelInfo - root store's shared central-panel selection source. */ constructor( private readonly panels: PanelActions, private readonly hasMainPanel: (id: MainPanelId) => boolean, + readonly panelInfo: HostObservable, ) {} /** Select a global panel or return to the Conversation. */ diff --git a/packages/client/ui-layout/src/client/shortcut-locales.ts b/packages/client/ui-layout/src/client/shortcut-locales.ts new file mode 100644 index 0000000000..7c90e1ae9a --- /dev/null +++ b/packages/client/ui-layout/src/client/shortcut-locales.ts @@ -0,0 +1,4 @@ +/** Layout command labels. */ +export const zh = { toggle: '展开/收起左侧栏' } +/** English labels for the same layout commands. */ +export const en: Record = { toggle: 'Toggle left sidebar' } diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 0e29721622..c38719c68d 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import ShortcutsService from '../../shortcuts/src/client/index.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' @@ -58,6 +59,7 @@ async function bench() { // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(ShortcutsService).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) @@ -77,7 +79,7 @@ async function bench() { describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots', 'theme', 'locale']) + expect(inject).toEqual(['slots', 'theme', 'locale', 'shortcuts']) }) it('provides ctx.layout and declares the five root-scoped frame slots', async () => { @@ -109,6 +111,7 @@ describe('ui-layout client apply', () => { const host = rendererHost() expect(host.storeOf(entry, undefined)).toBe(instance) const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo).toBe(layout.panelInfo) expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) const panelId = 'panel-a' as MainPanelId const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index 7ccadb5c3c..b3e3680d5c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,3 +1,4 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' import type { MainPanelId, PanelActions } from '../src/client/service.ts' @@ -18,7 +19,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.openRightbar(true, false) service.openRightbar(true, true) @@ -35,7 +36,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.toggleSidebar() @@ -45,7 +46,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -61,8 +62,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first, () => true) - const secondService = new LayoutController(second, () => true) + const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null })) + const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null })) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -74,7 +75,7 @@ describe('LayoutController', () => { it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { const panels = fakePanels() const present = new Set(['panel-a']) - const service = new LayoutController(panels, id => present.has(id)) + const service = new LayoutController(panels, id => present.has(id), createSnapshotStore({ activePanelId: null })) service.selectPanel('panel-a' as MainPanelId) const navigation = service.beginNavigation() present.delete('panel-a') @@ -86,7 +87,7 @@ describe('LayoutController', () => { }) it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { - const service = new LayoutController(fakePanels(), () => true) + const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null })) const first = service.beginNavigation() const second = service.beginNavigation() expect(first.aborted).toBe(true) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 8893b1800d..b2daaaf054 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 5ca5ca0bce..84beb9dff8 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -18,11 +18,11 @@ en: 056e2bd9e3c78dd8 zh: be35d2ba53be0442 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 2332d6b555492721 - zh: fb7a4dcdfe52d80b + en: c93c175a3583820a + zh: fa812b70176d3d60 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: a33cd11b46e25367 - zh: ba9264617d3e62f1 + en: c8ac8873c45eb568 + zh: fd623c411f79d90f /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: en: 9deb509f65b6edfd zh: d6cd63b2abc3731b diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 4f8462b295..ae8b195921 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,6 +31,8 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. +The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. + File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. @@ -43,7 +45,7 @@ Mounted controls with the same association reader and file share one query and i
Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes their captured directory and status; header controls derive delayed busy and transient error visuals from that source. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index df58e83862..1477640a04 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 +**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 + 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 @@ -43,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与状态;头部控件从该源派生延迟出现的等待态和短暂错误态。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 89ab47519c..dfff35dcc0 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -42,7 +42,9 @@ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview" @@ -62,6 +64,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", @@ -72,6 +75,7 @@ "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", - "@deepseek-ai/dsh-native-command": "workspace:*" + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" } } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index a58ed63933..532508c557 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,7 +1,10 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import { NS, type OpenInAppKey } from './locales.ts' +import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppLaunchState } from './controller.ts' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' +import { NS } from './locales.ts' import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ @@ -9,6 +12,8 @@ export interface OpenInAppActionInjected { hooks: { openInAppApps: ObservableSnapshot openInAppChoice: ObservableSnapshot + openInAppLaunch: ObservableSnapshot + shortcuts: ObservableSnapshot } launch: (appId: string, path: string) => Promise choose: (appId: string) => void @@ -21,47 +26,6 @@ export type OpenInAppActionProps = & PropsLocale & InjectFace -/** - * Label keys per catalog id: the browser renders only ids it can name, so a - * host catalog extension without a matching dictionary entry stays invisible - * instead of showing a raw id. - */ -const APP_LABEL_KEY: Record = { - finder: 'app.finder', - explorer: 'app.explorer', - filemanager: 'app.filemanager', - cursor: 'app.cursor', - vscode: 'app.vscode', - vscodeinsiders: 'app.vscodeinsiders', - windsurf: 'app.windsurf', - zed: 'app.zed', - sublimetext: 'app.sublimetext', - xcode: 'app.xcode', - androidstudio: 'app.androidstudio', - intellij: 'app.intellij', - pycharm: 'app.pycharm', - webstorm: 'app.webstorm', - phpstorm: 'app.phpstorm', - goland: 'app.goland', - rider: 'app.rider', - rustrover: 'app.rustrover', - fork: 'app.fork', - sourcetree: 'app.sourcetree', - github: 'app.github', - tower: 'app.tower', - gitkraken: 'app.gitkraken', - smartgit: 'app.smartgit', - sublimemerge: 'app.sublimemerge', - ghostty: 'app.ghostty', - warp: 'app.warp', - iterm: 'app.iterm', - kitty: 'app.kitty', - terminal: 'app.terminal', - windowsterminal: 'app.windowsterminal', - gitbash: 'app.gitbash', - gnometerminal: 'app.gnometerminal', - konsole: 'app.konsole', -} /** * Adapt the installed directory catalog to the shared opening control. @@ -73,6 +37,8 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) + const operation = props.useOpenInAppLaunch(value => value) + const shortcut = props.useShortcuts(rows => rows.find(row => row.id === 'workspace.openLocal')) const apps = (available ?? []).flatMap((id) => { const key = APP_LABEL_KEY[id] return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] @@ -82,6 +48,7 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element return ( { const id = operation.kind === 'application' ? operation.id : preferred.id try { diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 3d781070e1..ba28840f3d 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenInAppPathFailure } from './open-path.ts' import { useOpenFailureToast } from './open-failure-toast.tsx' @@ -22,6 +23,7 @@ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind /** Inputs shared by both target adapters and the file empty-state action. */ export interface OpenTargetButtonProps { + readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined @@ -130,8 +132,9 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
- +
) @@ -185,20 +196,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * able to name by position. */ const triggerRef = useRef(null) + const selectingWithTab = useRef(false) /** * Hand the keyboard back to the trigger that opened the menu — or, when the * anchor never held it, to the anchor's first button. Focus left on a removed * row otherwise falls to the page body, where the next Tab restarts from the * top of the page. + * @param navigation - whether explicit keyboard traversal should retain its focus indicator. */ - const refocusAnchor = (): void => { + const refocusAnchor = (navigation = false): void => { const trigger = triggerRef.current - if (trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled) { - trigger.focus() - return - } - rootRef.current?.querySelector('button:not(:disabled)')?.focus() + const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled + ? trigger : rootRef.current?.querySelector('button:not(:disabled)') + if (target == null) return + if (navigation) target.focus() + else focusWithoutRing(target) } /** @@ -209,10 +222,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * its removal produced) comes back to the trigger. */ const refocusAfterSelection = (): void => { + const navigation = selectingWithTab.current queueMicrotask(() => { if (openRef.current) return const active = document.activeElement - if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor() + if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) }) } const openRef = useRef(open) @@ -298,7 +312,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (!open || !autoFocus) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 - first?.focus() + if (first != null) focusWithoutRing(first) }, [open, autoFocus]) useEffect(() => { @@ -307,6 +321,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI walkIndex.current = null return } + const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node)) return // The portaled list is outside the anchor subtree; check both. @@ -315,12 +330,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI onClose() } const onKeyDown = (e: KeyboardEvent) => { + if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true const anchored = rootRef.current?.contains(focused) === true || insideList - if (e.key === 'Escape') { + if (e.key === 'Escape' && !e.shiftKey) { + e.preventDefault() + if (e.repeat) return // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). @@ -337,7 +355,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (e.shiftKey) { e.preventDefault() onClose() - refocusAnchor() + refocusAnchor(true) return } // Tab settles the row it is on; from anywhere else in the menu region @@ -347,7 +365,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (insideList) { if (focused instanceof Element && focused.getAttribute('role') === 'menuitem') { e.preventDefault() - ;(focused as HTMLElement).click() + selectingWithTab.current = true + try { (focused as HTMLElement).click() } + finally { selectingWithTab.current = false } } return } @@ -389,11 +409,16 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (document.activeElement instanceof HTMLIFrameElement) onClose() } document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) + const onEscape = (event: KeyboardEvent): void => { if (event.key === 'Escape') onKeyDown(event) } + const onOtherKey = (event: KeyboardEvent): void => { if (event.key !== 'Escape') onKeyDown(event) } + document.addEventListener('keydown', onOtherKey) + document.addEventListener('keydown', onEscape, true) window.addEventListener('blur', onWindowBlur) return () => { + composition.dispose() document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keydown', onOtherKey) + document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } }, [open, onClose, autoFocus]) @@ -432,6 +457,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)} disabled={entry.disabled} + aria-keyshortcuts={entry.shortcut?.aria} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined} @@ -445,6 +471,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -457,10 +484,12 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={css.item} disabled={sub.disabled} - onClick={() => { onSelect?.(sub.id) }} + aria-keyshortcuts={sub.shortcut?.aria} + onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }} > {sub.icon !== undefined && {sub.icon}} {sub.label} + {sub.shortcut !== undefined && } ))}
diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 8611494214..01c4de57a5 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -4,6 +4,7 @@ Cards size against this padding box: consumers cap growth with `max-height: 100%`, never their own viewport calc. */ .root { + pointer-events: auto; position: fixed; inset: 0; z-index: 1000; @@ -18,10 +19,17 @@ .mask { position: absolute; inset: 0; - background: var(--dsw-alias-bg-mask-1); backdrop-filter: var(--dsw-mask-blur); } +.mask::after { + content: ''; + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + animation: modalEnter var(--ds-transition-duration) var(--ds-ease-in-out); +} + /* Dialog card: r24, elevation-prominent, layer-2 fill, pb 24. */ .dialog { position: relative; @@ -36,6 +44,20 @@ border-radius: 24px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); + animation: modalEnter var(--ds-transition-duration) var(--ds-ease-in-out); +} + +@keyframes modalEnter { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .mask::after, .dialog { animation: none; } +} + +.dialog:focus { + outline: none; } .content { diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index dcb5da4535..e7fa7faa2e 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react' +import { useRef } from 'react' import type { KeyboardEventHandler, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' +import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' interface ModalBaseProps { @@ -14,7 +15,9 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + shortcutModal?: string onKeyDownCapture?: KeyboardEventHandler + backdropBlur?: boolean } type ModalProps = ModalBaseProps & ( @@ -25,37 +28,39 @@ type ModalProps = ModalBaseProps & ( /** * Render a centered, body-portaled modal over a blurred page mask. * @param props.open - whether the dialog is showing. - * @param props.onClose - Escape or mask click; while a menu is open inside the + * @param props.onClose - application close command, Escape, or mask click; while a menu is open inside the * dialog, Escape belongs to that menu first. * @param props.title - dialog heading (aria-label in every mode). * @param props.closeLabel - localized accessible close-button label. * @param props.description - optional supporting sentence under the title. - * @param props.children - body (inputs, etc.). + * @param props.children - dialog body; mark its initial-focus control with + * data-modal-autofocus instead of React autoFocus to preserve return focus. * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. + * @param props.backdropBlur - disable when the caller already blurs the page; defaults to true. + * @param props.shortcutModal - command scope allowed by shortcut owners; unnamed + * dialogs block application commands unless their owner allows the "other" scope. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. * @param props.onKeyDownCapture - handle a nested dialog's keys before the document Escape listeners. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, onKeyDownCapture, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, + onKeyDownCapture, headless = false, backdropBlur = true, shortcutModal, }: ModalProps) { - useEffect(() => { - if (!open) return - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + const dialog = useRef(null) + useModalLayer(dialog, open, onClose) if (!open) return null return createPortal((
-