From ed3dcf54c52378d1e8bd44b8c2daec0f8eaa5d68 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Thu, 24 Sep 2026 15:55:33 +0800 Subject: [PATCH 01/10] feat(web): refine shortcut editing and sidebar hints --- .../desktop-locale/automatic.expected.md | 4 +- .../desktop-locale/selected.expected.md | 4 +- .../settings-chrome/dialog-en.expected.md | 4 +- .../dialog-no-browser.expected.md | 4 +- .../settings-chrome/dialog.expected.md | 4 +- .../shortcuts-desktop/chord.expected.md | 4 +- .../macos-copy-override.expected.md | 4 +- .../windows-copy-override.expected.md | 4 +- .../macos-defaults.expected.md | 108 ++++---------- .../shortcuts/en-US-empty.expected.md | 1 + .../expected/shortcuts/en-US.expected.md | 109 ++++----------- .../shortcuts/zh-CN-empty.expected.md | 1 + .../expected/shortcuts/zh-CN.expected.md | 108 ++++---------- apps/web/tests/shortcuts-panels.e2e.ts | 19 ++- apps/web/tests/shortcuts-workspace.e2e.ts | 5 +- apps/web/tests/shortcuts.e2e.ts | 119 +++++++++++----- apps/web/tests/sidebar-right.e2e.ts | 5 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/skeleton/InputBar.tsx | 5 +- .../tests/input-bar.client.spec.tsx | 4 +- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 +- packages/client/ui-dockkit/README.zh.md | 2 +- .../ui-dockkit/src/components/FloatLayer.tsx | 2 +- .../ui-dockkit/src/components/TabPanel.tsx | 4 +- .../client/ui-dockkit/src/contract/adapter.ts | 8 +- .../src/client/OpenTargetButton.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/Menu.module.css | 8 +- packages/client/ui-primitives/src/Menu.tsx | 6 +- .../ui-primitives/src/ShortcutKeys.module.css | 58 ++++++-- .../client/ui-primitives/src/ShortcutKeys.tsx | 10 +- .../ui-primitives/src/Tooltip.module.css | 9 ++ packages/client/ui-primitives/src/Tooltip.tsx | 9 +- .../ui-primitives/src/plugin-artwork.tsx | 6 +- .../tests/tooltip.client.spec.tsx | 12 ++ .../src/client/SettingsRoot.tsx | 2 +- .../tests/settings-root.client.spec.tsx | 2 +- packages/client/ui-shortcuts/README.i18n.yaml | 4 +- packages/client/ui-shortcuts/README.md | 8 +- packages/client/ui-shortcuts/README.zh.md | 8 +- .../client/ui-shortcuts/src/client/Editor.tsx | 3 +- .../client/ui-shortcuts/src/client/Icons.tsx | 3 +- .../src/client/Reference.module.css | 48 ++++--- .../ui-shortcuts/src/client/Reference.tsx | 123 ++++++++-------- .../client/ui-shortcuts/src/client/locales.ts | 14 +- .../tests/commands.client.spec.ts | 2 +- .../tests/reference.client.spec.tsx | 72 +++++++--- .../ui-sidebar-browser/README.i18n.yaml | 4 +- packages/client/ui-sidebar-browser/README.md | 2 +- .../client/ui-sidebar-browser/README.zh.md | 2 +- .../src/client/definition.tsx | 16 ++- .../src/client/view/BrowserBody.tsx | 2 +- .../src/client/TextPreview.tsx | 2 +- .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 2 +- packages/client/ui-sidebar-files/README.zh.md | 2 +- .../ui-sidebar-files/src/client/FilesBody.tsx | 2 +- .../src/client/definition.tsx | 13 +- .../client/ui-sidebar-right/README.i18n.yaml | 8 +- packages/client/ui-sidebar-right/README.md | 4 +- packages/client/ui-sidebar-right/README.zh.md | 4 +- .../ui-sidebar-right/src/client/labels.ts | 11 +- .../src/client/shell/ExpandButton.tsx | 2 +- .../src/client/shell/SidebarRight.tsx | 4 +- .../client/tabs/guide/GuideBody.module.css | 6 +- .../src/client/tabs/guide/GuideBody.tsx | 2 +- .../tests/expand-button.client.spec.tsx | 4 +- .../tests/seat.client.spec.tsx | 4 +- .../ui-sidebar-terminal/README.i18n.yaml | 8 +- packages/client/ui-sidebar-terminal/README.md | 6 +- .../client/ui-sidebar-terminal/README.zh.md | 6 +- .../src/client/TerminalGuide.module.css | 21 +-- .../src/client/TerminalGuide.tsx | 55 ++++---- .../src/client/TerminalIcon.tsx | 15 +- .../ui-sidebar-terminal/src/client/index.ts | 4 +- .../tests/apply.client.spec.ts | 2 +- .../tests/terminal-guide.client.spec.tsx | 5 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/HeaderLeadingControls.tsx | 5 +- .../src/client/SidebarRoot.module.css | 15 ++ .../ui-sidebar/src/client/SidebarRoot.tsx | 52 +++++-- .../sidebar-snapshot.client.spec.tsx.snap | 132 ++++++++++-------- .../tests/sidebar-root.client.spec.tsx | 9 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 2 + .../expected/radius-exceptions.expected.json | 5 +- .../ui-workspace/src/client/rows/Rows.tsx | 2 +- .../src/client/rows/WorkspaceBrowser.tsx | 8 +- 98 files changed, 755 insertions(+), 686 deletions(-) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 4e10073523..db9488671b 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: 开发者工具 显示用于调试和排查问题的工具与信息 - 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 eadbe2289d..d353691f8d 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: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" [checked] - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - 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 2ad9903a2e..58e7b684a4 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: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - 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 2ad9903a2e..58e7b684a4 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: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" - text: Keyboard shortcuts - - paragraph: Browse available shortcuts and input actions - - button "View shortcuts" + - 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 a4a9349ffb..a9cc91d984 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: 开发者工具 显示用于调试和排查问题的工具与信息 - 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/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 b25d56f07a..3bbc15e0eb 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,49 @@ 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' }, + ])('keeps New Session centered and replaces a long inline binding with a key-only tooltip 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() + 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 [width, fits] of [[420, true], [264, false]] as const) { + 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.getAttribute('data-shortcut-inline')).toBe(fits ? 'true' : null) + const centerOffset = () => newSession.evaluate((element) => { + const button = element.getBoundingClientRect() + const content = element.firstElementChild!.getBoundingClientRect() + return Math.abs(button.x + button.width / 2 - content.x - content.width / 2) + }) + expect(await centerOffset()).toBeLessThan(1) + await newSession.hover() + expect(await centerOffset()).toBeLessThan(1) + if (fits) { + expect(await newSession.locator('kbd').first().evaluate(element => getComputedStyle(element.parentElement!.parentElement!).opacity)).toBe('1') + expect(await page.getByRole('tooltip').count()).toBe(0) + } else { + await page.getByRole('tooltip', { name: keys, exact: true }).waitFor() + expect(await page.getByRole('tooltip').locator('kbd').allTextContents()).toEqual(keys.split(' ')) + } + await page.mouse.move(700, 100) + } + } finally { await context.close() } + }) + it.each([ { platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' }, { platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' }, @@ -369,7 +411,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/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8b219abf38..2f8802d3f4 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: 8de4fbd9df016e06 - zh: 06b15ab7855120cb + en: 0e54fdecaa1b2139 + zh: 1e9aca1712811d3b /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 7c7739fee9..dbf1c8a965 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -84,7 +84,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 88ac5cb99f..1aee4d23d4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -84,7 +84,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 550515deda..942444d3b7 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -256,7 +256,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/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9139aa9ac8..f004aba1e9 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) && ( - + ) @@ -472,7 +472,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} - {entry.shortcut !== undefined && } + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -490,7 +490,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {sub.icon !== undefined && {sub.icon}} {sub.label} - {sub.shortcut !== undefined && } + {sub.shortcut !== undefined && } ))} diff --git a/packages/client/ui-primitives/src/ShortcutKeys.module.css b/packages/client/ui-primitives/src/ShortcutKeys.module.css index 06d42e26dd..46b8e90cfe 100644 --- a/packages/client/ui-primitives/src/ShortcutKeys.module.css +++ b/packages/client/ui-primitives/src/ShortcutKeys.module.css @@ -1,16 +1,56 @@ .keys { + display: inline-flex; + flex: none; + align-items: center; + gap: 3px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 16px; + white-space: nowrap; +} + +.key { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - gap: 3px; - height: 26px; - padding: 6px 16px; - border-radius: 10px; + min-width: 20px; + height: 20px; + padding: 0 3px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-module-platform); - color: var(--dsw-alias-label-primary); - font-size: 12px; - line-height: 14px; - white-space: nowrap; + font: inherit; } -.key { font: inherit; } + +.plain .key { + min-width: 0; + height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.tooltip { color: inherit; font-size: 11px; line-height: 14px; gap: 2px; } +.tooltip .key { + min-width: 16px; + height: 16px; + padding: 0 2px; + border-radius: 4px; + border: 0; + background: var(--dsw-alias-tooltip-key-bg); +} + +.separator { font: inherit; } + +.joined { + box-sizing: border-box; + height: 20px; + padding: 0 6px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-sm); + background: var(--dsw-alias-bg-module-platform); +} +.joined .key { min-width: 0; height: auto; padding: 0; border: 0; background: transparent; } +.joined.tooltip { height: 16px; padding: 0 4px; border: 0; border-radius: 4px; background: var(--dsw-alias-tooltip-key-bg); } diff --git a/packages/client/ui-primitives/src/ShortcutKeys.tsx b/packages/client/ui-primitives/src/ShortcutKeys.tsx index 706c78e37e..e583015412 100644 --- a/packages/client/ui-primitives/src/ShortcutKeys.tsx +++ b/packages/client/ui-primitives/src/ShortcutKeys.tsx @@ -1,12 +1,12 @@ -/** Shared shortcut badges; callers supply the effective platform presentation. */ +/** Shared shortcut keycaps; callers supply the effective platform presentation. */ import css from './ShortcutKeys.module.css' import clsx from 'clsx' /** * Render one command's keycaps without owning binding defaults or localized copy. - * @param props - effective key labels and optional interaction styling. - * @returns one rounded badge containing the key sequence. + * @param props - effective key labels, presentation variant and optional interaction styling. + * @returns keycaps (joined for plus-separated combinations), or unboxed keys for compact text hints. */ -export function ShortcutKeys({ keys, className }: { keys: readonly string[]; className?: string | undefined }) { - return {keys.map((key, index) => {key})} +export function ShortcutKeys({ keys, variant = 'keycaps', className }: { keys: readonly string[]; variant?: 'keycaps' | 'plain' | 'tooltip'; className?: string | undefined }) { + return {keys.map((key, index) => {key})} } diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 88de837e6a..db3d724528 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -1,4 +1,7 @@ .bubble { + display: inline-flex; + align-items: center; + gap: 8px; position: fixed; z-index: 100; /* Fixed-position shrink-to-fit measures only the space from `left` to the @@ -20,6 +23,12 @@ } .bubble[data-portal] { z-index: 1100; } +.label { min-width: 0; } + +/* A 16px keycap centered beside a 20px label has 5px of vertical clearance. */ +.bubble[data-has-shortcut] { padding-inline-end: 5px; } +.bubble[data-has-shortcut]:not(:has(.label)) { padding: 3px; } + .bubble[data-side='right'] { transform: translateY(-50%); diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 8c2c8a819d..48cbf65e8f 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -3,6 +3,7 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import { createPortal } from 'react-dom' +import { ShortcutKeys } from './ShortcutKeys.tsx' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ @@ -41,6 +42,7 @@ if (typeof window !== 'undefined') { /** * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. + * @param props.shortcutKeys - effective key labels rendered as platform-formatted keycaps after optional text. * @param props.side - placement relative to the anchor (default 'right'). * @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins * the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered @@ -60,7 +62,7 @@ if (typeof window !== 'undefined') { * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -191,10 +193,13 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, data-side={side} data-portal={portal || undefined} data-align={align} + data-has-shortcut={shortcutKeys?.length ? true : undefined} style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }} role="tooltip" + aria-label={shortcutKeys?.length ? [resolvedLabel, shortcutKeys.join(' ')].filter(Boolean).join(' ') : undefined} > - {resolvedLabel} + {resolvedLabel && {resolvedLabel}} + {shortcutKeys !== undefined && shortcutKeys.length > 0 && } ) diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index c5a774ca81..374d99e138 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,11 +12,11 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Terminal plugin artwork (prompt chevron and cursor bar). */ +/** Light-blue terminal artwork shared by plugin cards and sidebar guide entries. */ export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( - - + + ) diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index fb91da32a1..3d109a91bf 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -39,6 +39,18 @@ afterEach(() => { }) describe('Tooltip', () => { + it('updates independent keycaps and the accessible combination while visible', () => { + const view = render() + 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: } {message || (desktopChords ? '' : t(runtime === 'web' diff --git a/packages/client/ui-shortcuts/src/client/Icons.tsx b/packages/client/ui-shortcuts/src/client/Icons.tsx index 5ba9c6778f..03dfcf7220 100644 --- a/packages/client/ui-shortcuts/src/client/Icons.tsx +++ b/packages/client/ui-shortcuts/src/client/Icons.tsx @@ -1,7 +1,6 @@ /** Supplied shortcut-control and feedback artwork, colored by the owning UI. */ const artwork = { edit: <>, - trash: <>, error: <>, success: <>, } @@ -12,6 +11,6 @@ const artwork = { * @returns an ornamental SVG hidden from assistive technology. */ export function ShortcutIcon({ kind, className }: { kind: keyof typeof artwork; className?: string | undefined }) { - const size = kind === 'edit' || kind === 'trash' ? 12 : 16 + const size = kind === 'edit' ? 12 : 16 return } diff --git a/packages/client/ui-shortcuts/src/client/Reference.module.css b/packages/client/ui-shortcuts/src/client/Reference.module.css index 919baae556..f1fa274d1b 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.module.css +++ b/packages/client/ui-shortcuts/src/client/Reference.module.css @@ -61,6 +61,7 @@ box-shadow: 0 0 1px rgba(0, 0, 0, 0.2), 0 0 4px rgba(0, 0, 0, 0.02), 0 12px 32px rgba(0, 0, 0, 0.08); } .dialog:focus { outline: none; } +.contents { display: flex; flex-direction: column; flex: 1; min-height: 0; } .header { position: relative; flex: 0 0 46px; @@ -92,10 +93,11 @@ } .close:hover { background: var(--dsw-alias-interactive-bg-hover); } .searchRow { - flex: 0 0 72px; + box-sizing: border-box; + flex: 0 0 60px; display: flex; align-items: center; - padding: 0 24px; + padding: 14px 20px 10px; } .searchField { box-sizing: border-box; @@ -130,7 +132,7 @@ min-height: 0; overflow-y: auto; overscroll-behavior: contain; - padding: 0 24px 18px; + padding: 0 16px 18px; } .footer { flex: none; @@ -138,7 +140,7 @@ align-items: center; justify-content: space-between; gap: 12px; - min-height: 60px; + min-height: 52px; padding: 0 24px; border-top: 0.5px solid var(--dsw-alias-border-l2); font-size: 12px; @@ -152,16 +154,16 @@ border: 0; border-radius: 4px; background: transparent; - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } -.resetAll:enabled:hover { color: var(--dsw-alias-label-secondary); } +.resetAll:enabled:hover { color: var(--dsw-alias-label-tertiary); } .resetAll:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; } .resetAll:disabled { cursor: default; opacity: 0.45; } .modifiedCount { color: var(--dsw-alias-label-tertiary); } .group { - margin: 18px 0 4px; + margin: 18px 8px 4px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; @@ -169,32 +171,37 @@ } .rows { padding: 0; margin: 0; list-style: none; } .row { + position: relative; + border-radius: var(--dsw-radius-md); box-sizing: border-box; min-height: 42px; - padding: 8px 0; + padding: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px; } -.binding { flex-shrink: 0; } -.commandLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.binding { position: relative; flex-shrink: 0; pointer-events: none; } +.binding:has(.inlineEditor) { pointer-events: auto; } +.commandLabel { position: relative; pointer-events: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; } p.hint { margin: 6px 0; } /* Keyboard focus reveals actions without retaining a mouse interaction's hover state. */ -.rowActions { display: inline-flex; align-items: center; gap: 8px; opacity: 0; } +.rowActions { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 26px; color: var(--dsw-alias-label-tertiary); opacity: 0; } .row:hover .rowActions, .row:has(:focus-visible) .rowActions { opacity: 1; } -.iconButton { display: inline-grid; place-items: center; width: 20px; height: 26px; border: 0; padding: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; } -.iconButton:focus-visible, .inlineAction:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; border-radius: 4px; } -.keyButton { display: inline-flex; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; } -.keyButton:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; } -.keyButton:enabled:hover .keyBadge, .keyButton:enabled:hover .unbound { background: var(--dsw-alias-interactive-bg-hover-accent); } +.inlineAction:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; border-radius: 4px; } +.rowButton { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } +.row:has(.rowButton:enabled):hover { background: var(--dsw-alias-interactive-bg-hover); } +.rowButton:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: -1px; } +.binding .keyBadge { color: var(--dsw-alias-label-secondary); } +.binding .fixedKeyBadge { height: 26px; color: var(--dsw-alias-label-tertiary); } +.keyBadge { box-sizing: border-box; justify-content: center; height: 26px; padding: 0 12px; border-radius: 10px; background: var(--dsw-alias-bg-module-platform); } .unbound { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; height: 26px; padding: 6px 16px; border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 14px; white-space: nowrap; } .inlineEditor { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } .binding:has(.inlineEditor) { max-width: 100%; } .inlineControls { display: flex; justify-content: flex-end; align-items: center; gap: 8px; } .inlineAction { padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; } -.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; } +.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; } .recorder:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; } .recorder.invalid { border: 1px solid var(--dsw-alias-state-error-primary); box-shadow: none; } .recorded { height: auto; padding: 0; background: transparent; color: inherit; } @@ -203,4 +210,9 @@ p.hint { margin: 6px 0; } .toastError { color: var(--dsw-alias-state-warn-secondary); } .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } @media (hover: none) { .rowActions { opacity: 1; } } -.button:disabled, .iconButton:disabled, .keyButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; } +.button:disabled, .rowButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; } + +.search::-webkit-search-cancel-button { -webkit-appearance: none; } +.clearSearch { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; flex: none; width: 16px; height: 16px; padding: 0; border: 0.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; corner-shape: round; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; } +.clearSearch:hover { background: var(--dsw-alias-interactive-bg-hover); } +.clearSearch:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; } diff --git a/packages/client/ui-shortcuts/src/client/Reference.tsx b/packages/client/ui-shortcuts/src/client/Reference.tsx index 10ff58ccdb..6b841d34c2 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.tsx +++ b/packages/client/ui-shortcuts/src/client/Reference.tsx @@ -42,7 +42,6 @@ const coreActionOrder = new Map([ 'pane.fullscreen.toggle', 'page.refresh', 'page.close', - 'response.stop', ].map((id, index) => [id, index])) /** @@ -57,7 +56,7 @@ export function ShortcutsRow({ actions, t, useCatalog }: PropsRuntime<'settings.
{t('settings')}

{t('description')}

- + @@ -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 @@ -143,7 +137,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 +151,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"]') !== 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 =>
  • + {'modified' in row && target?.id !== row.id &&
  • )}
+
+ })} + {matches.length === 0 &&

{t('empty')}

} +
+
+ + {modifiedCount > 0 && {t('modified-count', { count: modifiedCount })}} +
-
- {(['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')}

} -
-
- - {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..84ea32f946 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,17 @@ export const zh = { 'web-reload': '刷新页面', 'desktop-reload': '重新启动 Harness', 'using-defaults': '当前使用默认键位。', 'using-accepted': '当前继续使用上次读取成功的键位。', 'native-failed': '无法启用桌面录键保护,请关闭后重试。', + 'global-hint': '全局唤起查看', 'clear-search': '清空搜索', hint: '{label} {keys}', - title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '查看快捷键', - description: '查看当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键', + 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 +62,10 @@ 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.', + 'global-hint': 'Open from anywhere', 'clear-search': 'Clear search', 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', + 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..78584a6ca8 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -38,7 +38,8 @@ 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 })) expect(f.store.getSnapshot().open).toBe(true) await act(async () => { pending.resolve({ status: 'saved', snapshot: f.config.getSnapshot() }); await pending.promise }) @@ -70,7 +71,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 +101,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 +187,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 +197,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 +224,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 +269,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 +306,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 +327,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 +392,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 +485,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 +526,34 @@ 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() +}) 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..5298f824b8 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 type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from './locales.ts' @@ -11,6 +11,18 @@ export const BROWSER_KIND = 'browser' /** Browser implementation identity and keyed Slot dispatch key. */ export const BROWSER_ID = '@deepseek-ai/dsh-client-ui-sidebar-browser' +/** Blue globe artwork for the guide entry. */ +function BrowserGuideIcon({ size = 36, className }: IconProps) { + return ( + + ) +} + /** Build the Browser type with locale-live copy. */ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRightTabDefinition { return { @@ -21,7 +33,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: BrowserGuideIcon, }], } } 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 */} - + - + - + ) } diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 555bbdf495..8a5a4a7bac 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -55,7 +55,9 @@ describe('ExpandButton', () => { binding: null, keys: ['Ctrl', 'B'], aria: 'Control+B', modified: true, conflicts: [], issue: null }]) expect(control()?.getAttribute('aria-keyshortcuts')).toBe('Control+B') fireEvent.mouseEnter(control()!) - expect((await view.findByRole('tooltip')).textContent).toBe('shortcut.hint') + const tooltip = await view.findByRole('tooltip') + expect(tooltip.textContent).toContain('chrome.expand') + expect(Array.from(tooltip.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'B']) cleanup() }) it('offers the way in while the session has no surface yet, and asks the panel to expand', () => { diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index cc11d1c863..8c692290c9 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -743,10 +743,10 @@ describe('slot-owned useTabInfo', () => { }) const anchor = element(h.view.container, '[data-dockkit-split-button]').parentElement! fireEvent.focus(anchor) - expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Two panes is the limit ⌘ \\') + expect(document.querySelector('[role="tooltip"]')?.getAttribute('aria-label')).toBe('Two panes is the limit ⌘ \\') act(() => { h.catalog.set([{ ...split, binding: { code: 'KeyG', modifiers: ['control'] }, keys: ['Ctrl', '+', 'G'], aria: 'Control+G' }]) }) - expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Two panes is the limit Ctrl + G') + expect(document.querySelector('[role="tooltip"]')?.getAttribute('aria-label')).toBe('Two panes is the limit Ctrl + G') act(() => { h.catalog.set([{ ...split, binding: null, keys: [], aria: undefined }]) }) expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Two panes is the limit') }) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index dc9984bc87..14e566dbc8 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 1fa9a72d1ee045cc /deepseek-ai-dsh-client-ui-sidebar-terminal/use-this-package: - en: 8cba6f2ee8cc9324 - zh: 046c5b8ff551f2d1 + en: 3f9f4b5514fc5552 + zh: ce6d84a9cc1da71a /deepseek-ai-dsh-client-ui-sidebar-terminal/understand-the-implementation: - en: 5fd7c0f80571300a - zh: 55429315124d0488 + en: 005b5496578870d3 + zh: 9883a41eef2a18a5 /deepseek-ai-dsh-client-ui-sidebar-terminal/further-exploration: en: 9a4ba1f433d5fda2 zh: b2b58d3d144d4eba diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index 78c8b5256e..fb2097bea1 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -25,9 +25,9 @@ Choose an installed shell from the right sidebar's Start page to run commands in ## Use this package -The `terminal.new` command creates a separate terminal 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. The guide button displays the current effective shortcut inline without a duplicate tooltip. Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound by default. +The `terminal.new` command creates a separate terminal 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. The guide card right-aligns the current effective shortcut as unboxed text without a duplicate tooltip. Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound by default. -Open the right sidebar and click **New terminal** to open the remembered available shell immediately. Its adjacent arrow opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal. +Open the right sidebar and click **New terminal** to open the remembered available shell immediately. The separate arrow beside the title opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal. Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A temporary disconnect preserves the screen and offers **Reconnect**, without exposing transport diagnostics. An exited shell remains visible with its exit code and offers **New terminal**; it never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. @@ -43,7 +43,7 @@ The terminal background, default text, cursor, and selection follow the DSH them
Implementation internals — click to expand -This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. +This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide reuses the Plugins page’s blue terminal artwork; tab titles use the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 0279ac78fe..13a26f821f 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用此包 -命令 `terminal.new` 在焦点停靠分栏创建独立终端,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。开始页按钮内显示当前有效快捷键,不额外弹出重复提示。Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。 +命令 `terminal.new` 在焦点停靠分栏创建独立终端,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。开始页卡片右侧对齐显示当前有效快捷键的无背景文字,不额外弹出重复提示。Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。 -展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。旁边的箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。 +展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。标题旁的独立箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。 双击终端标签标题可重命名。其他页面持有输入权时,点击 **接管输入** 使当前连接可写。暂时断线时保留屏幕并提供 **重新连接**,不展示传输内部错误。已退出的 shell 仍显示退出码并提供 **新建终端**,不会自动重启。已退出的终端仍计入 Session 配额;达到上限时请关闭不用的标签。 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节 -插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口复用插件页面的蓝色终端图标,页签标题使用线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css index 74b1cddf83..5d13db7b4d 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css @@ -1,11 +1,12 @@ -/* The enclosing card clips both button fills to the guide-owned radius. */ -.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsl-guide-entry-radius); background: var(--dsw-alias-bg-layer-1); } -.mainAnchor { display: flex; flex: 1; min-width: 0; } -.main { flex: 1; justify-content: flex-start; gap: 14px; min-width: 0; height: auto; min-height: 56px; padding: 14px 20px; border-radius: 0; text-align: left; } -.icon { flex: none; } -.text { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.entry { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 14px; width: 100%; min-height: 56px; padding: 14px 20px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsl-guide-entry-radius); background: var(--dsw-alias-bg-layer-1); } +.main { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 0; } +.main:focus-visible { outline-offset: -2px; } +.icon { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 26px; height: 26px; color: var(--dsw-alias-label-secondary); pointer-events: none; } +.text { position: relative; flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; pointer-events: none; } +.titleRow { display: flex; align-items: center; min-width: 0; gap: 4px; } .title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } -.description { overflow: hidden; color: var(--dsw-alias-label-caption); font-size: 13px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } -.trigger { align-self: stretch; flex: none; height: auto; width: 44px; padding: 0; border-radius: 0; } -.trigger svg { width: 18px; height: 18px; color: var(--dsw-alias-label-tertiary); } -.menu { display: flex; align-self: stretch; flex: none; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } +.trigger { flex: none; height: 20px; width: 20px; padding: 0; border-radius: 4px; } +.trigger svg { width: 14px; height: 14px; color: var(--dsw-alias-label-tertiary); } +.menu { display: flex; flex: none; pointer-events: auto; } +.shortcut { position: relative; pointer-events: none; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx index f70490e4bd..c9c06718a1 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx @@ -2,12 +2,11 @@ import { useEffect, useState, type ReactNode } from 'react' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import { ShortcutKeys, Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { PluginArtworkTerminal, ShortcutKeys, Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from './locales.ts' -import { TerminalGuideIcon } from './TerminalIcon.tsx' import css from './TerminalGuide.module.css' /** Provider-owned discovery and browser preference writes. */ @@ -26,7 +25,7 @@ type MenuState = { phase: 'loading' } | { phase: 'ready'; choices: TerminalLaunc /** * Open the remembered shell from the card or choose another shell from its menu. - * The launch button displays its current effective shortcut inline. + * The card displays its effective shortcut; the shell menu sits beside the title. * @param props - guide copy, enclosing tab actions and cancellable discovery. * @returns separate launch and menu buttons within one guide card. */ @@ -54,31 +53,31 @@ export function TerminalGuide({ ? [{ id: 'loading', label: t('shellLoading'), disabled: true }] : [{ id: 'error', label: t('failed', { message: state.message }), disabled: true }, { id: 'retry', label: t('retry') }] return
- - + } + /> + + {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 ee4d7543ff..28fc9ec607 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: 501dc10cc01d214c + zh: a3fcb4ff6ca53f5f /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 2750ab706a..91c94002a9 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -29,7 +29,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 reveals the effective binding as trailing grey text on hover or keyboard focus, keeping the icon and label centered together. When the trailing space is too narrow, a key-only tooltip replaces the inline hint. 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 32364811ae..83fce45384 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -29,7 +29,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 页面。 展开态的新建会话按钮在悬停或键盘聚焦时于右侧显示当前有效绑定的灰色文字,图标与文字始终作为整体居中。右侧空间不足时,改用仅含快捷键的 tooltip。纯图标控件保留使用平台键位样式的 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/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index a467c3dbba..79ec54b715 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,20 @@ 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.getByRole('tooltip').getAttribute('aria-label')).toBe('Ctrl N') + 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 70f49fbb64..f6602d231a 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: 1d398bcc78b09ef2 - zh: cc657eeb80b55a62 + en: 9e12626e970874b7 + zh: 7b4013d4305b4d2e /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 30cfb594cb..06bc74f848 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -59,7 +59,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou `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 3ccc8901a6..d7f1debd71 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -59,7 +59,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..fad12ea46f 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" ], @@ -58,7 +55,7 @@ ".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/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 )} /> )} - + {(canCloseTab?.(tab.id) ?? true) && ( - -
) @@ -472,7 +472,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} - {entry.shortcut !== undefined && } + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -490,7 +490,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {sub.icon !== undefined && {sub.icon}} {sub.label} - {sub.shortcut !== undefined && } + {sub.shortcut !== undefined && } ))} diff --git a/packages/client/ui-primitives/src/ShortcutKeys.module.css b/packages/client/ui-primitives/src/ShortcutKeys.module.css index 46b8e90cfe..10afdefb53 100644 --- a/packages/client/ui-primitives/src/ShortcutKeys.module.css +++ b/packages/client/ui-primitives/src/ShortcutKeys.module.css @@ -14,24 +14,9 @@ display: inline-flex; align-items: center; justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 3px; - border: 0.5px solid var(--dsw-alias-border-l1); - border-radius: var(--dsw-radius-sm); - background: var(--dsw-alias-bg-module-platform); font: inherit; } -.plain .key { - min-width: 0; - height: auto; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; -} - .tooltip { color: inherit; font-size: 11px; line-height: 14px; gap: 2px; } .tooltip .key { min-width: 16px; @@ -44,13 +29,5 @@ .separator { font: inherit; } -.joined { - box-sizing: border-box; - height: 20px; - padding: 0 6px; - border: 0.5px solid var(--dsw-alias-border-l1); - border-radius: var(--dsw-radius-sm); - background: var(--dsw-alias-bg-module-platform); -} +.joined { box-sizing: border-box; height: 16px; padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-tooltip-key-bg); } .joined .key { min-width: 0; height: auto; padding: 0; border: 0; background: transparent; } -.joined.tooltip { height: 16px; padding: 0 4px; border: 0; border-radius: 4px; background: var(--dsw-alias-tooltip-key-bg); } diff --git a/packages/client/ui-primitives/src/ShortcutKeys.tsx b/packages/client/ui-primitives/src/ShortcutKeys.tsx index e583015412..33c91db0aa 100644 --- a/packages/client/ui-primitives/src/ShortcutKeys.tsx +++ b/packages/client/ui-primitives/src/ShortcutKeys.tsx @@ -5,8 +5,8 @@ import clsx from 'clsx' /** * Render one command's keycaps without owning binding defaults or localized copy. * @param props - effective key labels, presentation variant and optional interaction styling. - * @returns keycaps (joined for plus-separated combinations), or unboxed keys for compact text hints. + * @returns unboxed keys by default, or tooltip keycaps with plus-separated combinations grouped together. */ -export function ShortcutKeys({ keys, variant = 'keycaps', className }: { keys: readonly string[]; variant?: 'keycaps' | 'plain' | 'tooltip'; className?: string | undefined }) { - return {keys.map((key, index) => {key})} +export function ShortcutKeys({ keys, variant = 'plain', className }: { keys: readonly string[]; variant?: 'plain' | 'tooltip'; className?: string | undefined }) { + return {keys.map((key, index) => {key})} } diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index db3d724528..a9c76670d0 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -29,7 +29,6 @@ .bubble[data-has-shortcut] { padding-inline-end: 5px; } .bubble[data-has-shortcut]:not(:has(.label)) { padding: 3px; } - .bubble[data-side='right'] { transform: translateY(-50%); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 48cbf65e8f..60da395224 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -41,7 +41,7 @@ if (typeof window !== 'undefined') { /** * Attach a hover/focus tooltip to an anchor element. - * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. + * @param props.label - bubble text, or a resolver evaluated only while visible; an empty string shows only shortcut keys. * @param props.shortcutKeys - effective key labels rendered as platform-formatted keycaps after optional text. * @param props.side - placement relative to the anchor (default 'right'). * @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins diff --git a/packages/client/ui-primitives/src/guide-artwork.tsx b/packages/client/ui-primitives/src/guide-artwork.tsx new file mode 100644 index 0000000000..8eb669bc57 --- /dev/null +++ b/packages/client/ui-primitives/src/guide-artwork.tsx @@ -0,0 +1,32 @@ +/** Fixed-palette artwork for sidebar guide entries. */ +import type { IconProps } from './icons/props.ts' + +/** + * Render fixed-palette artwork for a sidebar guide entry. + * @param props - canvas size and layout class supplied by the guide. + * @returns an ornamental SVG hidden from assistive technology. + */ +export function GuideArtworkBrowser({ size = 36, className }: IconProps) { + return ( + + ) +} + +/** + * Render fixed-palette artwork for a sidebar guide entry. + * @param props - canvas size and layout class supplied by the guide. + * @returns an ornamental SVG hidden from assistive technology. + */ +export function GuideArtworkFiles({ size = 36, className }: IconProps) { + return ( + + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index f853c375f7..0e4cc0a527 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -107,5 +107,6 @@ export * from './icons/index.tsx' export { PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, } from './plugin-artwork.tsx' +export { GuideArtworkBrowser, GuideArtworkFiles } from './guide-artwork.tsx' export { ImageLightbox } from './ImageLightbox.tsx' export type { ImageLightboxLabels } from './ImageLightbox.tsx' diff --git a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx index 5db7f79cb8..e34d8555d2 100644 --- a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx +++ b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx @@ -2,12 +2,15 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { + GuideArtworkBrowser, GuideArtworkFiles, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) const artworks = { + GuideArtworkBrowser, + GuideArtworkFiles, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index f0ca259c7f..e1008a2cd9 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -2,7 +2,6 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'shortcut.hint': '{label} {keys}', 'trigger': '设置', 'shortcut.open': '打开设置', 'desktop.update.available': '新版本', @@ -44,7 +43,6 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'shortcut.hint': '{label} {keys}', 'trigger': 'Settings', 'shortcut.open': 'Open settings', 'desktop.update.available': 'Update', diff --git a/packages/client/ui-shortcuts/README.i18n.yaml b/packages/client/ui-shortcuts/README.i18n.yaml index 6d3df3852a..7cde273f70 100644 --- a/packages/client/ui-shortcuts/README.i18n.yaml +++ b/packages/client/ui-shortcuts/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 0cd57b7b8d8c9b59 /deepseek-ai-dsh-client-ui-shortcuts/use-this-package: - en: ad39cdf5afe80c9d - zh: f21e5395e7377c23 + en: 10a28cdcb6bc7c03 + zh: 62b12ff10b52e8de /deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation: en: f56f2610e7258825 zh: 249d7cdd55c57166 diff --git a/packages/client/ui-shortcuts/README.md b/packages/client/ui-shortcuts/README.md index 1ebb9c0585..c2a478770b 100644 --- a/packages/client/ui-shortcuts/README.md +++ b/packages/client/ui-shortcuts/README.md @@ -33,7 +33,7 @@ Core actions follow a fixed product order, independent of plugin registration, u Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Search relevance takes precedence; equal matches retain the display order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Both platforms show each editable row’s complete key combination in one settings-control grey block with secondary ink. Fixed actions use unboxed tertiary text, cannot enter recording, and do not highlight on hover. Group titles align with command labels. -Hover an application row to highlight its rounded surface and reveal the edit icon; keyboard focus also reveals the icon. Clicking anywhere on the row starts its inline recorder. Unbound commands omit the inline Remove action. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a neutral grey border with secondary text. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. Clicking blank dialog space closes the inline editor and discards any held combination. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. Configuration or command-catalog changes require review before a draft can be retried. Read failures disable editing and identify the active bindings and affected document in a system toast: Web uses the current origin’s localStorage entry `dsh.keybindings.v1`; Desktop uses `userData/keybindings.json`. Back up and repair damaged data before reloading the page or restarting Harness; upgrade Harness for a document written by a newer version. Restore All never overwrites unreadable data. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. At zero overrides, the count is hidden and the action is disabled. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry. +Hover an application row to highlight its rounded surface and reveal the edit icon; keyboard focus also reveals the icon. Clicking anywhere on the row starts its inline recorder. Unbound commands omit the inline Remove action. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a neutral grey border with secondary text. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. Clicking blank dialog space outside the inline editor closes it and discards any held combination; clicking its status text preserves the draft. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. Configuration or command-catalog changes require review before a draft can be retried. Read failures disable editing and identify the active bindings and affected document in a system toast: Web uses the current origin’s localStorage entry `dsh.keybindings.v1`; Desktop uses `userData/keybindings.json`. Back up and repair damaged data before reloading the page or restarting Harness; upgrade Harness for a document written by a newer version. Restore All never overwrites unreadable data. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. At zero overrides, the count is hidden and the action is disabled. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry. ----- diff --git a/packages/client/ui-shortcuts/README.zh.md b/packages/client/ui-shortcuts/README.zh.md index 01603a90f3..eba485f581 100644 --- a/packages/client/ui-shortcuts/README.zh.md +++ b/packages/client/ui-shortcuts/README.zh.md @@ -33,7 +33,7 @@ Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注 搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。两个平台均将可编辑行的完整快捷键放在同一个设置控件灰色色块中,使用次级文字色。固定操作使用无背景的三级文字色,不能进入录键,悬停时不高亮。分组标题与命令名称左对齐。 -悬停应用操作行时,整行显示圆角高亮并露出编辑图标;键盘聚焦也会显示该图标。点击行内任意位置进入行内录键。未绑定的命令不显示行内“移除”按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,使用中性灰边框与次级文字色。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。点击弹窗空白处关闭行内编辑区,并丢弃尚未释放的组合。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。配置或命令目录变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的键位及受影响的配置位置:Web 使用当前来源的 localStorage 项 `dsh.keybindings.v1`;Desktop 使用 `userData/keybindings.json`。内容损坏时先备份并修复配置,再刷新页面或重新启动 Harness;配置由更新版本写入时应升级 Harness。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时隐藏计数,并禁用该操作。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。 +悬停应用操作行时,整行显示圆角高亮并露出编辑图标;键盘聚焦也会显示该图标。点击行内任意位置进入行内录键。未绑定的命令不显示行内“移除”按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,使用中性灰边框与次级文字色。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。点击行内编辑区外的弹窗空白处会关闭编辑区,并丢弃尚未释放的组合;点击编辑区内的状态文字会保留草稿。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。配置或命令目录变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的键位及受影响的配置位置:Web 使用当前来源的 localStorage 项 `dsh.keybindings.v1`;Desktop 使用 `userData/keybindings.json`。内容损坏时先备份并修复配置,再刷新页面或重新启动 Harness;配置由更新版本写入时应升级 Harness。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时隐藏计数,并禁用该操作。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。 ----- diff --git a/packages/client/ui-shortcuts/src/client/Editor.tsx b/packages/client/ui-shortcuts/src/client/Editor.tsx index 804dd2623f..4b2cf4d156 100644 --- a/packages/client/ui-shortcuts/src/client/Editor.tsx +++ b/packages/client/ui-shortcuts/src/client/Editor.tsx @@ -129,7 +129,6 @@ EditorProps) { // macOS can omit the character keyup while Command is held. if (desktopChords && (held.size === 0 || platform === 'macos' && /^Meta(Left|Right)$/u.test(event.code))) { held.clear(); blocked = false } if (binding === null) return - if (desktopChords && document.activeElement !== recorder.current) { reset(); return } handlers.current.capture(binding, targetId) } const blur = (): void => { reset(); if (desktopChords) { setCandidate(null); setCaptured(false) } } @@ -170,7 +169,7 @@ EditorProps) { onBlur={() => { restart.current() }} aria-label={t('record')} aria-invalid={message !== ''} aria-describedby={descriptionId} onClick={() => { if (!writing.current) { restart.current(); setCaptured(false); setMessage(''); setRetry(null) } }}> - {captured && message === '' ? : t('record')} + {captured && message === '' ? : t('record')}
{message || (desktopChords ? '' : t(runtime === 'web' diff --git a/packages/client/ui-shortcuts/src/client/Reference.tsx b/packages/client/ui-shortcuts/src/client/Reference.tsx index 6b841d34c2..d5dd35efd6 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.tsx +++ b/packages/client/ui-shortcuts/src/client/Reference.tsx @@ -130,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, @@ -152,7 +153,7 @@ export function ShortcutReference({ return <>
{ - if (target === null || busy || !(event.target instanceof Element) || event.target.closest('button, input, a, [contenteditable="true"]') !== null) return + if (target === null || busy || !(event.target instanceof Element) || event.target.closest('button, input, a, [contenteditable="true"], [role="group"]') !== null) return event.preventDefault() closeEditor() }}> @@ -191,9 +192,9 @@ export function ShortcutReference({ ? : <> - {row.keys.length === 0 ? {t('unbound')} : } + {row.keys.length === 0 ? {t('unbound')} : } - : } + : } )} diff --git a/packages/client/ui-shortcuts/src/client/locales.ts b/packages/client/ui-shortcuts/src/client/locales.ts index 84ea32f946..13cd61d4ba 100644 --- a/packages/client/ui-shortcuts/src/client/locales.ts +++ b/packages/client/ui-shortcuts/src/client/locales.ts @@ -27,7 +27,6 @@ export const zh = { 'using-defaults': '当前使用默认键位。', 'using-accepted': '当前继续使用上次读取成功的键位。', 'native-failed': '无法启用桌面录键保护,请关闭后重试。', 'global-hint': '全局唤起查看', 'clear-search': '清空搜索', - hint: '{label} {keys}', title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '编辑快捷键', description: '查看和编辑当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键', application: '应用操作', input: '消息输入', menus: '菜单与弹层', approval: '审批区域', unbound: '暂无快捷键', @@ -63,7 +62,6 @@ export const en: Record = { '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.', 'global-hint': 'Open from anywhere', 'clear-search': 'Clear search', - hint: '{label} {keys}', 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', diff --git a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx index 78584a6ca8..d2ffd27699 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -41,6 +41,9 @@ it('keeps a reference open while a clear operation is pending', async () => { 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) @@ -557,3 +560,26 @@ it('closes the editor and drops a pending recording when blank space is clicked' }) 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/src/client/definition.tsx b/packages/client/ui-sidebar-browser/src/client/definition.tsx index 5298f824b8..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 type { IconProps } 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' @@ -11,18 +11,6 @@ export const BROWSER_KIND = 'browser' /** Browser implementation identity and keyed Slot dispatch key. */ export const BROWSER_ID = '@deepseek-ai/dsh-client-ui-sidebar-browser' -/** Blue globe artwork for the guide entry. */ -function BrowserGuideIcon({ size = 36, className }: IconProps) { - return ( - - ) -} - /** Build the Browser type with locale-live copy. */ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRightTabDefinition { return { @@ -33,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: BrowserGuideIcon, + 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-documentpreview/src/client/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/locales.ts index 55650cfea2..f7c7c645d6 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/locales.ts @@ -8,7 +8,6 @@ /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { - 'shortcut.hint': '{label} {keys}', loading: '文档渲染中...', loadMore: '加载更多', changed: '文件已更新,当前显示为旧内容', @@ -38,7 +37,6 @@ export type SidebarDocumentPreviewKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { - 'shortcut.hint': '{label} {keys}', loading: 'Rendering document...', loadMore: 'Load more', changed: 'The file has changed, showing the previous content.', diff --git a/packages/client/ui-sidebar-files/src/client/definition.tsx b/packages/client/ui-sidebar-files/src/client/definition.tsx index 1603a5caab..2abf29e33f 100644 --- a/packages/client/ui-sidebar-files/src/client/definition.tsx +++ b/packages/client/ui-sidebar-files/src/client/definition.tsx @@ -9,7 +9,7 @@ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type {} from './locales.ts' -import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { GuideArtworkFiles } from '@deepseek-ai/dsh-client-ui-primitives' /** The tab kind this package owns. */ export const FILES_KIND = 'files' @@ -17,16 +17,6 @@ export const FILES_KIND = 'files' /** This implementation's identity in the tab system, and the key its body registers under. */ export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files' -/** Yellow folder artwork for the guide entry. */ -function FolderSheetGlyph({ size = 36, className }: IconProps) { - return ( - - ) -} - /** * The files type's registry definition. * @param t - namespace-bound translate, read fresh on every label call. @@ -44,7 +34,7 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab order: 10, title: () => t('guide.title'), description: () => t('guide.description'), - icon: FolderSheetGlyph, + icon: GuideArtworkFiles, }], } } diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts index e68bfb82ac..ea4b6eeaec 100644 --- a/packages/client/ui-sidebar-files/src/client/locales.ts +++ b/packages/client/ui-sidebar-files/src/client/locales.ts @@ -20,7 +20,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { - 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': '请先选择会话', 'type.label': '文件', 'guide.title': '工作区文件', @@ -45,7 +44,6 @@ export type SidebarFilesKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { - 'shortcut.hint': '{label} {keys}', 'shortcut.noSession': 'Select a session first', 'type.label': 'Files', 'guide.title': 'Workspace files', diff --git a/packages/client/ui-sidebar-right/src/client/labels.ts b/packages/client/ui-sidebar-right/src/client/labels.ts index eadcac8760..342c195ab8 100644 --- a/packages/client/ui-sidebar-right/src/client/labels.ts +++ b/packages/client/ui-sidebar-right/src/client/labels.ts @@ -23,13 +23,11 @@ export function dockLabels(t: TranslateNS<'sidebarRight'>, split?: ShortcutCatal return { emptyPane: t('dock.emptyPane'), splitPane: t('dock.splitPane'), - splitPaneTooltip: t('dock.splitPane'), splitPaneShortcut: split?.aria, splitPaneKeys: split?.keys, splitPaneDisabled: t('dock.splitPaneDisabled'), splitPaneNarrow: t('dock.splitPaneNarrow'), closeTab: t('dock.closeTab'), - closeTabTooltip: t('dock.closeTab'), closeTabShortcut: close?.aria, closeTabKeys: close?.keys, addTab: t('dock.addTab'), diff --git a/packages/client/ui-sidebar-right/src/client/locales.ts b/packages/client/ui-sidebar-right/src/client/locales.ts index f5c1142be8..d84f2a4b0a 100644 --- a/packages/client/ui-sidebar-right/src/client/locales.ts +++ b/packages/client/ui-sidebar-right/src/client/locales.ts @@ -8,7 +8,6 @@ /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { - 'shortcut.hint': '{label} {keys}', 'command.close': '关闭当前页面/窗口', 'command.refresh': '刷新当前页面', 'command.noRefresh': '当前页面不支持刷新', @@ -50,7 +49,6 @@ export type SidebarRightKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { - 'shortcut.hint': '{label} {keys}', 'command.close': 'Close current page or window', 'command.refresh': 'Refresh current page', 'command.noRefresh': 'This page cannot be refreshed', diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index e985567dd5..eedad513bc 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -69,7 +69,7 @@ function EntryBox({ entry, described, onPick, shortcut }: { {entry.title()} {description !== undefined && {description}} - {shortcut !== undefined && shortcut.keys.length > 0 && } + {shortcut !== undefined && shortcut.keys.length > 0 && } ) } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx index c9c06718a1..0c9112faaf 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx @@ -78,6 +78,6 @@ export function TerminalGuide({ {description !== undefined && } - {shortcut !== undefined && shortcut.keys.length > 0 && } + {shortcut !== undefined && shortcut.keys.length > 0 && }
} diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 28fc9ec607..4d4ae5af3f 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: 501dc10cc01d214c - zh: a3fcb4ff6ca53f5f + en: 0ee8cab67ce33ec2 + zh: 5925286aea07a045 /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 91c94002a9..dada96e867 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -29,7 +29,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. The expanded New Session button reveals the effective binding as trailing grey text on hover or keyboard focus, keeping the icon and label centered together. When the trailing space is too narrow, a key-only tooltip replaces the inline hint. 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`. +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 always displays the effective binding as trailing grey text. The centered icon and label fade before the shortcut 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 83fce45384..45337b3a4f 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -29,7 +29,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。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。 +展开的品牌行把 `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/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 64e1d88fe8..ae8f87bc2e 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -385,14 +385,15 @@ /* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off corners); collapsed it renders as the rail's plain icon control. */ .newSession { + container-type: inline-size; position: relative; flex: none; display: flex; align-items: center; justify-content: center; - gap: 6px; + gap: 0; height: 38px; - padding: 8px 16px; + padding: 8px 12px; margin: 0 2px 12px; box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); @@ -454,20 +455,18 @@ max-width: 0; } -.newSessionContent { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.newSessionLabelMask { flex: 1; min-width: 0; overflow: hidden; } +.newSessionLabelMask:has(+ .newSessionShortcut) { mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); } +.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; } +.newSessionContent > svg { flex: none; } .newSessionShortcut { - position: absolute; - right: 12px; display: inline-flex; - opacity: 0; + flex: none; pointer-events: none; font-weight: 400; } -.newSession[data-shortcut-inline]:hover .newSessionShortcut, -.newSession[data-shortcut-inline]:focus-visible .newSessionShortcut { opacity: 1; } - .panelList { flex: none; display: flex; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index cdfda5878d..a41fde106e 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -16,7 +16,7 @@ * scrollbar indirection away while it is elsewhere, so a list the user is not * pointing at carries no bar. */ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { FishLogo, IconNewChatOutlineMedium, IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, ShortcutKeys, Tooltip, @@ -101,11 +101,6 @@ export function SidebarRoot({ const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle')) const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new')) const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse') - const newSessionButton = useRef(null) - const newSessionContent = useRef(null) - const newSessionKeys = useRef(null) - const [newShortcutFits, setNewShortcutFits] = useState(false) - // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -120,24 +115,6 @@ export function SidebarRoot({ // (that is what --dsh-windows-menu-start reserves), so a right-side bubble // lands under their text. Below the caption is the only clear side. const captionTooltipSide = windowsTitlebar ? 'bottom' : 'right' - useLayoutEffect(() => { - const button = newSessionButton.current - const content = newSessionContent.current - const keys = newSessionKeys.current - if (!wide || button === null || content === null || keys === null) return - const fit = (): void => { - const style = getComputedStyle(button) - const available = (button.clientWidth - content.getBoundingClientRect().width) / 2 - - Number.parseFloat(style.paddingRight) - Number.parseFloat(style.columnGap) - setNewShortcutFits(keys.getBoundingClientRect().width <= available) - } - const observer = new ResizeObserver(fit) - for (const element of [button, content, keys]) observer.observe(element) - fit() - return () => { observer.disconnect() } - }, [wide, newShortcut?.keys]) - - // Freeze the content at its expanded width while it fades out (collapsed // && wide): the sliding column then clips it instead of reflowing it. The // rail layout (.collapsed styles) only applies once the fade settles. @@ -280,25 +257,23 @@ export function SidebarRoot({ {!darwinDesktop && toggle}
- {/* The centered action uses inline keys only when its trailing space fits them. */} - + {/* The label fades before the persistent shortcut, including on translucent backgrounds. */} + 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 79ec54b715..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 @@ -41,18 +41,22 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded type="button" > - + + +
renders Windows caption controls in expanded type="button" > - - New Session + + + New Session + @@ -285,18 +293,22 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad type="button" > - + + +
renders the expanded column (wordmark, capsul type="button" > - - New Session + + + New Session + @@ -558,22 +574,26 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo type="button" > - - 新会话 + + + 新会话 + diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index c232b0ef83..f2407246f8 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -109,7 +109,7 @@ describe('SidebarRoot shell', () => { const expanded = screen.getAllByRole('button', { name: 'New session' }).find(button => button.querySelector('kbd') !== null)! fireEvent.mouseEnter(expanded) fireEvent.focus(expanded) - expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe('Ctrl N') + expect(screen.queryByRole('tooltip')).toBeNull() expect(Array.from(expanded.querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'N']) cleanup() render( Date: Thu, 24 Sep 2026 17:58:58 +0800 Subject: [PATCH 07/10] test(cli): expect Schedule tools in the legacy-patch minimal preset The minimal preset carries the Schedule tools since #5123; the legacy-patch case added by #5108 still expected bash alone. --- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 3766a01de6..7d3a25f9af 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -912,7 +912,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', ...SCHEDULE_TOOLS]) } finally { await handle.dispose() } From c87959bc447fe5cb02f95f0588a09d16b8a1e970 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 24 Sep 2026 18:18:34 +0800 Subject: [PATCH 08/10] feat(ui-model-selection): show a spinner while a model selection is pending session.selectModel can take seconds while the Host persists the default model. The composer seat disabled its rows with no other feedback; the chosen row now spins in its check slot, and the trigger spins in place of its chevron while the menu is closed. --- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 38 ++++++++++++----- .../tests/model-select.client.spec.tsx | 41 +++++++++++++++++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index dc93f4d58c..2453c61cfb 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-model-selection/use-this-package: - en: 8451ab527ba71867 - zh: 933fac58cfa592b9 + en: b26ac46b464a9199 + zh: 41097c7bfef068a9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: en: e38d0195677a7b69 zh: 4eea8a9b1664afd9 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index fc2149d3bb..27b35a3a2a 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,7 +31,7 @@ The unselected model label uses the same regular weight as an available model na Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. -Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection is pending, focus stays on the trigger; a rejected selection leaves the menu open, and Tab returns to the current row. +Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection is pending, focus stays on the trigger, the chosen row shows a spinner in place of its check, and the closed trigger shows one in place of its chevron; a rejected selection leaves the menu open, and Tab returns to the current row. ### Model and effort diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 8bed58dea7..0c03bc1f16 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,7 +31,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 -鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待选择结果时,焦点停在触发按钮;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。 +鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待选择结果时,焦点停在触发按钮,所选行以加载图标代替勾选标记,菜单关闭时触发按钮以加载图标代替下拉箭头;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。 ### 模型与推理强度 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 975e945eee..100792425f 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -15,7 +15,8 @@ * selected effort come from the Host rather than a client-owned vocabulary. A * rejected selection announces through the shared transient Toast anchored to * the composer card; the in-menu strip with Retry remains the catalog-load - * surface. + * surface. While a selection is pending, the chosen row's check slot shows a + * spinner, and the trigger's chevron does while the menu is closed. */ import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' import { @@ -27,7 +28,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, Toast, + IconDataOutlineRegular, IconWarningOutlineRegular, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -43,6 +44,11 @@ interface EffortChoice { label: string } +/** Row key of one model choice; effort rows use their own `effort:` keys. */ +function modelKey(provider: string, model: string): string { + return `model:${provider}/${model}` +} + /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } @@ -69,6 +75,8 @@ export function ModelSelect( const lastActionRef = useRef<'load' | 'select'>('load') const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) const toastSeq = useRef(0) + // Row key of the selection this seat submitted, until it settles. + const [pending, setPending] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) @@ -282,6 +290,7 @@ export function ModelSelect( } const settleSelection = (result: Awaited>): void => { + setPending(null) if (result === undefined) return if (result.ok) { if (rootRef.current !== null) close(true) @@ -297,8 +306,9 @@ export function ModelSelect( }) } - const submit = (selection: ModelSelection): void => { + const submit = (selection: ModelSelection, key: string): void => { lastActionRef.current = 'select' + setPending(key) // Disabled option rows cannot retain focus while a selection is pending. triggerRef.current?.focus() void select(selection).then(settleSelection) @@ -309,10 +319,10 @@ export function ModelSelect( close(true) return } - submit(selection) + submit(selection, modelKey(selection.provider, selection.model)) } - const chooseEffort = (effort: string | undefined): void => { + const chooseEffort = (effort: string | undefined, key: string): void => { if (state.current === null) return if (effectiveEffort === effort) { close(true) @@ -323,7 +333,7 @@ export function ModelSelect( model: state.current.model, ...effort === undefined ? {} : { reasoningEffort: effort }, } - submit(selection) + submit(selection, key) } const waiting = state.current === null && state.status === 'loading' @@ -366,6 +376,7 @@ export function ModelSelect( aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} title={triggerLabel} + aria-busy={busy} disabled={locked} onClick={() => { if (open) { @@ -378,7 +389,9 @@ export function ModelSelect( {modelLabel} {effortLabel !== undefined && {effortLabel}} - + {busy && !open + ? + : } {/* Portaled to body (Menu primitive's portal mode) so the sidebar and @@ -436,6 +449,7 @@ export function ModelSelect(
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id + const key = modelKey(group.id, model.id) return ( ) @@ -486,13 +502,15 @@ export function ModelSelect( className={clsx(css.option, effectiveEffort === level.effort && css.selected)} key={level.key} disabled={busy} - onClick={() => { chooseEffort(level.effort) }} + onClick={() => { chooseEffort(level.effort, level.key) }} > {level.label} - {effectiveEffort === level.effort ? : null} + {busy && pending === level.key + ? + : effectiveEffort === level.effort ? : null} ))} 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..e5b59139e6 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 @@ -223,6 +223,47 @@ describe('ModelSelect reasoning effort', () => { expect(screen.queryByRole('button', { name: '重试' })).toBeNull() }) + it('spins on the chosen row, then on the closed trigger, until the selection settles', 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' })) + 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/ })) + const pro = screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }) + expect(spinners()).toHaveLength(1) + expect(pro.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(trigger.getAttribute('aria-busy')).toBe('true') + + fireEvent.mouseDown(document.body) + 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('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')! From 4d4bb4c9076bc882a0e626d81d8ce73c55f991f9 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Thu, 24 Sep 2026 18:40:55 +0800 Subject: [PATCH 09/10] fix(client): reveal new-session keys on hover and align settings radius --- apps/web/tests/shortcuts.e2e.ts | 25 +++++++++++++------ packages/client/ui-shortcuts/README.i18n.yaml | 4 +-- packages/client/ui-shortcuts/README.md | 2 +- packages/client/ui-shortcuts/README.zh.md | 2 +- .../src/client/Reference.module.css | 2 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 7 ++++-- .../ui-sidebar/src/client/SidebarRoot.tsx | 2 +- .../expected/radius-exceptions.expected.json | 1 - 11 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index 7a894719bf..0bbe929b34 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -324,7 +324,7 @@ describe('web e2e: shortcut reference', () => { it.each([ { platform: 'Win32', primary: 'Control', keys: 'Ctrl + Alt + N' }, { platform: 'MacIntel', primary: 'Meta', keys: '⌥ ⇧ ⌘ F12' }, - ])('keeps the shortcut on New Session at wide and narrow sidebar widths on $platform', async ({ platform, primary, keys }) => { + ])('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) @@ -353,19 +353,28 @@ describe('web e2e: shortcut reference', () => { 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)) }) - await newSession.hover() - expect(await centerOffset()).toBeLessThan(1) - const hintStyle = await newSession.locator('[class*="newSessionShortcut"]').evaluate(element => ({ - opacity: getComputedStyle(element).opacity, mask: getComputedStyle(element.previousElementSibling!).maskImage, - })) - expect(hintStyle.opacity).toBe('1') - expect(hintStyle.mask).toContain('linear-gradient') 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() } diff --git a/packages/client/ui-shortcuts/README.i18n.yaml b/packages/client/ui-shortcuts/README.i18n.yaml index 7cde273f70..c9db791ce2 100644 --- a/packages/client/ui-shortcuts/README.i18n.yaml +++ b/packages/client/ui-shortcuts/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 0cd57b7b8d8c9b59 /deepseek-ai-dsh-client-ui-shortcuts/use-this-package: - en: 10a28cdcb6bc7c03 - zh: 62b12ff10b52e8de + en: 2d35beb03b54d4ea + zh: 12f3c6ec1a79c673 /deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation: en: f56f2610e7258825 zh: 249d7cdd55c57166 diff --git a/packages/client/ui-shortcuts/README.md b/packages/client/ui-shortcuts/README.md index c2a478770b..743c948a08 100644 --- a/packages/client/ui-shortcuts/README.md +++ b/packages/client/ui-shortcuts/README.md @@ -25,7 +25,7 @@ Browse the commands available in the current window and find them by action, Eng ## Use this package -The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keys; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open without changing its border color. Automatic entry and return focus in the reference, recorder, and reset confirmation omit outlines, including returns after Escape and application close shortcuts. Containers remain outline-free, and Tab and directional navigation retain their visible focus indicators. The Settings entry is labeled “Edit shortcuts”, with an “Open from anywhere” shortcut tooltip, and uses a filled capsule button without a dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding. +The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keys; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open without changing its border color. Automatic entry and return focus in the reference, recorder, and reset confirmation omit outlines, including returns after Escape and application close shortcuts. Containers remain outline-free, and Tab and directional navigation retain their visible focus indicators. The Settings entry is labeled “Edit shortcuts”, with an “Open from anywhere” shortcut tooltip, and uses a filled button with the same standard radius as adjacent settings controls and no dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding. `Mod+/` opens the reference when no modal is open or Settings is in front, and closes it when the reference is in front. While the reference is in front, the Settings command cannot open or close Settings. Held-key repeats are ignored. Recording, pending writes, and a nested confirmation prevent the reference shortcut from closing the dialog. diff --git a/packages/client/ui-shortcuts/README.zh.md b/packages/client/ui-shortcuts/README.zh.md index eba485f581..9dc12ca5ae 100644 --- a/packages/client/ui-shortcuts/README.zh.md +++ b/packages/client/ui-shortcuts/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键位;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框,边框颜色保持不变。速查窗、录键框及重置确认框内的自动进入和回焦均不绘制外轮廓线,包括通过 Esc 和应用关闭快捷键触发的回焦。容器仍不绘制焦点外框,Tab 和方向键导航保留可见焦点提示。设置入口标为“编辑快捷键”,快捷键提示文案为“全局唤起查看”,使用无下拉图标的填充胶囊按钮,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。 +Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键位;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框,边框颜色保持不变。速查窗、录键框及重置确认框内的自动进入和回焦均不绘制外轮廓线,包括通过 Esc 和应用关闭快捷键触发的回焦。容器仍不绘制焦点外框,Tab 和方向键导航保留可见焦点提示。设置入口标为“编辑快捷键”,快捷键提示文案为“全局唤起查看”,使用与相邻设置控件一致的标准圆角填充按钮,不带下拉图标,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。 没有模态弹窗或设置位于最前方时,`Mod+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。 diff --git a/packages/client/ui-shortcuts/src/client/Reference.module.css b/packages/client/ui-shortcuts/src/client/Reference.module.css index 230c17dc10..86c2782ab2 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.module.css +++ b/packages/client/ui-shortcuts/src/client/Reference.module.css @@ -39,7 +39,7 @@ max-width: 280px; padding: 0 14px; border: none; - border-radius: 18px; + border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary); font: inherit; diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 4d4ae5af3f..d05d84036a 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: 0ee8cab67ce33ec2 - zh: 5925286aea07a045 + 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 dada96e867..db76ce49d8 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -29,7 +29,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. The expanded New Session button always displays the effective binding as trailing grey text. The centered icon and label fade before the shortcut 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`. +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 45337b3a4f..31267b804c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -29,7 +29,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 页面。 展开态的新建会话按钮始终在右侧以灰色文字显示当前有效绑定。空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。 +展开的品牌行把 `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/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index a440199935..ac1116f04c 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -465,18 +465,21 @@ max-width: 0; } -.newSessionLabelMask { flex: 1; min-width: 0; overflow: hidden; } -.newSessionLabelMask:has(+ .newSessionShortcut) { mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); } +.newSessionLabelMask { flex: 1; min-width: 0; } +.newSession:is(:hover, :focus-visible) .newSessionLabelMask:has(+ .newSessionShortcut) { overflow: hidden; mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); } .newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; } .newSessionContent > svg { flex: none; } .newSessionShortcut { + opacity: 0; display: inline-flex; flex: none; pointer-events: none; font-weight: 400; } +.newSession:is(:hover, :focus-visible) .newSessionShortcut { opacity: 1; } + .panelList { flex: none; display: flex; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index a41fde106e..fed580320c 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -257,7 +257,7 @@ export function SidebarRoot({ {!darwinDesktop && toggle}
- {/* The label fades before the persistent shortcut, including on translucent backgrounds. */} + {/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */} @@ -449,7 +442,6 @@ export function ModelSelect(
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id - const key = modelKey(group.id, model.id) return (