diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index ea0738686e..e29631633c 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -30,8 +30,8 @@ en: 7bc0fab85c7c91d0 zh: 3eafcde6b1e3249c /agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/the-bash-consumer: - en: af561a23104227cd - zh: ac6fd59518d1bb55 + en: f7f359c89a0e8fd6 + zh: 07d0d551a35716e0 /agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/escalation-one-approved-wider-retry-after-a-denial: en: 035640e276225e97 zh: 09fd95921529369b diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index f2c703ba67..1f8ca60c83 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -78,7 +78,7 @@ Provider-owned I/O requires asynchronous confinement. `confine(argv, policy, sig `dsh-bash-sandbox` extends `LocalBashExecutor`, awaits `ctx.sandbox` for the exact `['bash', '-c', command]` argv with the execution signal, rechecks cancellation, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn '`; a present path also requires `syscall: 'spawn'` or the exact `spawn `. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start reject with `SANDBOX_UNAVAILABLE` before handle publication, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`. -The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. +The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`) and forbids retrying around a denial, and the `sandbox:policy` context tells the model to try an available tool normally; a rejected escalation's error text says the action stays denied; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. #### Escalation: one approved wider retry after a denial diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index ff4d4ed592..707a576e97 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -78,7 +78,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费 `dsh-bash-sandbox` 扩展 `LocalBashExecutor`,携带执行信号等待 `ctx.sandbox` 为精确的 `['bash', '-c', command]` argv 完成限制准备,重新检查取消后直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会在发布句柄前以 `SANDBOX_UNAVAILABLE` 拒绝;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。 -模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 +模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`)并禁止绕过拒绝重试,`sandbox:policy` 上下文要求模型正常尝试可用工具;被拒绝的升级请求的错误文本说明该操作仍被拒绝。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 #### 升级机制:拒绝后一次经批准的更宽重试 diff --git a/.agents/skills/agent-experience/SKILL.md b/.agents/skills/agent-experience/SKILL.md index bd169f1ae2..3ad20ad5cb 100644 --- a/.agents/skills/agent-experience/SKILL.md +++ b/.agents/skills/agent-experience/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-experience -description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently. +description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently. metadata: date: "2026-09-11" --- @@ -11,3 +11,11 @@ metadata: - **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents. - **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more. - **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes. + +## Tool definitions + +- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered. +- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details. +- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter. +- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool. +- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change. diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index e72b731f31..5eb07e6be3 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -25,8 +25,8 @@ - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换 - switch "代码工作工具" [checked] - text: 快捷键 - - paragraph: 查看当前可用的快捷键和输入操作 - - button "查看快捷键" + - 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 e94627b044..1d68d15d1f 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -25,8 +25,8 @@ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats - switch "Coding Tools" [checked] - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - paragraph: View and edit available shortcuts and input actions + - button "Edit 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-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 7bdc9cf4e3..e4ebda98ac 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -25,8 +25,8 @@ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats - switch "Coding Tools" - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - paragraph: View and edit available shortcuts and input actions + - button "Edit 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 7bdc9cf4e3..e4ebda98ac 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 @@ -25,8 +25,8 @@ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats - switch "Coding Tools" - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - paragraph: View and edit available shortcuts and input actions + - button "Edit 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 8056341afa..40d4ecf3d7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -25,8 +25,8 @@ - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换 - switch "代码工作工具" - text: 快捷键 - - paragraph: 查看当前可用的快捷键和输入操作 - - button "查看快捷键" + - 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 index 3bf739802b..3105f57dec 100644 --- a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md +++ b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md @@ -1,5 +1,3 @@ - listitem: - - text: New Session - - button "Remove shortcut for New Session" - button "Edit shortcut for New Session" - - button "New Session A B": A B + - text: New Session A B 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 index 0881936876..9979080ca0 100644 --- a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md +++ b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md @@ -1,5 +1,3 @@ - listitem: - - text: New Session - - button "Remove shortcut for New Session" - button "Edit shortcut for New Session" - - button "New Session ⌘ C": ⌘ C + - text: New Session ⌘ C 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 index fd3ec59c13..84272e6570 100644 --- a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md +++ b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md @@ -1,5 +1,3 @@ - listitem: - - text: New Session - - button "Remove shortcut for New Session" - button "Edit shortcut for New Session" - - button "New Session Ctrl + C": Ctrl + C + - text: New Session Ctrl + C diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md index 7bddffee98..ceed422820 100644 --- a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -7,126 +7,72 @@ - 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 ⌘ /": ⌘ / + - text: Open keyboard shortcuts ⌘ / - listitem: - - text: New Session - - button "Remove shortcut for New Session" - button "Edit shortcut for New Session" - - button "New Session ⌥ ⌘ N": ⌥ ⌘ N + - text: New Session ⌥ ⌘ 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 + - text: Toggle left sidebar ⌥ ⌘ B - listitem: - - text: Search sessions - - button "Remove shortcut for Search sessions" - button "Edit shortcut for Search sessions" - - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K + - text: Search sessions ⌥ ⌘ K - listitem: - - text: Add workspace - - button "Remove shortcut for Add workspace" - button "Edit shortcut for Add workspace" - - button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O + - text: Add workspace ⌥ ⌘ O - listitem: - - text: Rename session - - button "Remove shortcut for Rename session" - button "Edit shortcut for Rename session" - - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R + - text: Rename session ⇧ ⌘ R - listitem: - - text: Fork session - - button "Remove shortcut for Fork session" - button "Edit shortcut for Fork session" - - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F + - text: Fork session ⇧ ⌘ F - listitem: - - text: Archive session - - button "Remove shortcut for Archive session" - button "Edit shortcut for Archive session" - - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A + - text: Archive session ⌥ ⌘ A - listitem: - - text: Open settings - - button "Remove shortcut for Open settings" - button "Edit shortcut for Open settings" - - button "Open settings ⌘ ,": ⌘ , + - text: 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 + - text: Toggle right sidebar ⇧ ⌘ B - listitem: - - text: Workspace files - - button "Remove shortcut for Workspace files" - button "Edit shortcut for Workspace files" - - button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P + - text: Workspace files ⌥ ⌘ P - listitem: - - text: New terminal - - button "Remove shortcut for New terminal" - button "Edit shortcut for New terminal" - - 'button "New terminal ⌃ `"': "⌃ `" + - text: "New terminal ⌃ `" - listitem: - - text: Split - - button "Remove shortcut for Split" - button "Edit shortcut for Split" - - button "Split ⌘ \\": ⌘ \ + - text: 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 + - text: Toggle panel fullscreen ⌥ ⌘ Enter - listitem: - - text: Refresh current page - button "Edit shortcut for Refresh current page" - - button "Refresh current page No shortcut": No shortcut + - text: Refresh current page 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 + - text: Close current page or window ⌥ ⌘ W - 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]: / + - listitem: Use the complementary Queue / Steer action ⌘ Enter + - listitem: Open reference menu @ + - listitem: New line ⇧ Enter + - listitem: Send message Enter + - listitem: Open command menu / + - listitem: Stop generating Esc Esc - 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 + - listitem: Close menu or top dialog Esc + - listitem: Move menu selection ↑ ↓ + - listitem: Select menu item 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 + - listitem: Allow once Enter + - listitem: Reject Esc - contentinfo: - button "Restore all defaults" [disabled] - - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md index 0661c89929..94072331f3 100644 --- a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md +++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md @@ -4,6 +4,7 @@ - button "Close keyboard shortcuts" - search "Search shortcuts": - searchbox "Search shortcuts": abc + - button "Clear search" - status: No matching shortcuts - contentinfo: - button "Restore all defaults" diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md index 4460a24ce0..d3740de7a2 100644 --- a/apps/web/tests/expected/shortcuts/en-US.expected.md +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -7,127 +7,72 @@ - 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 + / + - text: Open keyboard shortcuts 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 + - text: New Session 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 + - text: Toggle left sidebar 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 + - text: Search sessions 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 + - text: Add workspace 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 + - text: Rename session 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 + - text: Fork session 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 + - text: Archive session Ctrl + Alt + A - listitem: - - text: Open settings - - button "Remove shortcut for Open settings" - button "Edit shortcut for Open settings" - - button "Open settings Ctrl + ,": Ctrl + , + - text: Open settings 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 + - text: Toggle right sidebar 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 + - text: Workspace files Ctrl + Alt + P - listitem: - - text: New terminal - - button "Remove shortcut for New terminal" - button "Edit shortcut for New terminal" - - 'button "New terminal Ctrl + `"': "Ctrl + `" + - text: "New terminal Ctrl + `" - listitem: - - text: Split - - button "Remove shortcut for Split" - button "Edit shortcut for Split" - - button "Split Ctrl + \\": Ctrl + \ + - text: Split 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 + - text: Toggle panel fullscreen 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 + - text: Refresh current page 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 + - text: Close current page or window Ctrl + Alt + W - 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]: / + - listitem: Use the complementary Queue / Steer action Ctrl + Enter + - listitem: Open reference menu @ + - listitem: New line Shift + Enter + - listitem: Send message Enter + - listitem: Open command menu / + - listitem: Stop generating Esc Esc - 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 + - listitem: Close menu or top dialog Esc + - listitem: Move menu selection ↑ ↓ + - listitem: Select menu item 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 + - listitem: Allow once Enter + - listitem: Reject Esc - contentinfo: - button "Restore all defaults" [disabled] - - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md index 8bc73cf849..f43195fe3f 100644 --- a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md +++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md @@ -4,6 +4,7 @@ - button "关闭快捷键" - search "搜索快捷键": - searchbox "搜索快捷键": abc + - button "清空搜索" - status: 没有匹配的快捷键 - contentinfo: - button "恢复全部默认" diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md index 8fa16c3e99..ce19e0b876 100644 --- a/apps/web/tests/expected/shortcuts/zh-CN.expected.md +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -7,126 +7,72 @@ - region "应用操作": - list: - listitem: - - text: 快捷键速查 - - button "移除快捷键速查快捷键" - button "修改快捷键速查快捷键" - - button "快捷键速查 ⌘ /": ⌘ / + - text: 快捷键速查 ⌘ / - listitem: - - text: 新会话 - - button "移除新会话快捷键" - button "修改新会话快捷键" - - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N + - text: 新会话 ⌥ ⌘ N - listitem: - - text: 展开/收起左侧栏 - - button "移除展开/收起左侧栏快捷键" - button "修改展开/收起左侧栏快捷键" - - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - text: 展开/收起左侧栏 ⌥ ⌘ B - listitem: - - text: 搜索会话 - - button "移除搜索会话快捷键" - button "修改搜索会话快捷键" - - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K + - text: 搜索会话 ⌥ ⌘ K - listitem: - - text: 添加工作区 - - button "移除添加工作区快捷键" - button "修改添加工作区快捷键" - - button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O + - text: 添加工作区 ⌥ ⌘ O - listitem: - - text: 重命名会话 - - button "移除重命名会话快捷键" - button "修改重命名会话快捷键" - - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R + - text: 重命名会话 ⇧ ⌘ R - listitem: - - text: 分叉会话 - - button "移除分叉会话快捷键" - button "修改分叉会话快捷键" - - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F + - text: 分叉会话 ⇧ ⌘ F - listitem: - - text: 归档会话 - - button "移除归档会话快捷键" - button "修改归档会话快捷键" - - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A + - text: 归档会话 ⌥ ⌘ A - listitem: - - text: 打开设置 - - button "移除打开设置快捷键" - button "修改打开设置快捷键" - - button "打开设置 ⌘ ,": ⌘ , + - text: 打开设置 ⌘ , - listitem: - - text: 展开/收起右侧栏 - - button "移除展开/收起右侧栏快捷键" - button "修改展开/收起右侧栏快捷键" - - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B + - text: 展开/收起右侧栏 ⇧ ⌘ B - listitem: - - text: 工作区文件 - - button "移除工作区文件快捷键" - button "修改工作区文件快捷键" - - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P + - text: 工作区文件 ⌥ ⌘ P - listitem: - - text: 新建终端 - - button "移除新建终端快捷键" - button "修改新建终端快捷键" - - 'button "新建终端 ⌃ `"': "⌃ `" + - text: "新建终端 ⌃ `" - listitem: - - text: 分栏 - - button "移除分栏快捷键" - button "修改分栏快捷键" - - button "分栏 ⌘ \\": ⌘ \ + - text: 分栏 ⌘ \ - listitem: - - text: 面板全屏/退出全屏 - - button "移除面板全屏/退出全屏快捷键" - button "修改面板全屏/退出全屏快捷键" - - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - text: 面板全屏/退出全屏 ⌥ ⌘ Enter - listitem: - - text: 刷新当前页面 - button "修改刷新当前页面快捷键" - - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - text: 刷新当前页面 暂无快捷键 - listitem: - - text: 关闭当前页面/窗口 - - button "移除关闭当前页面/窗口快捷键" - button "修改关闭当前页面/窗口快捷键" - - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W - - listitem: - - text: 停止生成 - - button "停止生成 Esc Esc" [disabled]: Esc Esc + - text: 关闭当前页面/窗口 ⌥ ⌘ W - 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]: / + - listitem: 使用互补的 Queue/Steer 方式 ⌘ Enter + - listitem: 打开引用菜单 @ + - listitem: 换行 ⇧ Enter + - listitem: 发送消息 Enter + - listitem: 打开命令菜单 / + - listitem: 停止生成 Esc Esc - region "菜单与弹层": - heading "菜单与弹层" [level=3] - list: - - listitem: - - text: 关闭菜单或顶层弹窗 - - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc - - listitem: - - text: 移动菜单选择 - - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ - - listitem: - - text: 选择菜单项 - - button "选择菜单项 Enter" [disabled]: Enter + - listitem: 关闭菜单或顶层弹窗 Esc + - listitem: 移动菜单选择 ↑ ↓ + - listitem: 选择菜单项 Enter - region "审批区域": - heading "审批区域" [level=3] - list: - - listitem: - - text: 允许一次 - - button "允许一次 Enter" [disabled]: Enter - - listitem: - - text: 拒绝 - - button "拒绝 Esc" [disabled]: Esc + - listitem: 允许一次 Enter + - listitem: 拒绝 Esc - contentinfo: - button "恢复全部默认" [disabled] - - text: 0 项已自定义 diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index 62c89bd029..1f2fb03954 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index 9f3bd76b10..9968341f1b 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -23,9 +23,9 @@ async function bind(page: Page, primary: string, label: string, previous?: strin 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 ${previous}`, exact: true }).click() + await dialog.getByRole('button', { name: 'Remove', exact: true }).click() + await dialog.getByRole('group').waitFor({ state: 'hidden' }) } await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() const recorder = dialog.getByRole('group', { name: label, exact: true }) @@ -125,6 +125,19 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await terminalGuide.focus() expect(await page.getByRole('tooltip').count()).toBe(0) + const terminalEntry = panel.locator('[data-sidebar-right-guide-entry="terminal"]') + const shellMenu = terminalEntry.getByRole('button', { name: 'Choose shell', exact: true }) + const titleBounds = await terminalEntry.getByText('New terminal', { exact: true }).boundingBox() + const arrowBounds = await shellMenu.boundingBox() + expect(arrowBounds!.x).toBeGreaterThanOrEqual(titleBounds!.x + titleBounds!.width) + expect(arrowBounds!.x - titleBounds!.x - titleBounds!.width).toBeLessThan(8) + await shellMenu.click() + await page.getByRole('menu').waitFor() + expect(await panel.locator('[data-sidebar-terminal]').count()).toBe(0) + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'detached' }) + expect(await shellMenu.evaluate(element => element === document.activeElement)).toBe(true) + await bind(page, primary, 'Browser', 'Toggle right sidebar') const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]') await browserGuide.hover() diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index f269d80e55..e84cdc280d 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -61,8 +61,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { 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 ${previous}`, exact: true }).click() + await reference.getByRole('button', { name: 'Remove', exact: true }).click() + await reference.getByRole('group').waitFor({ state: 'hidden' }) } await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() await page.keyboard.press('Meta+Shift+Comma') diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index cbfc49732a..0bbe929b34 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -19,8 +19,8 @@ describe('web e2e: shortcut reference', () => { 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' }, + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit 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 } }) @@ -46,14 +46,13 @@ describe('web e2e: shortcut reference', () => { 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 }) + expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 440, height: 36 }) const rows = dialog.getByRole('listitem') - const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true }) + const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '消息输入' : 'Message input', 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(await stopButtons.count()).toBe(0) 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 @@ -72,29 +71,22 @@ describe('web e2e: shortcut reference', () => { 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 fixedBackground = await stopRow.evaluate(element => getComputedStyle(element).backgroundColor) + await stopRow.hover() + expect(await stopRow.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') + expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).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) + const badge = boundRow.getByRole('button', { name: locale === 'zh-CN' ? '修改快捷键速查快捷键' : 'Edit shortcut for Open keyboard shortcuts', exact: true }) + const restingColor = await boundRow.evaluate(element => getComputedStyle(element).backgroundColor) + await badge.hover({ position: { x: 12, y: 12 } }) + expect(await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(restingColor) + expect(await boundRow.evaluate(element => getComputedStyle(element).borderRadius)).toBe('12px') + expect(await badge.boundingBox()).toEqual(await boundRow.boundingBox()) await badge.click() await boundRow.getByRole('group').waitFor() expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) @@ -104,7 +96,7 @@ describe('web e2e: shortcut reference', () => { 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) + const boundActionsOpacity = () => boundRow.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).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') @@ -118,14 +110,17 @@ describe('web e2e: shortcut reference', () => { 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) + expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await row.getByRole('button').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 => ({ + color: getComputedStyle(element).color, border: getComputedStyle(element).borderColor, + }))) + .toEqual({ color: 'rgb(97, 102, 107)', border: 'rgba(0, 0, 0, 0.16)' }) expect(await recorder.evaluate(element => ({ focused: element === document.activeElement, outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow, @@ -155,7 +150,7 @@ describe('web e2e: shortcut reference', () => { 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') + expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).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') @@ -168,12 +163,14 @@ describe('web e2e: shortcut reference', () => { }))).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() + await editButton.click() + await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click() + await inline.waitFor({ state: 'hidden' }) 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() + expect(await row.getByRole('button').count()).toBe(1) + await editButton.click({ position: { x: 12, y: 12 } }) await inline.waitFor() expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0) await page.keyboard.press('Escape') @@ -182,7 +179,7 @@ describe('web e2e: shortcut reference', () => { 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 row.getByRole('button').count()).toBe(1) expect(await page.getByRole('dialog').count()).toBe(2) for (const query of ['abc', 'sendEnter']) { await page.getByRole('searchbox').fill(query) @@ -245,11 +242,13 @@ describe('web e2e: shortcut reference', () => { 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 + /'}` + const viewHint = `${locale === 'zh-CN' ? '全局唤起查看' : 'Open from anywhere'} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}` await viewButton.hover() - expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint) await viewButton.click() - await boundRow.getByRole('button').first().click() + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor() await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) @@ -266,7 +265,7 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) await viewButton.hover() - expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint) await viewButton.click() await page.getByRole('searchbox').fill('abc') expect(await dialog.getByRole('listitem').count()).toBe(0) @@ -286,7 +285,7 @@ describe('web e2e: shortcut reference', () => { 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 dialog.locator('footer').textContent()).toBe(locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults') 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) @@ -322,6 +321,65 @@ describe('web e2e: shortcut reference', () => { } finally { await context.close() } }) + it.each([ + { platform: 'Win32', primary: 'Control', keys: 'Ctrl + Alt + N' }, + { platform: 'MacIntel', primary: 'Meta', keys: '⌥ ⇧ ⌘ F12' }, + ])('shows the shortcut inside New Session on hover and keyboard focus at wide and narrow widths on $platform', async ({ platform, primary, keys }) => { + 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) + const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }) + await newSession.waitFor() + if (platform === 'MacIntel') await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' }) + await page.keyboard.press(`${primary}+/`) + await page.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + await page.keyboard.press(platform === 'Win32' ? 'Control+Alt+N' : 'Meta+Alt+Shift+F12') + await page.getByRole('group', { name: 'New Session', exact: true }).waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + for (const width of [420, 264]) { + const handle = (await page.locator('[data-side="sidebar"]').boundingBox())! + await page.mouse.move(handle.x + handle.width / 2, 240) + await page.mouse.down() + await page.mouse.move(width, 240, { steps: 10 }) + await page.mouse.up() + await expect.poll(() => newSession.locator('kbd').allTextContents()).toEqual(keys.split(' ')) + const centerOffset = () => newSession.evaluate((element) => { + const button = element.getBoundingClientRect() + const content = element.querySelector('[class*="newSessionContent"]')!.getBoundingClientRect() + return Math.abs(button.x + button.width / 2 - content.x - content.width / 2) + }) + expect(await centerOffset()).toBeLessThan(1) + const hintStyle = () => newSession.locator('[class*="newSessionShortcut"]').evaluate(element => ({ + opacity: getComputedStyle(element).opacity, mask: getComputedStyle(element.previousElementSibling!).maskImage, + })) + expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' }) + await newSession.hover() + expect(await centerOffset()).toBeLessThan(1) + const mask = await newSession.locator('[class*="newSessionLabelMask"]').boundingBox() + const hint = await newSession.locator('[class*="newSessionShortcut"]').boundingBox() + expect(mask!.x + mask!.width).toBeLessThanOrEqual(hint!.x + 1) + expect((await hintStyle()).opacity).toBe('1') + expect((await hintStyle()).mask).toContain('linear-gradient') + await newSession.screenshot({ path: fileURLToPath(new URL(`../../../.artifacts/new-session-${process.pid}-${platform}-${colorScheme}-${width}.png`, import.meta.url)) }) + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.mouse.move(700, 100) + expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' }) + await newSession.focus() + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Tab') + expect(await newSession.evaluate(element => element.matches(':focus-visible'))).toBe(true) + expect((await hintStyle()).opacity).toBe('1') + expect(await page.getByRole('tooltip').count()).toBe(0) + await newSession.evaluate((element) => { element.blur() }) + } + } + } finally { await context.close() } + }) + it.each([ { platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' }, { platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' }, @@ -371,7 +429,8 @@ describe('web e2e: shortcut reference', () => { 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 other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await other.getByRole('button', { name: 'Remove', 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() diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index e3c2a34c87..b389687677 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1176,8 +1176,9 @@ describe('web e2e: shipped right Sidebar', () => { } expect(await terminalEntry.evaluate(node => getComputedStyle(node).overflow)).toBe('hidden') const terminalActions = terminalEntry.getByRole('button') - for (const action of await terminalActions.all()) { - expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe('0px') + const terminalButtons = await terminalActions.all() + for (const [index, action] of terminalButtons.entries()) { + expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe(index === 0 ? '0px' : '4px') await action.hover() expect(await action.evaluate(node => getComputedStyle(node).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') } diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 556fd63e12..91725764e7 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -341,9 +341,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' }) const selector = entry.getByRole('button', { name: 'Choose shell', exact: true }) - const cardBox = (await entry.boundingBox())! + const titleBox = (await entry.getByText('New terminal', { exact: true }).boundingBox())! const triggerBox = (await selector.boundingBox())! - expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2) + expect(triggerBox.x).toBeGreaterThanOrEqual(titleBox.x + titleBox.width) + expect(triggerBox.x - titleBox.x - titleBox.width).toBeLessThan(8) await page.emulateMedia({ colorScheme: 'dark' }) await selector.click() await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor() diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 000e032a8e..b673798feb 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1116,8 +1116,8 @@ en: 93c4403f94ecdc14 zh: 2b2eb0c400881d71 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: 13071a796a3bfcd2 - zh: 93e5fbb1cdf78a79 + en: c9fa5343ea99733b + zh: d6de41351c57a73f /session-persistence-event-catalog/resolved-persistence-types/schedulechange: en: 16be53be76c16720 zh: 38fd164dde86ccac diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cdf607da2b..1179721a65 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -4307,7 +4307,7 @@ One of: SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -Sources: [`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts) +Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) One of: diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 146d6a3b51..b36dc48fcd 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -4309,7 +4309,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -来源:[`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts) +来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) 以下类型之一: diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 7472359902..645ebec6ae 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -93879,7 +93879,7 @@ "packages/sandbox/sandbox/src/index.ts#SandboxMode" ], "sources": [ - "packages/sandbox/sandbox/src/index.ts:29" + "packages/sandbox/sandbox/src/index.ts:30" ] }, { diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index e7ec59d39a..094d83b58f 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -54,8 +54,8 @@ en: e713a4a11a15d13c zh: e713a4a11a15d13c /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question: - en: 3d5e09e59e7d5274 - zh: 624c14da73524315 + en: f42b7f17ad59577a + zh: 7e1eee22ff35d4b1 /tool-schema-catalog/deepseek-ai-dsh-tools: en: 76fbbf0fe38aef10 zh: 76fbbf0fe38aef10 @@ -66,26 +66,26 @@ en: b500b530845e26a9 zh: b500b530845e26a9 /tool-schema-catalog/deepseek-ai-dsh-plan-mode/exit-plan-mode: - en: a4bf2ed3f9f55ff1 - zh: ce7b136d3ad3946a + en: b410483c2bd0c1a1 + zh: a07ceea6abf1dda3 /tool-schema-catalog/deepseek-ai-dsh-tool-bash: en: 408ff0a5189bd7ec zh: 408ff0a5189bd7ec /tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash: - en: 70add265cf4cfda5 - zh: 47c2e23d50557d10 + en: 67ee2b8d4997bd1b + zh: 82d1fb17b619c364 /tool-schema-catalog/deepseek-ai-dsh-tool-present: en: f656575e6df094f6 zh: f656575e6df094f6 /tool-schema-catalog/deepseek-ai-dsh-tool-present/present: - en: 4af0d859e1d39dc5 - zh: 780e75b1b42a6410 + en: 24ef5efe4c0edf50 + zh: a2914d345bd8eefa /tool-schema-catalog/deepseek-ai-dsh-tool-pwsh: en: b222fc05f3f8ee4a zh: b222fc05f3f8ee4a /tool-schema-catalog/deepseek-ai-dsh-tool-pwsh/pwsh: - en: 775abe9511776e1d - zh: 76ae33dab0dd399a + en: 9d7055b9fb8ba9de + zh: 0647025c36e80237 /tool-schema-catalog/deepseek-ai-dsh-tool-cordis: en: 76c7c62f9029b6f5 zh: 76c7c62f9029b6f5 @@ -123,8 +123,8 @@ en: 94bfc5b03c63baef zh: 339338a0e74696c3 /tool-schema-catalog/deepseek-ai-dsh-tool-fs/read-image: - en: 4e82f36d958c9c1c - zh: 1f87182c327bec10 + en: f6f7a1140066ce04 + zh: d528e01a0939795a /tool-schema-catalog/deepseek-ai-dsh-tool-fs/write: en: f80700c5d57e8d87 zh: fb7cccbba0d8d486 @@ -132,11 +132,11 @@ en: 703fea005ce84c50 zh: 703fea005ce84c50 /tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/glob: - en: 418d627507ddd693 - zh: 85f14b433c93efbe + en: 3ba734ba7852cdd5 + zh: c6ce31ff2c195520 /tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/grep: - en: 224288aaf3fbd214 - zh: 9e39dadfaa3768e7 + en: 6e1f7da62cc9a35b + zh: ee7f3081887f8067 /tool-schema-catalog/deepseek-ai-dsh-tool-terminal: en: 77501c1b592b6a88 zh: 77501c1b592b6a88 @@ -162,29 +162,29 @@ en: 74fff3741e7c13de zh: 74fff3741e7c13de /tool-schema-catalog/deepseek-ai-dsh-tool-goal/create-goal: - en: b22408124a0a6f34 - zh: 7b3bf9ceb5d7f1dc + en: 9ae877cdf8a641a3 + zh: f83c3c09e80e4190 /tool-schema-catalog/deepseek-ai-dsh-tool-goal/get-goal: - en: 73ab2b24d4de682d - zh: 82c42a427774c67d + en: 0b517f13dfde11fe + zh: b7ee7482e8ecde56 /tool-schema-catalog/deepseek-ai-dsh-tool-goal/update-goal: - en: a5ef842aa3d1109e - zh: 8f0a9135944e6f1d + en: e6b92fac5aad585c + zh: 6b3cdd87b059a98f /tool-schema-catalog/deepseek-ai-dsh-schedule: en: 573da0cf49b1eed6 zh: 573da0cf49b1eed6 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create: - en: 687b784279240ed2 - zh: a23fa1b08c15642b + en: 0a10b5db8919d316 + zh: 175d680a29f59b63 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete: - en: 3b5a084ce0d87d13 - zh: c1356d4b062f0e63 + en: cff096695f06e298 + zh: 3d0063a1e529377f /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list: - en: 4422ae2e2a4ca897 - zh: 56431645118d6ca0 + en: d8543c8503d0947e + zh: 205e6c813ad2b37c /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update: - en: a86e8751a34e9f06 - zh: 92a32fd4fc3657b4 + en: 3d5e2517b510238b + zh: d6e5c4c4465c9645 /tool-schema-catalog/deepseek-ai-dsh-tool-lsp: en: 32982123b1f0e5bc zh: 32982123b1f0e5bc @@ -201,8 +201,8 @@ en: 927a5ac0974b7642 zh: 927a5ac0974b7642 /tool-schema-catalog/deepseek-ai-dsh-tool-skill/skill: - en: cd716dc11308817d - zh: 08b4ed76101642a2 + en: 9b159d5831855afb + zh: 275ee82af0e0f59a /tool-schema-catalog/deepseek-ai-dsh-tool-session-query: en: eac1dff4b6d9e195 zh: eac1dff4b6d9e195 @@ -228,32 +228,32 @@ en: c89084b31ffccbce zh: d7b3b7000f2d2be9 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent: - en: df17e5091f9cdfc9 - zh: 3b31fe9df04242f2 + en: 55cbd29d70bcf3d7 + zh: 886a1b57446bd5e1 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control: en: ab82e130c3413f04 zh: ab82e130c3413f04 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent: - en: 3eb5ac28342e354f - zh: 91ca5628d8e333f0 + en: 0cec3f2520050839 + zh: 06735f086cea84f1 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents: - en: e688153e9ba4a684 - zh: 56c36cb0140c9202 + en: ae22e8543d123c39 + zh: 4943a5c5980fc2d3 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message: - en: b4d8ffa51c3c9cce - zh: 32d5281b2dec0d81 + en: 2ebdc450faef6446 + zh: 7e3cff35fedc6a32 /tool-schema-catalog/deepseek-ai-dsh-tool-jobs: en: 9be673b1604d0114 zh: 9be673b1604d0114 /tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-kill: - en: 39e53ddd7ea31c2d - zh: 16640c2952c263b6 + en: 14294cc1988ae768 + zh: 80a9152bdc02723f /tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-list: en: 0c37fb1fed979d72 zh: 5ea9d07f5a4a8a6c /tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output: - en: 2a1d3887688abd05 - zh: c1cafbabccc2e021 + en: f927cca09b283e21 + zh: 2acd9b36737f17c2 /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team: en: 6b1106f417b50db6 zh: 6b1106f417b50db6 @@ -288,14 +288,14 @@ en: dea62d26fb18ff68 zh: dea62d26fb18ff68 /tool-schema-catalog/deepseek-ai-dsh-tool-todo/todo-write: - en: 3e63adceedda7304 - zh: bc20a7b69ce2cddd + en: 8c64fe5f2e58a890 + zh: e3c780fc842aeb43 /tool-schema-catalog/deepseek-ai-dsh-tool-workflow: en: bb38a9d29f46563c zh: bb38a9d29f46563c /tool-schema-catalog/deepseek-ai-dsh-tool-workflow/workflow: - en: 4ca5c5f9ebc188e5 - zh: 98bbc45e441019af + en: fdd667cfe21481e8 + zh: 88864d3b958398a4 /tool-schema-catalog/deepseek-ai-dsh-tool-workspace-dependencies: en: 96bffc40b80df4b8 zh: 96bffc40b80df4b8 @@ -309,5 +309,5 @@ en: 8673f203aea9bdf2 zh: 4e17783ffdf34c83 /tool-schema-catalog/deepseek-ai-dsh-tool-web/web-search: - en: 096d6a19c83af773 - zh: fa5213caf9401216 + en: cc1ad38bf0006710 + zh: 324eacb827a09e93 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index d92c8d583f..d2c16e3006 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -449,7 +449,7 @@ Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../p ### `ask_user_question` -Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. +Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. ```json { @@ -571,7 +571,7 @@ Owned by the tool registry as a reserved transport outside filterable capability ### `exit_plan_mode` -Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. +Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. ```json { @@ -598,7 +598,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr ### `bash` -Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far. +Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way. ```json { @@ -642,7 +642,7 @@ The bash tool is the model-facing consumer of the bash executor seam. With a job ### `present` -Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. +Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied. ```json { @@ -650,6 +650,7 @@ Declare selected existing files accessible through the Session filesystem as fin "properties": { "files": { "type": "array", + "description": "Usually the 1-2 most important deliverables; at most 4 per call.", "items": { "type": "object", "additionalProperties": false, @@ -685,7 +686,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi ### `pwsh` -Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far. +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way. ```json { @@ -958,7 +959,7 @@ Edit an existing UTF-8 text file by replacing literal text. }, "old_string": { "type": "string", - "description": "Literal text to replace. Must match exactly." + "description": "Literal text to replace." }, "new_string": { "type": "string", @@ -1010,7 +1011,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts ### `read_image` -Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input. +Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image. ```json { @@ -1063,7 +1064,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-p ### `glob` -Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries. +Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result is sampled across top-level entries and reports where the complete list was saved. ```json { @@ -1088,7 +1089,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc ### `grep` -Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context. +Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved. ```json { @@ -1288,7 +1289,7 @@ The six terminal tools are opt-in and complement one-shot shell/filesystem tools ### `create_goal` -Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. +Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say "goal"; not for single-turn work. ```json { @@ -1313,7 +1314,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. +Read the current session goal, including the id and revision that update_goal requires. ```json { @@ -1326,7 +1327,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. +Update the current goal. ```json { @@ -1342,7 +1343,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "action": { "type": "string", - "description": "edit | pause | resume | complete | blocked", + "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.", "enum": [ "edit", "pause", @@ -1361,7 +1362,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress." } }, "required": [ @@ -1382,7 +1383,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, 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 60, 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. +Create a reminder in the current session that delivers prompt when it becomes due. Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash. ```json { @@ -1394,19 +1395,19 @@ Create one reminder in the current session. Supply a non-empty prompt, a title, }, "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." + "description": "Task name of at most 120 characters, shown on the task card and in task lists." }, "after_seconds": { "type": "number", - "description": "Positive safe-integer delay in seconds." + "description": "Delay in whole seconds." }, "every_seconds": { "type": "number", - "description": "Fixed-rate safe-integer interval in seconds, at least 60." + "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time." }, "daily": { "type": "object", - "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "description": "Every day at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1425,7 +1426,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title, }, "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.", + "description": "On the given weekdays at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1438,7 +1439,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title, }, "weekdays": { "type": "array", - "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.", "items": { "type": "integer" } @@ -1452,12 +1453,12 @@ Create one reminder in the current session. Supply a non-empty prompt, a title, }, "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.", + "description": "Five-field Vixie cron expression in a time zone.", "additionalProperties": false, "properties": { "expression": { "type": "string", - "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches." }, "time_zone": { "type": "string", @@ -1495,7 +1496,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title, ] } ], - "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone." } }, "required": [ @@ -1509,7 +1510,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_delete` -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. +Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued. ```json { @@ -1517,7 +1518,7 @@ Delete one retained reminder in the current session by its exact id, whether act "properties": { "id": { "type": "string", - "description": "Exact schedule id." + "description": "Schedule id returned by schedule_list." } }, "required": [ @@ -1530,7 +1531,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_list` -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. +List the active reminders in the current session. ```json { @@ -1543,7 +1544,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `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. +Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; omitted fields keep their stored values. To change a relative delay, create a new reminder. ```json { @@ -1551,23 +1552,23 @@ Change one reminder in the current session in place, keeping its id and its save "properties": { "id": { "type": "string", - "description": "Exact schedule id that schedule_list returned for one reminder." + "description": "Schedule id returned by schedule_list." }, "title": { "type": "string", - "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + "description": "New task name of at most 120 characters." }, "prompt": { "type": "string", - "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + "description": "New reminder content." }, "every_seconds": { "type": "number", - "description": "Fixed-rate safe-integer interval in seconds, at least 60." + "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time." }, "daily": { "type": "object", - "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "description": "Every day at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1586,7 +1587,7 @@ Change one reminder in the current session in place, keeping its id and its save }, "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.", + "description": "On the given weekdays at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1599,7 +1600,7 @@ Change one reminder in the current session in place, keeping its id and its save }, "weekdays": { "type": "array", - "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.", "items": { "type": "integer" } @@ -1613,12 +1614,12 @@ Change one reminder in the current session in place, keeping its id and its save }, "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.", + "description": "Five-field Vixie cron expression in a time zone.", "additionalProperties": false, "properties": { "expression": { "type": "string", - "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches." }, "time_zone": { "type": "string", @@ -1656,7 +1657,7 @@ Change one reminder in the current session in place, keeping its id and its save ] } ], - "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone." } }, "required": [ @@ -1754,7 +1755,7 @@ A fixed foreground workflow starts one fresh structured child per round; the mod ### `skill` -Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. +Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog. ```json { @@ -2036,7 +2037,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This call waits for the result by default. ```json { @@ -2052,7 +2053,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false." } }, "required": [ @@ -2072,7 +2073,7 @@ The registered delegation name is the load-time `toolName` config (default `suba ### `interrupt_agent` -Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. +Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a direct child's conversation later with send_message. Subagents it started will keep running. ```json { @@ -2080,7 +2081,7 @@ Request cancellation of a background agent's current turn by its agent id. The t "properties": { "agent_id": { "type": "string", - "description": "The agent id of the running agent to interrupt." + "description": "The id of an agent created under you: your direct child or a deeper descendant." } }, "required": [ @@ -2093,7 +2094,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. +List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation. ```json { @@ -2101,7 +2102,7 @@ List your continuable background subagents by durable id and label. Use it to re "properties": { "scope": { "type": "string", - "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", "enum": [ "children", "descendants" @@ -2115,7 +2116,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag ### `send_message` -Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered. +Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer. ```json { @@ -2147,7 +2148,7 @@ The globally named control tools over continuable background subagents: provider ### `job_kill` -Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. +Request cancellation of a running background job. ```json { @@ -2185,7 +2186,7 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/ ### `job_output` -Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. +Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job. ```json { @@ -2197,11 +2198,11 @@ Read a background job. Stream jobs return only output since the previous read; f }, "wait": { "type": "boolean", - "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false." }, "timeout_ms": { "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration." } }, "required": [ @@ -2510,7 +2511,7 @@ All nine tools are scoped to implicit Team Leads and durable teammates. The ship ### `todo_write` -Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). +Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done. ```json { @@ -2562,17 +2563,13 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. -The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. - Script-body hooks: -- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. -- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. +- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides. +- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. -Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. - -Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`). +Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work. ```json { @@ -2580,11 +2577,11 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "properties": { "script": { "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result." }, "meta": { "type": "object", - "description": "The workflow identity block (plain JSON — never code).", + "description": "The workflow identity as plain JSON, not code.", "additionalProperties": true, "properties": { "name": { @@ -2697,7 +2694,7 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde ### `web_search` -Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs. +Search the web for current information. Returns an optional summary answer and a list of source URLs. ```json { @@ -2705,7 +2702,7 @@ Search the web for current information. Provide 1–4 queries in the required qu "properties": { "queries": { "type": "array", - "description": "Required search queries; accepts 1–4 items and merges their results.", + "description": "1–4 search queries; their results are merged.", "items": { "type": "string" } diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 9c05b6f477..1b3ad1897e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -453,7 +453,7 @@ ### `ask_user_question` -继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。发送一个或多个问题,每个问题都带一个稳定 id,该 id 会在答案中原样返回。 +继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。 ```json { @@ -575,7 +575,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 ### `exit_plan_mode` -仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。发送**完整的** Markdown 计划,以一个为计划命名的 # 标题开头。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。 +仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。 ```json { @@ -602,7 +602,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 ### `bash` -执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。 +执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝:请勿换一种方式重试。 ```json { @@ -646,7 +646,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job ### `present` -选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 +将已有文件声明为交付给用户的最终交付物。当用户需要独立文件时使用,尤其是 Office 文档、电子表格和演示文稿;如果最终回复已经足够,优先使用最终回复。用户打开的是当前文件;不复制其内容。 ```json { @@ -654,6 +654,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job "properties": { "files": { "type": "array", + "description": "Usually the 1-2 most important deliverables; at most 4 per call.", "items": { "type": "object", "additionalProperties": false, @@ -689,7 +690,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job ### `pwsh` -执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。 +执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝:请勿换一种方式重试。 ```json { @@ -964,7 +965,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 }, "old_string": { "type": "string", - "description": "Literal text to replace. Must match exactly." + "description": "Literal text to replace." }, "new_string": { "type": "string", @@ -1016,7 +1017,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `read_image` -读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。无扩展名的路径同样被接受;格式按文件内容检测,因此规范化附件路径可以直接传入,无需复制或重命名。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。 +读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。大图会自动缩小;不要为了查看图片而安装图片库或创建缩略图。 ```json { @@ -1069,7 +1070,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `glob` -查找路径匹配 glob 模式的文件。只返回匹配的文件路径,绝不返回目录;包括隐藏文件和被忽略的文件,但排除 VCS 元数据目录。最多按修改时间顺序返回 100 条路径;如果结果更多,则改为返回从顶层条目中抽样的 100 条路径,说明已抽样,并报告完整排序列表的保存位置。该工具不枚举目录条目。 +查找路径匹配 glob 模式的文件(不含目录),包括隐藏文件和被忽略的文件。最多按修改时间顺序返回 100 条路径;更大的结果会从顶层条目中抽样,并报告完整列表的保存位置。 ```json { @@ -1094,7 +1095,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `grep` -使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。前 250 条匹配会直接返回;结果达到上限时会报告完整匹配列表的保存位置。如需周边上下文,请对匹配的文件使用 read。 +使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。最多返回 250 条匹配;更大的结果会报告完整匹配列表的保存位置。 ```json { @@ -1294,7 +1295,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn ### `create_goal` -当当前直接人类请求是需要跨自主 Goal Round 持续推进的长期目标时,创建一个持久化的同会话完成目标。即使用户没有明确说「创建目标」,你也可以推断其意图。不要用于简单的单轮工作。执行时会拒绝非人类权限和 subagent 权限。 +创建一个持久化目标,使当前会话跨自动延续 Round 持续工作。当直接人类请求是长期目标时使用,即使用户没有说「目标」;不要用于单轮工作。 ```json { @@ -1319,7 +1320,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn ### `get_goal` -读取当前的同会话目标,包括确切的 id/revision、目标、阶段、已完成的延续 Round 数、Round 上限、存在时的阻塞原因,以及是否已准备下一次延续。更新目标前请先调用此工具。 +读取当前会话目标,包括 update_goal 所需的 id 和 revision。 ```json { @@ -1332,7 +1333,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn ### `update_goal` -更新确切的当前目标 revision。edit、pause 和 resume 要求直接的顶层人类请求。在自动延续当前目标期间,也允许 complete 和 blocked。在达到配置的最小 Round 数之前会拒绝 blocked;模型仍须判断相同条件是否在这些 Round 中持续存在,并在 blocked_reason 中予以说明。 +更新当前目标。 ```json { @@ -1348,7 +1349,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn }, "action": { "type": "string", - "description": "edit | pause | resume | complete | blocked", + "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.", "enum": [ "edit", "pause", @@ -1367,7 +1368,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress." } }, "required": [ @@ -1388,7 +1389,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_create` -在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 60 的安全整数 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 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。 +在当前会话中创建一条提醒,到期时投递 prompt。请恰好提供一个时间参数:after_seconds、at、every_seconds、daily、weekly 或 cron。时区中不存在的本地时间会被跳过;重复出现的本地时间只在较早的时刻触发一次。停机后,重复提醒只投递最近错过的一次。崩溃后可能重复投递。 ```json { @@ -1400,19 +1401,19 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "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." + "description": "Task name of at most 120 characters, shown on the task card and in task lists." }, "after_seconds": { "type": "number", - "description": "Positive safe-integer delay in seconds." + "description": "Delay in whole seconds." }, "every_seconds": { "type": "number", - "description": "Fixed-rate safe-integer interval in seconds, at least 60." + "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time." }, "daily": { "type": "object", - "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "description": "Every day at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1431,7 +1432,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "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.", + "description": "On the given weekdays at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1444,7 +1445,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "weekdays": { "type": "array", - "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.", "items": { "type": "integer" } @@ -1458,12 +1459,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "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.", + "description": "Five-field Vixie cron expression in a time zone.", "additionalProperties": false, "properties": { "expression": { "type": "string", - "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches." }, "time_zone": { "type": "string", @@ -1501,7 +1502,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ] } ], - "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone." } }, "required": [ @@ -1515,7 +1516,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_delete` -使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。 +删除当前会话中的一条提醒,活动或已结束的均可。删除不会撤回已经入队的提醒消息。 ```json { @@ -1523,7 +1524,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "properties": { "id": { "type": "string", - "description": "Exact schedule id." + "description": "Schedule id returned by schedule_list." } }, "required": [ @@ -1536,7 +1537,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_list` -列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。 +列出当前会话中的活动提醒。 ```json { @@ -1553,7 +1554,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_update` -在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。 +原地修改一条提醒并保留其 id。提供新的 title、prompt,或至多一个时间参数;未提供的字段保持原值。需要相对延迟时请新建一条提醒。 ```json { @@ -1561,23 +1562,23 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "properties": { "id": { "type": "string", - "description": "Exact schedule id that schedule_list returned for one reminder." + "description": "Schedule id returned by schedule_list." }, "title": { "type": "string", - "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + "description": "New task name of at most 120 characters." }, "prompt": { "type": "string", - "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + "description": "New reminder content." }, "every_seconds": { "type": "number", - "description": "Fixed-rate safe-integer interval in seconds, at least 60." + "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time." }, "daily": { "type": "object", - "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "description": "Every day at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1596,7 +1597,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "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.", + "description": "On the given weekdays at a local time.", "additionalProperties": false, "properties": { "time": { @@ -1609,7 +1610,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "weekdays": { "type": "array", - "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.", + "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.", "items": { "type": "integer" } @@ -1623,12 +1624,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "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.", + "description": "Five-field Vixie cron expression in a time zone.", "additionalProperties": false, "properties": { "expression": { "type": "string", - "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches." }, "time_zone": { "type": "string", @@ -1666,7 +1667,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ] } ], - "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone." } }, "required": [ @@ -1764,7 +1765,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `skill` -加载可用 skill(技能)的完整说明。在执行点名某项 skill 或与其明确匹配的任务前,请使用会话 skill 目录中的确切名称调用此工具。 +加载某项 skill(技能)的完整说明。在执行点名某项 skill 或与会话 skill 目录中某项 skill 明确匹配的任务前,请调用此工具。 ```json { @@ -2046,7 +2047,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此调用默认等待结果。 ```json { @@ -2062,7 +2063,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false." } }, "required": [ @@ -2082,7 +2083,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `interrupt_agent` -根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。 +请 subagent 停止当前工作。此调用不等待其停止即返回。之后可以用 send_message 继续与直接子级的对话。它启动的 subagent 会继续运行。 ```json { @@ -2090,7 +2091,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "agent_id": { "type": "string", - "description": "The agent id of the running agent to interrupt." + "description": "The id of an agent created under you: your direct child or a deeper descendant." } }, "required": [ @@ -2103,7 +2104,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 +列出你启动的 subagent 及其 id、标签和状态。running 表示正在工作;inactive 表示当前未在工作。subagent 完成时你会收到通知,无需反复查看状态。使用 send_message 继续对话。 ```json { @@ -2111,7 +2112,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "scope": { "type": "string", - "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", "enum": [ "children", "descendants" @@ -2125,7 +2126,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 +向某个 agent 发送消息。工作中的 agent 会在下一个 step 收到消息;空闲的 agent 会以该消息开始新一轮。返回投递确认,而不是该 agent 的答案。 ```json { @@ -2157,7 +2158,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `job_kill` -根据 job id 请求取消正在运行的后台任务。此调用立即返回;任务的工作真正停止后,会以 killed 状态结算。 +请求取消正在运行的后台任务。 ```json { @@ -2195,7 +2196,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `job_output` -读取后台任务。流式任务只返回自上次读取以来的输出;最终输出任务会在结算后返回结果。每个响应都以 `[status: ...]` 结尾。读取默认不阻塞;设置 `wait: true` 后,最长等待到配置的上限。 +读取后台任务:流式任务返回自上次读取以来的输出,已完成的最终输出任务返回其结果。 ```json { @@ -2207,11 +2208,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "wait": { "type": "boolean", - "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false." }, "timeout_ms": { "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration." } }, "required": [ @@ -2521,7 +2522,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `todo_write` -记录并更新当前工作的结构化任务列表。每次调用都要发送**完整列表**,它会**替换**之前的列表,不支持局部更新或逐项编辑。请用它规划多步骤工作并展示进度:开始前为每个具体步骤添加一项 todo。将当前正在处理的每项 todo 标记为 `in_progress`;确实并行运行时(例如并发 subagent 或后台命令)可同时标记多项,顺序工作则标记 1 项。只要工作尚未完成,就应至少有一项任务为 `in_progress`。某项 todo 完成后立即标记为 `completed`,不要批量标记完成;只有全部工作完成后,才可以没有 `in_progress` 项。简单的单步骤任务无需使用列表。状态:`pending`(未开始)、`in_progress`(正在处理)、`completed`(已完成)。 +记录并更新任务列表,用于规划多步骤工作并展示进度;简单的单步骤任务无需使用。开始前为每个具体步骤添加一项 todo。只要工作尚未完成,就将正在处理的 todo 标记为 `in_progress`,仅在工作并行运行时同时标记多项。某项 todo 完成后立即标记为 `completed`。 ```json { @@ -2573,18 +2574,14 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 运行用于大规模编排 subagent 的 JavaScript 工作流脚本。当工作会分散到许多相互独立的部分时,请使用此工具,例如审查大量文件、执行迁移、开展多角度研究或对发现进行对抗式验证;此时应将编排写成脚本,而不是逐轮委派。 -工作流的身份通过 `meta` 参数以 JSON 形式传入:必填的 `name`(简短 kebab-case)和 `description` 字符串,以及可选的 `whenToUse` 字符串和 `phases` 数组(`{title, detail?, provider?, model?}`)。`script` 参数只能是纯 JavaScript **函数体**,不能是 TypeScript,也不能包含 `export const meta` 语句;meta 是参数而非代码。脚本支持顶层 await;请以 `return ` 结尾,该值必须可以 JSON 序列化,并作为此工具的结果。 - 脚本函数体提供以下钩子: -- `agent(prompt, opts?): Promise`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,不支持 pattern/format/数值边界,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项,两者可单独提供。其他任何选项(`effort`/`isolation`/`agentType`)都会明确报错。 -- `pipeline(items, ...stages): Promise`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。普通的阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。 +- `agent(prompt, opts?): Promise`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项。 +- `pipeline(items, ...stages): Promise`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。 - `parallel(thunks): Promise`:并发运行零参数函数并等待**全部**完成。它会形成屏障,仅当某个阶段确实需要汇总全部先前结果时使用。抛出异常的 thunk 解析为 `null`。 - `phase(title)`:开始一个进度阶段;`log(message)`:说明进度;`args`:工具调用的 `args` 输入,原样提供。 -如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。 - -约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。 +如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),整个脚本会终止,而不会产生 `null`。脚本没有文件系统、网络、定时器或 Node.js API;具体工作由 agent 完成。 ```json { @@ -2592,11 +2589,11 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 "properties": { "script": { "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result." }, "meta": { "type": "object", - "description": "The workflow identity block (plain JSON — never code).", + "description": "The workflow identity as plain JSON, not code.", "additionalProperties": true, "properties": { "name": { @@ -2709,7 +2706,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 ### `web_search` -在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。 +在 Web 上搜索最新信息。返回可选的摘要答案和来源 URL 列表。 ```json { @@ -2717,7 +2714,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 "properties": { "queries": { "type": "array", - "description": "Required search queries; accepts 1–4 items and merges their results.", + "description": "1–4 search queries; their results are merged.", "items": { "type": "string" } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9c9627d70d..d2b72795ad 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13ae68865fa315d7 zh: c152f020abf20f35 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 72a6f8429c1ab3d0 - zh: ae76012917c96dcf + en: ea2f1e81b1f9a38a + zh: a2d4745b3e93e5a9 /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 2641c53cde..57d63f0c6e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -86,7 +86,7 @@ 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. +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 `input` 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 f2ce7a515a..d1cacb38b3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -86,7 +86,7 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切 当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。 -在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `application` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。 +在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `input` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 731becd6c2..0fc9b2560c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { ), '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', + id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input', } const dispose = scope.shortcuts.registerFixed(command) stopShortcut.set(command.keys) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 070d9e352f..88ae0697f7 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,7 +9,6 @@ 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': '打开命令菜单', @@ -380,7 +379,6 @@ 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', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ee054eb35e..2361d96c99 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -311,7 +311,6 @@ 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?.() @@ -459,7 +458,7 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })} } {interruptible && ( - + {(canCloseTab?.(tab.id) ?? true) && ( - - {/* Portaled to body (Menu primitive's portal mode) so the sidebar and @@ -452,7 +458,9 @@ export function ModelSelect( {model.name} - {selected ? : null} + {pending?.provider === group.id && pending.model === model.id + ? + : selected ? : null} ) @@ -492,7 +500,10 @@ export function ModelSelect( {level.label} - {effectiveEffort === level.effort ? : null} + {pending !== null && pending.provider === state.current?.provider + && pending.model === state.current.model && pending.reasoningEffort === level.effort + ? + : effectiveEffort === level.effort ? : null} ))} diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 0cd75e7541..ad885ffac6 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -28,6 +28,8 @@ export interface ModelDirectoryState { failures: readonly ModelCatalogFailure[] /** Lifecycle of the in-flight operation. */ status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error' + /** Selection submitted by the latest `select` until it settles; null otherwise. */ + pending: ModelSelection | null /** Whole-request or selection failure text; null when none. */ error: string | null } @@ -36,7 +38,7 @@ export interface ModelDirectoryState { export class ModelDirectory { /** The shared snapshot both entries render from (uSES-safe store). */ readonly store: SnapshotStore = createSnapshotStore({ - current: null, routable: null, groups: [], failures: [], status: 'idle', error: null, + current: null, routable: null, groups: [], failures: [], status: 'idle', pending: null, error: null, }) /** Latest selection operation wins; an older response never overwrites a newer one. */ @@ -90,7 +92,7 @@ export class ModelDirectory { const previousEffort = previous?.reasoningEffort ?? (previous === null ? undefined : this.catalog.reasoningFor(previous)?.defaultEffort) const nextEffort = selection.reasoningEffort ?? this.catalog.reasoningFor(selection)?.defaultEffort const generation = ++this.generation - this.store.update((s) => { s.status = 'selecting'; s.error = null }) + this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null }) const result = await this.sessions.selectModel({ sessionId: this.sessionId, provider: selection.provider, @@ -105,6 +107,7 @@ export class ModelDirectory { if (!result.ok) { this.store.update((s) => { s.status = 'error' + s.pending = null s.error = `${result.error.code}: ${result.error.message}` }) return result @@ -117,7 +120,7 @@ export class ModelDirectory { session_id: this.sessionId, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default', }) } - this.store.update((s) => { s.status = 'ready'; s.error = null }) + this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null }) this.syncInputs() return { ok: true, value: undefined } } @@ -130,6 +133,7 @@ export class ModelDirectory { ++this.generation this.store.update((state) => { if (state.status === 'selecting') state.status = 'idle' + state.pending = null state.error = null }) this.syncInputs() @@ -165,6 +169,7 @@ export class ModelDirectory { groups: catalog.value?.groups ?? [], failures: catalog.value?.failures ?? [], status: catalog.status === 'error' ? 'error' : 'loading', + pending: this.store.getSnapshot().pending, error: catalog.error, }) return @@ -181,6 +186,7 @@ export class ModelDirectory { status: this.store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready', + pending: this.store.getSnapshot().pending, error: null, }) } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index c8f892cae7..0719e8c476 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -208,7 +208,7 @@ describe('ui-model-selection dual entry', () => { b.rejectSelection() await expect(b.popup().onSelect(options[0]!, input)).rejects.toThrow(zh['error.sessionInUse']) expect(b.ctx.modelDirectories.directoryFor(sid('owned')).store.getSnapshot()).toMatchObject({ - status: 'error', error: 'session/writer-held: writer held', + status: 'error', pending: null, error: 'session/writer-held: writer held', }) }) @@ -252,12 +252,12 @@ describe('ui-model-selection dual entry', () => { const b = await bench() b.mint('s1') const seatFace = b.seat().inject!(sid('s1')) - // Switch through the SEAT entry. - expect(await seatFace.select({ - provider: 'deepseek-official', - model: 'deepseek-v4-pro', - reasoningEffort: 'max', - })).toEqual({ ok: true, value: undefined }) + // Switch through the SEAT entry; the directory holds the submission until it settles. + const selection = { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' } + const settled = seatFace.select(selection) + expect(seatFace.directory.getSnapshot()).toMatchObject({ status: 'selecting', pending: selection }) + expect(await settled).toEqual({ ok: true, value: undefined }) + expect(seatFace.directory.getSnapshot()).toMatchObject({ status: 'ready', pending: null }) expect(b.hostCurrent()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-pro', @@ -339,6 +339,19 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(1) }) + it('drops a pending selection on connection reset and ignores its late settlement', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + const late = face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) + expect(face.directory.getSnapshot().pending).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) + b.ctx.emit('connection/reset') + expect(face.directory.getSnapshot()).toMatchObject({ status: 'loading', pending: null }) + await late + expect(face.directory.getSnapshot().pending).toBeNull() + }) + it('hides the effective selection until the reconnected catalog validates it', async () => { const b = await bench() b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 2aabbf644a..aafc5b98a9 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -47,6 +47,7 @@ function state(overrides: Partial = {}): ModelDirectoryStat }], failures: [], status: 'ready', + pending: null, error: null, ...overrides, } @@ -223,6 +224,65 @@ describe('ModelSelect reasoning effort', () => { expect(screen.queryByRole('button', { name: '重试' })).toBeNull() }) + it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => { + const groups = [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + ], + }] + const directory = createSnapshotStore(state({ groups })) + let settle!: () => void + const select = vi.fn((selection: ModelSelection) => { + directory.set(state({ groups, status: 'selecting', pending: selection })) + return new Promise<{ ok: true; value: undefined }>((resolve) => { + settle = () => { + directory.set(state({ groups, current: selection })) + resolve({ ok: true, value: undefined }) + } + }) + }) + render() + const spinners = () => document.querySelectorAll('[data-state="ongoing"]') + + const trigger = screen.getByRole('button', { name: /选择模型|当前/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) + expect(spinners()).toHaveLength(2) + expect(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }).querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(trigger.getAttribute('aria-busy')).toBe('true') + + // Leaving the pane unmounts the row; the trigger keeps the feedback. + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(screen.queryByRole('menuitemradio')).toBeNull() + expect(spinners()).toHaveLength(1) + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + + await act(async () => { settle() }) + expect(spinners()).toHaveLength(0) + expect(trigger.getAttribute('aria-busy')).toBe('false') + }) + + it('spins on the chosen effort row only', () => { + const directory = createSnapshotStore(state()) + const select = vi.fn((selection: ModelSelection) => { + directory.set(state({ status: 'selecting', pending: selection })) + return new Promise(() => {}) + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ })) + fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ })) + expect(screen.getAllByRole('menuitemradio') + .filter(row => row.querySelector('[data-state="ongoing"]') !== null) + .map(row => row.textContent)).toEqual(['Max']) + }) + it('portals the placed menu card to body and closes only on truly-outside mousedown', () => { const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')! const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! 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 ba28840f3d..93d4c67625 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -132,7 +132,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
- + ) + fireEvent.focus(screen.getByText('anchor')) + expect(Array.from(screen.getByRole('tooltip', { name: 'Reload ⌘ R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['⌘', 'R']) + view.rerender() + expect(Array.from(screen.getByRole('tooltip', { name: 'Reload Ctrl + R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', '+', 'R']) + view.rerender() + expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull() + fireEvent.click(screen.getByText('anchor')) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + it('fits from observed sizes without synchronously measuring the bubble', () => { automaticResize = false const measured = vi.spyOn(Element.prototype, 'getBoundingClientRect') diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index af6cf8b40c..761ae2dc0f 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -218,7 +218,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) { wide, settingsOpen: open, openSettings: actions.open, ...(shortcut?.keys.length ? { settingsShortcut: { keys: shortcut.keys, aria: shortcut.aria } } : {}), openOnboarding: (id) => { close(); setRequestedOnboarding(id) }, - }, { fallback: + }, { fallback: }
- + @@ -114,11 +113,6 @@ export function ShortcutReference({ if (mounted.current) setBusy(false) return result } - const remove = async (row: ShortcutCatalogEntry): Promise => { - const result = await persist({ type: 'set', id: row.id, binding: null }, config.revision) - if (!mounted.current) return - notify(result.status === 'saved' ? t('saved') : shortcutFailure(result, catalog, t, runtime), result.status !== 'saved') - } const resetAll = async (): Promise => { /* v8 ignore next -- The reset control is accessible only while its confirmation is open. */ if (resetRevision === null) return @@ -136,6 +130,7 @@ export function ShortcutReference({ const input = search.current if (open && input !== null && !isBehindModal(input)) focusWithoutRing(input) }, [open, focusRequest]) + // Stop stays after the other fixed input actions. const entries = [ ...catalog.map(row => ({ ...row, @@ -143,7 +138,8 @@ export function ShortcutReference({ group: 'application' as const, })), ...fixedCatalog.map(row => ({ ...row, names: [row.id, row.keys.join(' ')] })), - ].sort((left, right) => (coreActionOrder.get(left.id) ?? coreActionOrder.size) - (coreActionOrder.get(right.id) ?? coreActionOrder.size) + ].sort((left, right) => Number(left.id === 'response.stop') - Number(right.id === 'response.stop') + || (coreActionOrder.get(left.id) ?? coreActionOrder.size) - (coreActionOrder.get(right.id) ?? coreActionOrder.size) || Number(left.id > right.id) - Number(left.id < right.id)) const ranked = rankByName(entries.flatMap(row => row.names.map(name => ({ name, label: row.label, row }))), query.trim()) const matches = [...new Set(ranked.map(match => match.row))] @@ -156,65 +152,63 @@ export function ShortcutReference({ }, [open, resetRevision, modifiedCount]) return <> -
-

{t('title')}

- -
-
-
- - { actions.search(event.target.value) }} /> +
{ + if (target === null || busy || !(event.target instanceof Element) || event.target.closest('button, input, a, [contenteditable="true"], [role="group"]') !== null) return + event.preventDefault() + closeEditor() + }}> +
+

{t('title')}

+ +
+
+
+ + { actions.search(event.target.value) }} /> + {query !== '' && } +
-
-
- {(['application', 'input', 'menus', 'approval'] as const).map((group) => { - const rows = matches.filter(row => row.group === group) - if (rows.length === 0) return null - return
- {group !== 'application' &&

{t(group)}

} -
    {rows.map(row =>
  • - {row.label} - - {'modified' in row - ? target?.id === row.id - ? - : <> - - {row.binding !== null && } - - - - - : } - -
  • )}
-
- })} - {matches.length === 0 &&

{t('empty')}

} + + : } + + )} + + })} + {matches.length === 0 &&

{t('empty')}

} +
+
+ + {modifiedCount > 0 && {t('modified-count', { count: modifiedCount })}} +
-
- - {t('modified-count', { count: modifiedCount })} -
{ if (!busy) setResetRevision(null) }} diff --git a/packages/client/ui-shortcuts/src/client/locales.ts b/packages/client/ui-shortcuts/src/client/locales.ts index 681228d735..13cd61d4ba 100644 --- a/packages/client/ui-shortcuts/src/client/locales.ts +++ b/packages/client/ui-shortcuts/src/client/locales.ts @@ -1,6 +1,6 @@ /** Shortcut reference copy; only implemented operations appear in the catalog. */ export const zh = { - 'edit-label': '修改{command}快捷键', 'remove-label': '移除{command}快捷键', + 'edit-label': '修改{command}快捷键', record: '按下快捷键', 'record-help': '松开组合键即保存。Tab 切换操作,Esc 退出录键。', 'web-help': '浏览器可用组合:Mod+/、Mod+Shift+,、Mod+Shift+.。Mod 在 Mac 上为 Command,其他系统为 Ctrl。', 'unsupported-key': '暂不支持这个按键。', reserved: '此组合由系统或文本编辑操作保留。', @@ -26,16 +26,16 @@ export const zh = { 'web-reload': '刷新页面', 'desktop-reload': '重新启动 Harness', 'using-defaults': '当前使用默认键位。', 'using-accepted': '当前继续使用上次读取成功的键位。', 'native-failed': '无法启用桌面录键保护,请关闭后重试。', - hint: '{label} {keys}', - title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '查看快捷键', - description: '查看当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键', + 'global-hint': '全局唤起查看', 'clear-search': '清空搜索', + title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '编辑快捷键', + description: '查看和编辑当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键', application: '应用操作', input: '消息输入', menus: '菜单与弹层', approval: '审批区域', unbound: '暂无快捷键', empty: '没有匹配的快捷键', move: '移动菜单选择', select: '选择菜单项', dismiss: '关闭菜单或顶层弹窗', } /** Typed English counterpart. */ export const en: Record = { - 'edit-label': 'Edit shortcut for {command}', 'remove-label': 'Remove shortcut for {command}', + 'edit-label': 'Edit shortcut for {command}', record: 'Press a shortcut', 'record-help': 'Release the keys to save. Tab moves between actions; Esc cancels.', 'web-help': 'Browser combinations: Mod+/, Mod+Shift+,, Mod+Shift+.. Mod is Command on Mac and Ctrl elsewhere.', 'unsupported-key': 'This key is not supported.', reserved: 'This combination is reserved for system or text editing actions.', @@ -61,9 +61,9 @@ export const en: Record = { 'web-reload': 'reload the page', 'desktop-reload': 'restart Harness', 'using-defaults': 'Default bindings are active.', 'using-accepted': 'The last successfully read bindings remain active.', 'native-failed': 'Could not protect desktop key recording. Exit recording and try again.', - hint: '{label} {keys}', - title: 'Keyboard shortcuts', open: 'Open keyboard shortcuts', settings: 'Keyboard shortcuts', view: 'View shortcuts', - description: 'Browse available shortcuts and input actions', search: 'Search shortcuts', close: 'Close keyboard shortcuts', + 'global-hint': 'Open from anywhere', 'clear-search': 'Clear search', + title: 'Keyboard shortcuts', open: 'Open keyboard shortcuts', settings: 'Keyboard shortcuts', view: 'Edit shortcuts', + description: 'View and edit available shortcuts and input actions', search: 'Search shortcuts', close: 'Close keyboard shortcuts', application: 'Application', input: 'Message input', menus: 'Menus and dialogs', approval: 'Approval area', unbound: 'No shortcut', empty: 'No matching shortcuts', move: 'Move menu selection', select: 'Select menu item', dismiss: 'Close menu or top dialog', diff --git a/packages/client/ui-shortcuts/tests/commands.client.spec.ts b/packages/client/ui-shortcuts/tests/commands.client.spec.ts index a58aa1ac3d..5b1401a75a 100644 --- a/packages/client/ui-shortcuts/tests/commands.client.spec.ts +++ b/packages/client/ui-shortcuts/tests/commands.client.spec.ts @@ -40,7 +40,7 @@ describe('assembled shortcut command owners', () => { const fixedIds = () => shortcuts.fixedCatalog.getSnapshot().map(row => row.id) expect(fixedIds()).toEqual(expect.arrayContaining(conversationIds)) expect(shortcuts.fixedCatalog.getSnapshot().find(row => row.id === 'response.stop')) - .toMatchObject({ group: 'application', keys: ['Esc', 'Esc'] }) + .toMatchObject({ group: 'input', keys: ['Esc', 'Esc'] }) await client.unload('@deepseek-ai/dsh-client-ui-shortcuts') expect(fixedIds()).toEqual(expect.arrayContaining(conversationIds)) expect(fixedIds()).not.toContain('fixed.move') diff --git a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx index c3b37ef23f..d2ffd27699 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -38,8 +38,12 @@ it('keeps a reference open while a clear operation is pending', async () => { const f = referenceFixture() const pending = Promise.withResolvers>>() f.edit.mockReturnValueOnce(pending.promise) - fireEvent.click(screen.getByRole('button', { name: 'Remove shortcut for Open settings' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + fireEvent.click(screen.getByRole('button', { name: en.clear })) fireEvent.click(screen.getByRole('button', { name: en.close })) + fireEvent.pointerDown(screen.getByRole('heading', { name: en.title })) + fireEvent.click(screen.getByRole('dialog').previousElementSibling!) + expect(screen.getByRole('group', { name: 'Open settings' })).toBeTruthy() expect(f.store.getSnapshot().open).toBe(true) await act(async () => { pending.resolve({ status: 'saved', snapshot: f.config.getSnapshot() }); await pending.promise }) expect(screen.getByRole('alert').textContent).toBe(en.saved) @@ -70,7 +74,7 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and describeBinding: describe, platform, t: makeTranslate(en) } as Parameters[0] render(<>) expect(screen.queryByRole('dialog')).toBeNull() - const opener = screen.getByRole('button', { name: 'View shortcuts' }); opener.focus(); fireEvent.click(opener) + const opener = screen.getByRole('button', { name: en.view }); opener.focus(); fireEvent.click(opener) expect(opener.getAttribute('aria-keyshortcuts')).toBe(platform === 'macos' ? 'Meta+/' : 'Control+/') const search = screen.getByRole('searchbox') expect(document.activeElement).toBe(search) @@ -100,7 +104,7 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and expect(store.getSnapshot().query).toBe('') expect(document.activeElement).toBe(opener) fireEvent.mouseEnter(opener) - expect(screen.getByRole('tooltip').textContent).toBe(platform === 'macos' ? 'View shortcuts ⌘ /' : 'View shortcuts Ctrl + /') + expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe(`${en['global-hint']} ${platform === 'macos' ? '⌘ /' : 'Ctrl + /'}`) act(() => { catalog.set(catalog.getSnapshot().map(row => row.id === 'shortcuts.open' ? { ...row, binding: null, keys: [], aria: undefined } : row)) }) expect(screen.queryByRole('tooltip')).toBeNull() @@ -186,7 +190,7 @@ it('keeps core actions in product order across registration, remount and label c ...template, id: id as ShortcutCommandId, label, })) const stop: ShortcutFixedCatalogEntry = { id: 'response.stop' as ShortcutCommandId, label: 'Stop reply', - keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application' } + keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' } const fixed: ShortcutFixedCatalogEntry[] = [ { ...stop, id: 'fixed.send' as ShortcutCommandId, label: 'Send', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' }, { ...stop, id: 'fixed.newline' as ShortcutCommandId, label: 'Newline', keys: ['Shift', 'Enter'], bindings: [{ code: 'Enter', modifiers: ['shift'] }], group: 'input' }, @@ -196,21 +200,21 @@ it('keeps core actions in product order across registration, remount and label c stop, ] const labels = (group: string) => within(screen.getByRole('region', { name: group })).getAllByRole('listitem') - .map(row => row.firstElementChild?.textContent) - const expected = [...core.map(([, label]) => label), 'Stop reply', 'Zulu extension', 'Alpha extension'] + .map(row => row.querySelector(':scope > span')?.textContent) + const expected = [...core.map(([, label]) => label), 'Zulu extension', 'Alpha extension'] for (const reversed of [true, false]) { act(() => { catalog.set(reversed ? [...extensions, ...commands].reverse() : [...extensions, ...commands]) fixedCatalog.set(reversed ? [...fixed].reverse() : fixed) }) expect(labels(en.application)).toEqual(expected) - expect(labels(en.input)).toEqual(['Newline', 'Send']) + expect(labels(en.input)).toEqual(['Newline', 'Send', 'Stop reply']) expect(labels(en.menus)).toEqual(['Dismiss', 'Select']) expect(labels(en.approval)).toEqual(['Approve']) } - const stopButton = within(screen.getByRole('region', { name: en.application })).getByRole('button', { name: 'Stop reply Esc Esc' }) - expect(stopButton.hasAttribute('disabled')).toBe(true) - fireEvent.click(stopButton) + const stopRow = screen.getByText('Stop reply').closest('li')! + expect(within(stopRow).queryByRole('button')).toBeNull() + fireEvent.click(stopRow) expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull() expect(screen.queryByRole('group', { name: 'Stop reply' })).toBeNull() act(() => { catalog.set(extensions); fixedCatalog.set(fixed.filter(row => row.id !== stop.id)) }) @@ -223,7 +227,7 @@ it('keeps core actions in product order across registration, remount and label c store.actions.search('操作') }) expect(labels(en.application)).toEqual(expected.map(label => `操作:${label}`)) - expect(labels(en.input)).toEqual(['操作:Newline', '操作:Send']) + expect(labels(en.input)).toEqual(['操作:Newline', '操作:Send', '操作:Stop reply']) }) it('keeps product order for tied searches while prioritizing stronger matches', () => { @@ -268,9 +272,9 @@ it('shows mounted fixed actions as searchable read-only rows and follows their l const approve = { id: 'approval.accept' as ShortcutCommandId, label: 'Approve', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval' as const } expect(screen.queryByText(send.label)).toBeNull() act(() => { fixedCatalog.set([send, stop, approve]) }) - expect(screen.getByRole('button', { name: 'Send from catalog Enter' }).hasAttribute('disabled')).toBe(true) + expect(within(screen.getByText(send.label).closest('li')!).queryByRole('button')).toBeNull() expect(screen.getByRole('region', { name: 'Approval area' }).textContent).toContain('Approve') - expect(screen.getByRole('button', { name: 'Stop reply Esc Esc' }).hasAttribute('disabled')).toBe(true) + expect(within(screen.getByText(stop.label).closest('li')!).queryByRole('button')).toBeNull() expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull() act(() => { store.actions.search('response.stop') }) expect(screen.getAllByRole('listitem')).toHaveLength(1) @@ -305,12 +309,13 @@ it.each(['conflict', 'reserved'] as const)('omits status labels for a %s binding expect(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }).hasAttribute('disabled')).toBe(false) }) -it('removes from the row and replaces failure feedback with a fresh system success toast', async () => { +it('removes from the inline editor and replaces failure feedback with a fresh system success toast', async () => { vi.useFakeTimers() try { const f = referenceFixture() f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() }) - const remove = screen.getByRole('button', { name: 'Remove shortcut for Open settings' }) + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + const remove = screen.getByRole('button', { name: en.clear }) await act(async () => { remove.click() }) expect(screen.getByRole('alert').textContent).toBe(en['write-failed']) expect(screen.getByRole('dialog').contains(screen.getByRole('alert'))).toBe(false) @@ -325,10 +330,10 @@ it('removes from the row and replaces failure feedback with a fresh system succe } finally { vi.useRealTimers() } }) -it('opens the same recorder from a key badge and retains the editor when restoring defaults fails', async () => { +it('opens the recorder from the application row and retains the editor when restoring defaults fails', async () => { const f = referenceFixture() f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() }) - fireEvent.click(screen.getByRole('button', { name: 'Open settings ⇧ ⌘ ,' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) await act(async () => { screen.getByRole('button', { name: en.reset }).click() }) expect(f.edit).toHaveBeenLastCalledWith({ type: 'reset', id: 'settings.open' }, f.config.getSnapshot().revision) expect(screen.getByRole('alert').textContent).toBe(en['write-failed']) @@ -390,7 +395,8 @@ it('blocks reference dismissal during a removal and ignores its completion after let settle!: (value: Awaited>) => void const reply = new Promise>>((resolve) => { settle = resolve }) f.edit.mockReturnValueOnce(reply) - fireEvent.click(screen.getByRole('button', { name: 'Remove shortcut for Open settings' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + fireEvent.click(screen.getByRole('button', { name: en.clear })) fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(f.store.getSnapshot().open).toBe(true) f.view.unmount() @@ -482,7 +488,7 @@ it('restores defaults after confirmation and retains accepted configuration when await act(async () => { confirm.click() }) expect(screen.queryByRole('dialog', { name: en['reset-title'] })).toBeNull() expect(screen.getByRole('alert').textContent).toBe(en['reset-saved']) - expect(screen.getByText('0 customized')).toBeTruthy() + expect(screen.queryByText('0 customized')).toBeNull() expect(restore.hasAttribute('disabled')).toBe(true) expect(document.activeElement).toBe(screen.getByRole('searchbox')) }) @@ -523,3 +529,57 @@ it('keeps an active recorder focused when another window resets the profile', () expect(document.activeElement).toBe(recorder) expect(screen.getByRole('group', { name: 'Open settings' })).toBeTruthy() }) + +it('clears a search and returns focus to the search field', () => { + referenceFixture() + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'absent' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: en['clear-search'] })) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(document.activeElement).toBe(search) + expect(screen.queryByRole('button', { name: en['clear-search'] })).toBeNull() +}) + +it('closes the editor and drops a pending recording when blank space is clicked', async () => { + const f = referenceFixture() + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + const recorder = screen.getByRole('button', { name: en.record }) + expect(document.activeElement).toBe(recorder) + fireEvent.keyDown(recorder, { key: '.', code: 'Period', metaKey: true, shiftKey: true }) + fireEvent.pointerDown(screen.getByRole('heading', { name: en.title })) + expect(document.activeElement).toBe(screen.getByRole('dialog')) + await act(async () => { fireEvent.keyUp(document.activeElement!, { key: '.', code: 'Period' }) }) + expect(f.edit).not.toHaveBeenCalled() + expect(screen.queryByRole('group', { name: 'Open settings' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + const reopened = screen.getByRole('button', { name: en.record }) + await act(async () => { + fireEvent.keyDown(reopened, { key: '.', code: 'Period', metaKey: true, shiftKey: true }) + fireEvent.keyUp(reopened, { key: '.', code: 'Period' }) + }) + expect(f.edit).toHaveBeenCalledOnce() +}) + +it('preserves a failed draft when its status text is clicked, then retries after reviewing an external change', async () => { + const f = referenceFixture() + fireEvent.pointerDown(screen.getByRole('heading', { name: en.title })) + expect(f.store.getSnapshot().open).toBe(true) + fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' })) + f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() }) + const recorder = screen.getByRole('button', { name: en.record }) + fireEvent.pointerDown(recorder) + await act(async () => { + fireEvent.keyDown(recorder, { key: '.', code: 'Period', metaKey: true, shiftKey: true }) + fireEvent.keyUp(recorder, { key: '.', code: 'Period' }) + }) + const editor = screen.getByRole('group', { name: 'Open settings' }) + fireEvent.pointerDown(within(editor).getByText(en['write-failed'])) + expect(within(editor).getByRole('button', { name: en['retry-save'] })).toBeTruthy() + act(() => { f.config.set({ ...f.config.getSnapshot(), revision: initialShortcutConfig().revision }) }) + fireEvent.pointerDown(within(editor).getByText(en.stale)) + fireEvent.click(within(editor).getByRole('button', { name: en.review })) + await act(async () => { within(editor).getByRole('button', { name: en['retry-save'] }).click() }) + expect(f.edit).toHaveBeenLastCalledWith({ type: 'set', id: 'settings.open', binding: { code: 'Period', modifiers: ['shift', 'meta'] } }, f.config.getSnapshot().revision) + expect(screen.queryByRole('group', { name: 'Open settings' })).toBeNull() +}) diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 408861fa2f..4c5c2a8a75 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -21,8 +21,8 @@ en: fb79cb7d59bc01af zh: 5f6202d8b0b62d7d /deepseek-ai-dsh-client-ui-sidebar-browser/use-this-package/minimal-configuration: - en: fbbc95839824742c - zh: fb49613017959ff4 + en: 32511e10645b4154 + zh: ce0e0efd60dda336 /deepseek-ai-dsh-client-ui-sidebar-browser/understand-the-implementation: en: 42b15ac6321706aa zh: e15901d8462ec7c7 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index c50036ed03..a9cc06b03d 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -42,7 +42,7 @@ The package has no plugin configuration. A Web profile enables the shipped entry Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. -The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button displays the effective shortcut inline without a duplicate tooltip. +The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button uses a blue globe and displays the effective shortcut inline without a duplicate tooltip. The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays a warning. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index ab79ca401a..32e9a43bf6 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -42,7 +42,7 @@ Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用 Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 -命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮内显示有效快捷键,不额外弹出重复提示。 +命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮使用蓝色地球图标,并在按钮内显示有效快捷键,不额外弹出重复提示。 工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。 diff --git a/packages/client/ui-sidebar-browser/src/client/definition.tsx b/packages/client/ui-sidebar-browser/src/client/definition.tsx index a829e10533..3f6dc8d54e 100644 --- a/packages/client/ui-sidebar-browser/src/client/definition.tsx +++ b/packages/client/ui-sidebar-browser/src/client/definition.tsx @@ -1,7 +1,7 @@ /** Static Browser tab type and guide declaration. */ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { GuideArtworkBrowser } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from './locales.ts' @@ -21,7 +21,7 @@ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRigh title: () => t('type.label'), guide: [{ id: 'new', commandId: 'browser.new' as ShortcutCommandId, order: 30, title: () => t('guide.title'), - description: () => t('guide.description'), icon: IconGlobeOutlineRegular, + description: () => t('guide.description'), icon: GuideArtworkBrowser, }], } } diff --git a/packages/client/ui-sidebar-browser/src/client/locales.ts b/packages/client/ui-sidebar-browser/src/client/locales.ts index 901ec7309c..acf00a50e2 100644 --- a/packages/client/ui-sidebar-browser/src/client/locales.ts +++ b/packages/client/ui-sidebar-browser/src/client/locales.ts @@ -1,6 +1,5 @@ /** Locale-owned Browser tab copy. */ export const zh = { - 'shortcut.hint': '{label} {keys}', 'type.label': '浏览器', 'guide.title': '浏览器', 'guide.description': '浏览网页', @@ -34,7 +33,6 @@ export type SidebarBrowserKey = keyof typeof zh /** English dictionary with the same keys. */ export const en = { - 'shortcut.hint': '{label} {keys}', 'type.label': 'Browser', 'guide.title': 'Browser', 'guide.description': 'Browse web pages', diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx index 3003c2f623..f819dba436 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx @@ -81,7 +81,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
- +
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 65b517dbef..101f2debd8 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -310,7 +310,7 @@ export function TextPreview({ {/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */} {/* jscpd:ignore-start */} - + - + - + + } + /> + + {description !== undefined && } - { setOpen(false) }} - onSelect={(path) => { - if (state.phase === 'failed') { setState({ phase: 'loading' }); setAttempt(value => value + 1); return } - selectShell(path) - setOpen(false) - tab.actions.openTab('terminal', { replaceTab: true, params: { shellPath: path } }) - }} - anchor={} - /> + {shortcut !== undefined && shortcut.keys.length > 0 && }
} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx index 4c0fa3d11c..a875c26837 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -1,6 +1,5 @@ -/** Terminal glyphs for the sidebar guide and tab title. */ +/** Terminal glyph for sidebar tab titles. */ import type { ReactNode } from 'react' -import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' /** * Render the tab title's terminal prompt in the surrounding text color. @@ -12,15 +11,3 @@ export function TerminalIcon(): ReactNode { } - -/** - * Render the guide's terminal card within the folder glyph's painted bounds. - * @param props - canvas size and layout class supplied by the guide. - * @returns a dark rounded terminal card with a white prompt. - */ -export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { - return -} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index 7fe95a27c1..98ac69d4c9 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' -import { TerminalGuideIcon } from './TerminalIcon.tsx' +import { PluginArtworkTerminal } from '@deepseek-ai/dsh-client-ui-primitives' import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx' import { LazyTerminalBody } from './LazyTerminalBody.tsx' import { TerminalTitle } from './TerminalTitle.tsx' @@ -70,7 +70,7 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') ctx.effect(() => ctx.sidebarRightTabs.register({ id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), - guide: [{ id: 'new', order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }], + guide: [{ id: 'new', order: 20, title: () => t('new'), description: () => t('description'), icon: PluginArtworkTerminal }], }), 'ui-sidebar-terminal.type') ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { ctx.webTerminals.close(sessionId, tab.id, tab.contentId, terminalId(sessionId, tab.id)) diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 90f4a7965d..408be7c1e4 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -95,7 +95,7 @@ it('registers terminal views without recovery or cleanup slots, then releases co const Icon = definition.guide?.[0]?.icon if (Icon === undefined) throw new Error('Terminal guide icon was not registered') expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"') - expect(renderToStaticMarkup(createElement(Icon))).toContain('width="26"') + expect(renderToStaticMarkup(createElement(Icon))).toContain('width="36"') expect(definition.multiple).toBe(true) expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index 13b8057cf0..4d0ef3d022 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -72,12 +72,12 @@ it.each(['hover', 'focus'] as const)('keeps the terminal shortcut inline without if (trigger === 'hover') fireEvent.mouseEnter(button) else fireEvent.focus(button) expect(h.view.queryByRole('tooltip')).toBeNull() - expect(button.textContent).toContain('Ctrl`') + expect(Array.from(h.view.container.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', '`']) expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+`') h.setShortcut({ keys: ['Alt', 'T'], aria: 'Alt+t' }) expect(h.view.queryByRole('tooltip')).toBeNull() - expect(button.textContent).toContain('AltT') + expect(Array.from(h.view.container.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Alt', 'T']) expect(button.getAttribute('aria-keyshortcuts')).toBe('Alt+t') h.setShortcut({ keys: [], aria: undefined }) @@ -92,6 +92,7 @@ it('loads on demand, remembers a selected shell before opening, and cancels on m const h = mount() h.open() await h.view.findByRole('menuitem', { name: 'zsh' }) + expect(h.openTab).not.toHaveBeenCalled() const signal = h.loadShells.mock.calls[0]![0] h.selectShell.mockImplementation(() => { expect(h.openTab).not.toHaveBeenCalled() }) fireEvent.click(h.view.getByRole('menuitem', { name: 'bash' })) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 83ac74dbf0..46a8755740 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -18,8 +18,8 @@ en: a970f0547496634f zh: 36ef9a85b26e19f1 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/brand-and-new-session: - en: 6daf5bc805b6ad3d - zh: f759fde0f7ad821b + en: d084cd6732966c41 + zh: 466e028ca49b4230 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries: en: 5d3300d3506254c5 zh: 541ae1ee1e10b921 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 31839e55ad..a6179cfad8 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -31,7 +31,7 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co ### Brand and New Session -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. New Session controls show the command registry's effective binding in tooltips and `aria-keyshortcuts`, including the resident macOS header control when the sidebar is hidden. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. The expanded New Session button displays the effective binding as trailing grey text on hover or keyboard focus. While the shortcut is visible, the centered icon and label fade before it when space is narrow; CSS reserves the shortcut’s width without measuring the button. Icon-only controls retain tooltips with platform-formatted keycaps, including the resident macOS header control when the sidebar is hidden. All controls expose `aria-keyshortcuts`. ### Global panel entries diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 11fcb415c3..d941f983a4 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -31,7 +31,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### 品牌与 New Session -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 新建会话控件的 tooltip 和 `aria-keyshortcuts` 显示命令注册表中的当前有效绑定,侧栏隐藏时 macOS 常驻头部中的控件也保持一致。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 展开态的新建会话按钮在悬停或键盘聚焦时于右侧以灰色文字显示当前有效绑定。快捷键可见且空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。 ### 全局面板入口 diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index d1b7824e0c..8e65ad4ab0 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -26,10 +26,9 @@ export type HeaderLeadingControlsProps = export function HeaderLeadingControls({ toggleSidebar, startSession, useShortcuts, t }: HeaderLeadingControlsProps) { const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle')) const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new')) - const newHint = newShortcut?.keys.length ? t('shortcut.hint', { label: t('session.new.label'), keys: newShortcut.keys.join(' ') }) : t('session.new.label') return (
- + - + diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index b2cc58d832..bdd1486333 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -2,7 +2,6 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'shortcut.hint': '{label} {keys}', 'session.new': '新会话', 'session.new.label': '新建会话', 'toggle.open': '打开侧边栏', @@ -15,7 +14,6 @@ export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'shortcut.hint': '{label} {keys}', 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index a467c3dbba..652e9f6b39 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -40,16 +40,24 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded class="newSession" type="button" > - + + + + +
renders Windows caption controls in expanded class="newSession" type="button" > - - New Session + + + + New Session + +
renders the collapsed rail after the crossfad class="newSession" type="button" > - + + + + +
renders the expanded column (wordmark, capsul class="newSession" type="button" > - - New Session + + + + New Session + +
renders the expanded column in the default lo class="newSession" type="button" > - - 新会话 + + + + 新会话 + +
{ for (const button of screen.getAllByRole('button', { name: 'New session' })) { expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N') } + const expanded = screen.getAllByRole('button', { name: 'New session' }).find(button => button.querySelector('kbd') !== null)! + fireEvent.mouseEnter(expanded) + fireEvent.focus(expanded) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(Array.from(expanded.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'N']) cleanup() render( { const button = screen.getByRole('button', { name: 'New session' }) expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N') fireEvent.focus(button) - expect(screen.getByRole('tooltip').textContent).toContain('Ctrl N') + expect(Array.from(screen.getByRole('tooltip').querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'N']) }) it('routes New Session (capsule + wordmark) and the column toggle', () => { const b = mountShell() @@ -306,5 +311,5 @@ it('uses the same effective sidebar binding for hover/focus hints and ARIA', () const toggle = screen.getByRole('button', { name: en['toggle.collapse'] }) expect(toggle.getAttribute('aria-keyshortcuts')).toBe('Meta+B') fireEvent.focus(toggle) - expect(screen.getByRole('tooltip').textContent).toContain('⌘ B') + expect(Array.from(screen.getByRole('tooltip').querySelectorAll('kbd'), key => key.textContent)).toEqual(['⌘', 'B']) }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 58e19261b2..7ade0ae00d 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -30,8 +30,8 @@ en: 8fbfdb050e880f9b zh: dd201f049036c5c4 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets: - en: b6d02b9fd488fed6 - zh: 466c428f1d111edb + en: cb63405e3ab1f395 + zh: 2a40c303286c8ea0 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding: en: bf76e7c34a331595 zh: 5accd0260ef7c61e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 1d607d491f..b0f2c7510d 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -65,7 +65,7 @@ Menu icons use `--dsw-alias-menu-icon`: neutral-bluish 800 in light mode and `la `base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact. -System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers. Document previews pair `--dsw-alias-bg-document-preview` with `--dsw-alias-label-document-preview` so the backdrop and status text follow the same theme. +System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers. Document previews pair `--dsw-alias-bg-document-preview` with `--dsw-alias-label-document-preview` so the backdrop and status text follow the same theme. Tooltip keycaps use `--dsw-alias-tooltip-key-bg`, a lighter fill derived from the tooltip background in each palette. `brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index e91015475b..b1c54a6608 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -65,7 +65,7 @@ kind: "package-reference" `base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。 -系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。文档预览配对使用 `--dsw-alias-bg-document-preview` 与 `--dsw-alias-label-document-preview`,使底色与状态文字遵循相同主题。 +系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。文档预览配对使用 `--dsw-alias-bg-document-preview` 与 `--dsw-alias-label-document-preview`,使底色与状态文字遵循相同主题。Tooltip 键帽使用 `--dsw-alias-tooltip-key-bg`,由各主题的 tooltip 背景派生稍浅的填充。 `brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 7b9ee141d6..672e90c20a 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -256,6 +256,7 @@ body { --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800); --dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850); + --dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%); --dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200); --dsw-specific-bubble: var(--dsw-static-deepseek-50); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); @@ -365,6 +366,7 @@ body[data-ds-dark-theme] { --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750); --dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750); + --dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%); --dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750); --dsw-specific-bubble: var(--dsw-static-neutral-bluish-850); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-850); diff --git a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json index f4a7729d1a..fa33cce684 100644 --- a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json +++ b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json @@ -1,7 +1,4 @@ { - "ui-primitives/src/ShortcutKeys.module.css": [ - ".keys: border-radius: 10px" - ], "ui-schedule/src/client/ClockPicker.module.css": [ ".option: border-radius: 10px" ], @@ -54,11 +51,10 @@ ".indicator: border-radius: 6px" ], "ui-shortcuts/src/client/Reference.module.css": [ - ".button: border-radius: 18px", ".dialog: border-radius: 24px", ".close: border-radius: 28px", ".searchField: border-radius: 12px", - ".keyButton: border-radius: 10px", + ".keyBadge: border-radius: 10px", ".unbound: border-radius: 10px", ".recorder: border-radius: 10px" ] diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 08d49b9660..5a615c5509 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -9,7 +9,6 @@ export const zh = { 'defaultWorkspace.failed': '无法创建默认工作区,请通过“选择工作区”选择文件夹', 'group.ungrouped': '未分组', 'session.new': '新会话', - 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': '请先选择一个会话', 'shortcut.noPicker': '目录选择器不可用', 'shortcut.directoryBusy': '正在选择或添加工作区', @@ -128,7 +127,6 @@ export const en = { 'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.', 'group.ungrouped': 'Ungrouped', 'session.new': 'New Session', - 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': 'Select a session first', 'shortcut.noPicker': 'Directory picker unavailable', 'shortcut.directoryBusy': 'Selecting or adding a workspace', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index bdf201e7e9..635770fd47 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -294,7 +294,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog )} /> )} - +