feat(web): refine shortcut editing and sidebar hints

This commit is contained in:
yixiangihsiang committed 2026-09-24 15:55:33 +08:00
1 parent 55fadaa601
commit ed3dcf54c5
98 files changed
+755 -686

No files matched your search

@@ -25,8 +25,8 @@
- text: 开发者工具 显示用于调试和排查问题的工具与信息
- switch "开发者工具" [checked]
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -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}}"
@@ -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}}"
@@ -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}}"
@@ -25,8 +25,8 @@
- text: 开发者工具 显示用于调试和排查问题的工具与信息
- switch "开发者工具"
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -4,6 +4,7 @@
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键": abc
- button "清空搜索"
- status: 没有匹配的快捷键
- contentinfo:
- button "恢复全部默认"
@@ -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 项已自定义
+16 -3
View File
@@ -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()
+3 -2
View File
@@ -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')
+81 -38
View File
@@ -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()
+3 -2
View File
@@ -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)')
}
@@ -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
+1 -1
View File
@@ -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.
<a id="temporary-composer-entries"></a>
## Temporary composer entries
+1 -1
View File
@@ -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` 序列。
<a id="temporary-composer-entries"></a>
## 临时 composer entry
@@ -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)
@@ -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 })}
</div>}
{interruptible && (
<Tooltip label={stopHint} side="top" delayMs={500} disabled={stop === undefined}>
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stop === undefined}>
<button
type="button"
className={css.primary}
@@ -474,7 +473,7 @@ export const InputBar = memo(function InputBar({
</button>
</Tooltip>
)}
<Tooltip label={primaryStops ? stopHint : primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled}>
<button
type="button"
className={css.primary}
@@ -856,7 +856,7 @@ describe('running and lock semantics', () => {
if (trigger === 'hover') fireEvent.mouseEnter(stop)
else fireEvent.focus(stop)
act(() => { vi.advanceTimersByTime(500) })
expect(view.getByRole('tooltip').textContent).toBe(`${label} Esc Esc`)
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe(`${label} Esc Esc`)
act(() => { stopShortcut.set([]) })
expect(view.getByRole('tooltip').textContent).toBe(label)
} finally {
@@ -870,7 +870,7 @@ describe('running and lock semantics', () => {
const { button, view, session } = bench({ running: true })
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
expect(view.getByRole('tooltip').textContent).toBe('停止生成 Esc Esc')
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe('停止生成 Esc Esc')
// Disabling a hovered native button need not deliver mouseleave.
act(() => { session.set(snapshotOf({ running: false })) })
+2 -2
View File
@@ -21,8 +21,8 @@
en: 789c879dd83f463a
zh: 37f2dc45f9588810
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
en: 2c92ba93f7d6ee1c
zh: b4da347bf1e24408
en: 7e5c1e9a9f9f244f
zh: 7e3a8fcef86bc7dd
/deepseek-ai-dsh-client-ui-dockkit/build-shape:
en: 21216ba5798d7f5f
zh: ac505fdcd5c114d4
+1 -1
View File
@@ -65,7 +65,7 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
<a id="interaction-rules-worth-keeping"></a>
## Interaction rules worth keeping
The split control accepts effective shortcut tooltip and ARIA labels from its embedder. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
The split control accepts localized tooltip text, separate effective keys through `splitPaneKeys`, and an ARIA combination from its embedder. Close controls receive their keys through `closeTabKeys`. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
These are not stylistic; each one fixes a defect found in a real browser.
+1 -1
View File
@@ -65,7 +65,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
<a id="interaction-rules-worth-keeping"></a>
## 值得保留的交互规则
分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
分栏控件接收嵌入方传入的本地化提示文本、`splitPaneKeys` 独立按键及 ARIA 组合。关闭控件通过 `closeTabKeys` 接收按键。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
@@ -130,7 +130,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
</button>
</Tooltip>
{(canCloseTab?.(tab.id) ?? true) && (
<Tooltip label={labels.closeTabTooltip ?? labels.closeFloat} side="bottom" delayMs={500}>
<Tooltip label={labels.closeTabTooltip ?? labels.closeFloat} shortcutKeys={labels.closeTabKeys} side="bottom" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={labels.closeTabTooltip ?? labels.closeFloat}
data-dockkit-float-close={paneId} onPointerDown={(event) => { event.stopPropagation() }}
onClick={() => { intents.closeTab(tab.id) }}>
@@ -313,7 +313,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
>
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
{closable && (
<Tooltip disabled={menu !== undefined} label={callbacks.labels.closeTabTooltip ?? callbacks.labels.closeTab} side="bottom" delayMs={500}>
<Tooltip disabled={menu !== undefined} label={callbacks.labels.closeTabTooltip ?? callbacks.labels.closeTab} shortcutKeys={callbacks.labels.closeTabKeys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tabClose}
@@ -365,7 +365,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
)}
<div className={css.stripFill} data-dockkit-strip-fill />
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
<Tooltip label={block === undefined ? callbacks.labels.splitPaneTooltip ?? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} side="bottom" delayMs={500}>
<Tooltip label={block === undefined ? callbacks.labels.splitPaneTooltip ?? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} shortcutKeys={callbacks.labels.splitPaneKeys} side="bottom" delayMs={500}>
<span
tabIndex={block === undefined ? undefined : 0}
aria-label={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
@@ -19,20 +19,24 @@ export interface DockLabels {
readonly emptyPane: string
/** The split control, while splitting is allowed. */
readonly splitPane: string
/** Effective localized action and shortcut tooltip, when supplied. */
/** Localized action tooltip, when supplied. */
readonly splitPaneTooltip?: string
/** ARIA combination from the effective binding, when bound. */
readonly splitPaneShortcut?: string | undefined
/** Effective split keys shown as separate tooltip keycaps. */
readonly splitPaneKeys?: readonly string[] | undefined
/** The split control, once the pane budget is spent. */
readonly splitPaneDisabled: string
/** The split control, while the pane is too narrow for two working halves. */
readonly splitPaneNarrow: string
/** Destroy a tab: the chip's close control and the menu's close item. */
readonly closeTab: string
/** Effective close label and keycaps supplied by the embedder. */
/** Localized close tooltip supplied by the embedder. */
readonly closeTabTooltip?: string
/** Effective close combination, when bound. */
readonly closeTabShortcut?: string | undefined
/** Effective close keys shown as separate tooltip keycaps. */
readonly closeTabKeys?: readonly string[] | undefined
/** The strip's add control, which seats the embedder's seeded tab. */
readonly addTab: string
/** Send a floating panel back into the docked tree. */
@@ -132,7 +132,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
anchor={(
<div className={css.split} data-open-target={kind} data-size={props.prominent ? 'large' : 'compact'}
data-open-path={kind === 'file' && !props.prominent ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip portal label={props.shortcut?.keys.length ? t('shortcut.hint', { label: primaryLabel, keys: props.shortcut.keys.join(' ') }) : primaryLabel} side="bottom" delayMs={500}>
<Tooltip portal label={primaryLabel} shortcutKeys={props.shortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={disabled} aria-label={props.prominent ? undefined : primaryLabel}
aria-keyshortcuts={props.shortcut?.aria}
data-open-path-open={kind === 'file' && !props.prominent ? '' : undefined}
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: 91ac7c457248a593
zh: 744c9cea337bb20f
en: 5490e4639bb6aa31
zh: 006db37d2dc0b98a
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 33299e518ff2cad1
zh: 880c7da903bc20d4
en: e4e850bb3ae8f257
zh: 4ac1e4cc490c2cdb
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 83052e14c3fb34cf
zh: 0adb09885723296a
+2 -2
View File
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
<a id="use-this-package"></a>
## Use this package
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied keys as lightly outlined keycaps, grouping plus-separated combinations into one filled block. Its `plain` variant supplies unboxed menu and inline hints; `tooltip` uses a lighter fill over the dark bubble. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
@@ -66,7 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
| `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. |
| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. |
+2 -2
View File
@@ -25,7 +25,7 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的按键显示为浅描边键帽,以加号连接的组合则共用一个填充色块。`plain` 变体用于无方块的菜单和行内提示;`tooltip` 在深色气泡上使用稍浅的填充。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
@@ -66,7 +66,7 @@ kind: "package-library"
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
| `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 |
| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 |
@@ -201,11 +201,9 @@
}
.shortcut .shortcutKeys {
height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-caption);
font-size: 11px;
line-height: 16px;
}
.check {
+3 -3
View File
@@ -97,7 +97,7 @@ export function MenuItemButton({
>
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
<span className={css.itemLabel}>{children}</span>
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={shortcut.keys} className={css.shortcutKeys} /></span>}
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys variant="plain" keys={shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
</div>
)
@@ -472,7 +472,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{entry.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={entry.shortcut.keys} className={css.shortcutKeys} /></span>}
{entry.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys variant="plain" keys={entry.shortcut.keys} className={css.shortcutKeys} /></span>}
{/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
{selected && selection === 'check' && <IconCheckOutlineRegular className={css.check} />}
</button>
@@ -490,7 +490,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
{sub.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={sub.shortcut.keys} className={css.shortcutKeys} /></span>}
{sub.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys variant="plain" keys={sub.shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
))}
</MenuSurface>
@@ -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); }
@@ -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 <span className={clsx(css.keys, className)}>{keys.map((key, index) => <kbd key={index} className={css.key}>{key}</kbd>)}</span>
export function ShortcutKeys({ keys, variant = 'keycaps', className }: { keys: readonly string[]; variant?: 'keycaps' | 'plain' | 'tooltip'; className?: string | undefined }) {
return <span className={clsx(css.keys, css[variant], variant !== 'plain' && keys.includes('+') && css.joined, className)}>{keys.map((key, index) => <kbd key={index} className={key === '+' ? css.separator : css.key}>{key}</kbd>)}</span>
}
@@ -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%);
@@ -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<AnchorProps> }) {
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<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(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 && <span className={css.label}>{resolvedLabel}</span>}
{shortcutKeys !== undefined && shortcutKeys.length > 0 && <ShortcutKeys keys={shortcutKeys} variant="tooltip" />}
</span>
)
@@ -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) => (
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 11L16.606 17.606C16.6841 17.6841 16.6841 17.8107 16.606 17.8888L10 24.4948" stroke="#145AF3" strokeWidth="3.5" />
<path d="M20.1211 24.4946H26.8685" stroke="#145AF3" strokeWidth="3.5" />
<path d="M10 11L16.606 17.606C16.6841 17.6841 16.6841 17.8107 16.606 17.8888L10 24.4948" stroke="#679EFE" strokeWidth="3.5" />
<path d="M20.1211 24.4946H26.8685" stroke="#679EFE" strokeWidth="3.5" />
</svg>
)
@@ -39,6 +39,18 @@ afterEach(() => {
})
describe('Tooltip', () => {
it('updates independent keycaps and the accessible combination while visible', () => {
const view = render(<Tooltip label="Reload" shortcutKeys={['⌘', 'R']}><button>anchor</button></Tooltip>)
fireEvent.focus(screen.getByText('anchor'))
expect(Array.from(screen.getByRole('tooltip', { name: 'Reload ⌘ R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['⌘', 'R'])
view.rerender(<Tooltip label="Reload" shortcutKeys={['Ctrl', '+', 'R']}><button>anchor</button></Tooltip>)
expect(Array.from(screen.getByRole('tooltip', { name: 'Reload Ctrl + R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', '+', 'R'])
view.rerender(<Tooltip label="Reload" shortcutKeys={[]}><button>anchor</button></Tooltip>)
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')
@@ -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: <Tooltip disabled={open} label={shortcut?.keys.length ? t('shortcut.hint', { label: t('trigger'), keys: shortcut.keys.join(' ') }) : t('trigger')}>
}, { fallback: <Tooltip disabled={open} label={t('trigger')} shortcutKeys={shortcut?.keys}>
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
@@ -513,7 +513,7 @@ it('shows the effective settings binding on focus and exposes it to assistive te
const trigger = screen.getByRole('button', { name: 'Settings' })
expect(trigger.getAttribute('aria-keyshortcuts')).toBe('Meta+,')
fireEvent.focus(trigger)
expect(screen.getByRole('tooltip').textContent).toBe('Settings ⌘ ,')
expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe('Settings ⌘ ,')
})
it('passes current Settings key labels to the launcher and removes them when unbound', () => {
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 0cd57b7b8d8c9b59
/deepseek-ai-dsh-client-ui-shortcuts/use-this-package:
en: 527535d67439c5f5
zh: 6eabb96b25c4d080
en: ad39cdf5afe80c9d
zh: f21e5395e7377c23
/deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation:
en: f56f2610e7258825
zh: 249d7cdd55c57166
+4 -4
View File
@@ -25,15 +25,15 @@ Browse the commands available in the current window and find them by action, Eng
<a id="use-this-package"></a>
## 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 keycaps; 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 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 capsule button without a 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.
Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the core list and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups.
Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups.
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. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
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 or focus it with the keyboard to reveal its actions. The key badge highlights only while hovered; selecting it or Edit starts the same inline recorder. Unbound commands omit both remove controls. 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 single border. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. 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. The action is disabled without overrides. 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 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.
-----
+4 -4
View File
@@ -25,15 +25,15 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
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+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。
核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于核心列表末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。
核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 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。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时隐藏计数,并禁用该操作。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。
-----
@@ -167,9 +167,10 @@ EditorProps) {
{target.binding !== null && <button type="button" className={css.inlineAction} disabled={readonly}
onClick={() => { void save({ type: 'set', id: target.id, binding: null }) }}>{t('clear')}</button>}
<button ref={recorder} type="button" className={clsx(css.recorder, message && css.invalid)} disabled={!nativeReady} aria-disabled={busy || !nativeReady}
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 === '' ? <ShortcutKeys keys={describeBinding(candidate).keys} className={css.recorded} /> : t('record')}
{captured && message === '' ? <ShortcutKeys keys={describeBinding(candidate).keys} variant="plain" className={css.recorded} /> : t('record')}
</button>
</div>
<span id={descriptionId} className={css.srOnly}>{message || (desktopChords ? '' : t(runtime === 'web'
@@ -1,7 +1,6 @@
/** Supplied shortcut-control and feedback artwork, colored by the owning UI. */
const artwork = {
edit: <><path d="M7.4553 1.01221C8.02829 0.676881 8.73746 0.676959 9.31048 1.01221C9.52928 1.14026 9.72631 1.34353 9.98255 1.59978C10.2388 1.85601 10.4421 2.05305 10.5701 2.27185C10.9053 2.84485 10.9054 3.55407 10.5701 4.12703C10.4421 4.34576 10.2387 4.54293 9.98255 4.7991L4.99422 9.78744C4.71024 10.0714 4.50494 10.2832 4.2488 10.4324C3.99266 10.5815 3.70709 10.6557 3.32001 10.7625L2.43268 11.0072C2.08118 11.1042 1.75876 11.1948 1.50071 11.2265C1.2387 11.2585 0.884455 11.2512 0.607798 10.9745C0.331166 10.6979 0.323805 10.3436 0.35587 10.0816C0.387501 9.82358 0.47815 9.50112 0.575111 9.14964L0.819864 8.26232C0.926638 7.87525 1.00083 7.58966 1.14992 7.33353C1.2991 7.07739 1.5109 6.87209 1.79489 6.58811L6.78322 1.59978C7.03941 1.34359 7.23656 1.14027 7.4553 1.01221ZM11.6568 11.13H5.6639L6.72503 10.0689H11.6568V11.13ZM2.54509 7.33831C2.22348 7.65993 2.13155 7.75784 2.06755 7.86768C2.0036 7.97757 1.96369 8.10606 1.84272 8.54454L1.59797 9.43187C1.5011 9.78304 1.44118 10.0065 1.4162 10.1653C1.57504 10.1404 1.79876 10.0814 2.15046 9.98436L3.03779 9.7396C3.47631 9.61863 3.60475 9.57874 3.71464 9.51478C3.82448 9.45077 3.92239 9.35885 4.24401 9.03723L8.04047 5.23998L6.34155 3.54106L2.54509 7.33831ZM8.77473 1.92824C8.53277 1.78664 8.233 1.78664 7.99104 1.92824C7.91987 1.97 7.83628 2.04713 7.53343 2.34998L7.09176 2.79085L8.79068 4.48977L9.23235 4.0489C9.53517 3.74608 9.61232 3.66245 9.65409 3.59128C9.79567 3.34935 9.79564 3.04954 9.65409 2.8076C9.61237 2.73635 9.53565 2.65329 9.23235 2.34998C8.92899 2.04662 8.84598 1.96995 8.77473 1.92824Z" /></>,
trash: <><path d="M10.8591 3.6309L10.6608 7.58679C10.5831 9.14078 10.5507 9.759 10.0404 10.471C9.82871 10.7663 9.56281 11.0188 9.25726 11.2155C8.86839 11.4658 8.43152 11.5741 7.91934 11.6252C7.41588 11.6755 6.78752 11.6746 6.00041 11.6746C5.21331 11.6746 4.58495 11.6755 4.08149 11.6252C3.56931 11.5741 3.13244 11.4658 2.74357 11.2155C2.43802 11.0188 2.17212 10.7663 1.96042 10.471C1.45015 9.759 1.41777 9.14078 1.34007 7.58679L1.14177 3.6309L2.16797 3.57998L2.3655 7.5351C2.44708 9.16675 2.49381 9.45152 2.79527 9.87221C2.93161 10.0624 3.10308 10.2255 3.29988 10.3521C3.49696 10.479 3.7524 10.5598 4.18411 10.6029C4.62464 10.6469 5.19266 10.6477 6.00041 10.6477C6.80817 10.6477 7.37618 10.6469 7.81671 10.6029C8.24843 10.5598 8.50387 10.479 8.70095 10.3521C8.89775 10.2255 9.06922 10.0624 9.20556 9.87221C9.50702 9.45152 9.55375 9.16674 9.63533 7.5351L9.83285 3.57998L10.8591 3.6309ZM4.07301 4.67176H5.09998V8.54355H4.07301V4.67176ZM6.90085 4.67176H7.92782V8.54355H6.90085V4.67176ZM6.40241 0.326222C6.88525 0.326222 7.23958 0.320593 7.5729 0.428842C7.67721 0.462737 7.77917 0.504814 7.8769 0.55461C8.1891 0.713729 8.43522 0.968642 8.77656 1.30999L9.37376 1.90642H11.5311V2.93416H0.469727V1.90642H2.62707L3.22427 1.30999C3.56561 0.968643 3.81173 0.713729 4.12393 0.55461C4.22166 0.504814 4.32362 0.462737 4.42793 0.428842C4.76125 0.320593 5.11558 0.326222 5.59842 0.326222H6.40241ZM5.59842 1.35397C5.04948 1.35397 4.88773 1.3593 4.74505 1.40566C4.69201 1.4229 4.64044 1.4444 4.59074 1.4697C4.47341 1.52948 4.36804 1.62229 4.08149 1.90642H7.91934C7.63279 1.62229 7.52742 1.52948 7.41009 1.4697C7.36039 1.4444 7.30882 1.4229 7.25578 1.40566C7.1131 1.3593 6.95135 1.35397 6.40241 1.35397H5.59842Z" /></>,
error: <><path d="M8.90039 3.97328V9.07973H7.09961V3.97328H8.90039Z" /><path d="M8.90039 10.341V12.3654H7.09961V10.341H8.90039Z" /><path d="M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z" /></>,
success: <><path d="M12.5303 6.53027L8.80273 10.2578C8.54967 10.5109 8.31796 10.7439 8.10645 10.9141C7.88375 11.0932 7.616 11.2602 7.27344 11.3145C7.09229 11.3431 6.90771 11.3431 6.72656 11.3145C6.384 11.2602 6.11625 11.0932 5.89355 10.9141C5.68204 10.7439 5.45033 10.5109 5.19727 10.2578L3.46973 8.53027L4.53027 7.46973L6.25781 9.19727C6.53457 9.47402 6.70036 9.63859 6.83398 9.74609C6.95637 9.84453 6.98241 9.83644 6.96094 9.83301C6.98679 9.83709 7.01321 9.83709 7.03906 9.83301C7.01759 9.83644 7.04363 9.84453 7.16602 9.74609C7.29964 9.63859 7.46543 9.47402 7.74219 9.19727L11.4697 5.46973L12.5303 6.53027Z" /><path d="M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z" /></>,
}
@@ -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 <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" className={className} aria-hidden="true">{artwork[kind]}</svg>
}
@@ -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; }
@@ -42,7 +42,6 @@ const coreActionOrder = new Map<string, number>([
'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.
<div className={css.settingTitle}>{t('settings')}</div>
<p className={css.settingDescription}>{t('description')}</p>
</div>
<Tooltip disabled={!shortcut?.keys.length} label={shortcut?.keys.length ? t('hint', { label: t('view'), keys: shortcut.keys.join(' ') }) : t('view')}>
<Tooltip disabled={!shortcut?.keys.length} label={t('global-hint')} shortcutKeys={shortcut?.keys}>
<button className={css.button} type="button" aria-label={t('view')} aria-keyshortcuts={shortcut?.aria}
onClick={() => { actions.open() }}>{t('view')}</button>
</Tooltip>
@@ -114,11 +113,6 @@ export function ShortcutReference({
if (mounted.current) setBusy(false)
return result
}
const remove = async (row: ShortcutCatalogEntry): Promise<void> => {
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<void> => {
/* 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 <><Modal open={open} onClose={closeReference} title={t('title')} headless
shortcutModal="shortcuts" className={css.dialog as string}>
<header className={css.header}>
<h2 className={css.title}>{t('title')}</h2>
<button type="button" className={css.close} aria-label={t('close')} disabled={busy} onClick={closeReference}>
<IconCloseOutlineRegular size={14} />
</button>
</header>
<div className={css.searchRow}>
<div className={css.searchField} role="search" aria-label={t('search')}>
<svg className={css.searchIcon} width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M10.28 5.86536C10.28 3.41381 8.29245 1.42644 5.84094 1.42627C3.38929 1.42627 1.40186 3.4137 1.40186 5.86536C1.40203 8.31686 3.38939 10.3044 5.84094 10.3044C8.29234 10.3043 10.2799 8.31676 10.28 5.86536ZM11.4174 5.86536C11.4172 8.94498 8.92057 11.4416 5.84094 11.4418C2.76117 11.4418 0.263843 8.94509 0.263672 5.86536C0.263672 2.78548 2.76106 0.288086 5.84094 0.288086C8.92067 0.288258 11.4174 2.78559 11.4174 5.86536Z" fill="currentColor" />
<path d="M13.7372 12.9078L12.9323 13.7127L9.9732 10.7536L10.7781 9.94867L13.7372 12.9078Z" fill="currentColor" />
</svg>
<input ref={search} data-modal-autofocus className={css.search} type="search" aria-label={t('search')}
placeholder={t('search')} value={query} onChange={(event) => { actions.search(event.target.value) }} />
<div className={css.contents} onPointerDownCapture={(event) => {
if (target === null || busy || !(event.target instanceof Element) || event.target.closest('button, input, a, [contenteditable="true"]') !== null) return
event.preventDefault()
closeEditor()
}}>
<header className={css.header}>
<h2 className={css.title}>{t('title')}</h2>
<button type="button" className={css.close} aria-label={t('close')} disabled={busy} onClick={closeReference}>
<IconCloseOutlineRegular size={14} />
</button>
</header>
<div className={css.searchRow}>
<div className={css.searchField} role="search" aria-label={t('search')}>
<svg className={css.searchIcon} width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M10.28 5.86536C10.28 3.41381 8.29245 1.42644 5.84094 1.42627C3.38929 1.42627 1.40186 3.4137 1.40186 5.86536C1.40203 8.31686 3.38939 10.3044 5.84094 10.3044C8.29234 10.3043 10.2799 8.31676 10.28 5.86536ZM11.4174 5.86536C11.4172 8.94498 8.92057 11.4416 5.84094 11.4418C2.76117 11.4418 0.263843 8.94509 0.263672 5.86536C0.263672 2.78548 2.76106 0.288086 5.84094 0.288086C8.92067 0.288258 11.4174 2.78559 11.4174 5.86536Z" fill="currentColor" />
<path d="M13.7372 12.9078L12.9323 13.7127L9.9732 10.7536L10.7781 9.94867L13.7372 12.9078Z" fill="currentColor" />
</svg>
<input ref={search} data-modal-autofocus className={css.search} type="search" aria-label={t('search')}
placeholder={t('search')} value={query} onChange={(event) => { actions.search(event.target.value) }} />
{query !== '' && <button type="button" className={css.clearSearch} aria-label={t('clear-search')}
onClick={() => { actions.search(''); search.current?.focus() }}><IconCloseOutlineRegular size={10} /></button>}
</div>
</div>
<div className={css.list} aria-busy={config.status === 'loading'}>
{(['application', 'input', 'menus', 'approval'] as const).map((group) => {
const rows = matches.filter(row => row.group === group)
if (rows.length === 0) return null
return <section key={group} aria-label={t(group)}>
{group !== 'application' && <h3 className={css.group}>{t(group)}</h3>}
<ul className={css.rows}>{rows.map(row => <li key={row.id} className={css.row}>
{'modified' in row && target?.id !== row.id && <button type="button" className={css.rowButton}
aria-label={t('edit-label', { command: row.label })} disabled={busy || config.status !== 'ready'}
onClick={() => { setTarget(row) }} />}
<span className={css.commandLabel}>{row.label}</span>
<span className={css.binding}>
{'modified' in row
? target?.id === row.id
? <ShortcutEditor key={row.id} target={row} {...editorProps} />
: <>
<span className={css.rowActions}><ShortcutIcon kind="edit" /></span>
{row.keys.length === 0 ? <span className={css.unbound}>{t('unbound')}</span> : <ShortcutKeys keys={row.keys} variant="plain" className={css.keyBadge} />}
</>
: <ShortcutKeys keys={row.keys} variant="plain" className={css.fixedKeyBadge} />}
</span>
</li>)}</ul>
</section>
})}
{matches.length === 0 && <p className={css.hint} role="status">{t('empty')}</p>}
</div>
<footer className={css.footer}>
<button type="button" className={css.resetAll} disabled={busy || config.status !== 'ready' || modifiedCount === 0}
onClick={(event) => { event.currentTarget.focus(); setTarget(null); setResetRevision(config.revision) }}>
<IconRefreshOutlineRegular size={12} />{t('reset-all')}
</button>
{modifiedCount > 0 && <span className={css.modifiedCount}>{t('modified-count', { count: modifiedCount })}</span>}
</footer>
</div>
<div className={css.list} aria-busy={config.status === 'loading'}>
{(['application', 'input', 'menus', 'approval'] as const).map((group) => {
const rows = matches.filter(row => row.group === group)
if (rows.length === 0) return null
return <section key={group} aria-label={t(group)}>
{group !== 'application' && <h3 className={css.group}>{t(group)}</h3>}
<ul className={css.rows}>{rows.map(row => <li key={row.id} className={css.row}>
<span className={css.commandLabel}>{row.label}</span>
<span className={css.binding}>
{'modified' in row
? target?.id === row.id
? <ShortcutEditor key={row.id} target={row} {...editorProps} />
: <>
<span className={css.rowActions}>
{row.binding !== null && <button type="button" className={css.iconButton} aria-label={t('remove-label', { command: row.label })}
disabled={busy || config.status !== 'ready'} onClick={() => { void remove(row) }}><ShortcutIcon kind="trash" /></button>}
<button type="button" className={css.iconButton}
aria-label={t('edit-label', { command: row.label })} disabled={busy || config.status !== 'ready'}
onClick={() => { setTarget(row) }}><ShortcutIcon kind="edit" /></button>
</span>
<button type="button" className={css.keyButton} disabled={busy || config.status !== 'ready'}
aria-label={t('hint', { label: row.label, keys: row.keys.length === 0 ? t('unbound') : row.keys.join(' ') })}
onClick={() => { setTarget(row) }}>
{row.keys.length === 0 ? <span className={css.unbound}>{t('unbound')}</span> : <ShortcutKeys keys={row.keys} className={css.keyBadge} />}
</button>
</>
: <button type="button" className={css.keyButton} disabled
aria-label={t('hint', { label: row.label, keys: row.keys.join(' ') })}>
<ShortcutKeys keys={row.keys} className={css.keyBadge} />
</button>}
</span>
</li>)}</ul>
</section>
})}
{matches.length === 0 && <p className={css.hint} role="status">{t('empty')}</p>}
</div>
<footer className={css.footer}>
<button type="button" className={css.resetAll} disabled={busy || config.status !== 'ready' || modifiedCount === 0}
onClick={(event) => { event.currentTarget.focus(); setTarget(null); setResetRevision(config.revision) }}>
<IconRefreshOutlineRegular size={16} />{t('reset-all')}
</button>
<span className={css.modifiedCount}>{t('modified-count', { count: modifiedCount })}</span>
</footer>
</Modal>
<Modal open={open && resetRevision !== null} title={t('reset-title')} description={t('reset-description')}
closeLabel={t('close-confirmation')} onClose={() => { if (!busy) setResetRevision(null) }}
@@ -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<keyof typeof zh, string> = {
'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<keyof typeof zh, string> = {
'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',
@@ -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')
@@ -38,7 +38,8 @@ it('keeps a reference open while a clear operation is pending', async () => {
const f = referenceFixture()
const pending = Promise.withResolvers<Awaited<ReturnType<typeof f.edit>>>()
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<typeof ShortcutReference>[0]
render(<><ShortcutsRow {...props} /><ShortcutReference {...props} /></>)
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<ReturnType<typeof f.edit>>) => void
const reply = new Promise<Awaited<ReturnType<typeof f.edit>>>((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()
})
@@ -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
+1 -1
View File
@@ -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.
@@ -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;只有点击恢复或刷新才打开该地址。
@@ -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 (
<svg width={size} height={size} className={className} aria-hidden="true" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9995 28.1465C23.6034 28.1465 28.1461 23.6038 28.1461 18C28.1461 12.3963 23.6034 7.85352 17.9995 7.85352C12.3958 7.85352 7.85303 12.3963 7.85303 18C7.85303 23.6038 12.3958 28.1465 17.9995 28.1465Z" stroke="#539CFA" strokeWidth="2" />
<path d="M8.57764 18H27.4211" stroke="#539CFA" strokeWidth="2" strokeLinecap="square" />
<path d="M17.999 28.1467C20.0576 28.1467 21.6228 23.6039 21.6228 18C21.6228 12.3963 20.0576 7.85352 17.999 7.85352" stroke="#539CFA" strokeWidth="2" />
<path d="M17.9992 28.1467C15.9407 28.1467 14.3755 23.6039 14.3755 18C14.3755 12.3963 15.9407 7.85352 17.9992 7.85352" stroke="#539CFA" strokeWidth="2" />
</svg>
)
}
/** 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,
}],
}
}
@@ -81,7 +81,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
<form className={css.toolbar} onSubmit={submit}>
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.tool} aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
</Tooltip>
<div className={css.addressBox}>
@@ -310,7 +310,7 @@ export function TextPreview({
</span>
{/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */}
{/* jscpd:ignore-start */}
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
@@ -15,8 +15,8 @@
en: 9411fb4809692e09
zh: dd13990d39c5be7f
/deepseek-ai-dsh-client-ui-sidebar-files/what-it-registers:
en: 72afb24f7aa8cbb7
zh: 17baab21526fe175
en: a13aadd9b0294870
zh: d30dc67f177f6c2d
/deepseek-ai-dsh-client-ui-sidebar-files/the-tree:
en: a0ae0b1f223b3df1
zh: 8ddc325519bd75b2
+1 -1
View File
@@ -26,7 +26,7 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a
The `workspace.files` command opens or focuses the file page in the focused pane; from the conversation it uses the current Session's active dock pane. A guide gives way to the file page, and repeated opens retain one file page per pane. Desktop defaults to Mod+P. The guide entry displays the effective shortcut; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves this command unbound by default.
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph a yellow folder) that opens the type.
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right.
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
@@ -26,7 +26,7 @@ kind: "package-reference"
命令 `workspace.files` 在焦点分栏内打开或聚焦文件页;从聊天区触发时使用当前会话的活动停靠分栏。文件页替换开始页,同一分栏内重复打开仍保留一个文件页。桌面默认键为 Mod+P。开始页入口显示有效快捷键;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标为黄色文件夹)。
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
@@ -198,7 +198,7 @@ export function FilesBody({
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</span>
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
@@ -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 { FileTypeIcon, type IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/** The tab kind this package owns. */
export const FILES_KIND = 'files'
@@ -17,9 +17,14 @@ 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'
/** The type's coloured folder sheet at the guide capsule's glyph size, as the chip title draws it. */
function FolderSheetGlyph({ size, className }: IconProps) {
return <FileTypeIcon kind="folder" size={size} className={className} />
/** Yellow folder artwork for the guide entry. */
function FolderSheetGlyph({ size = 36, className }: IconProps) {
return (
<svg width={size} height={size} className={className} aria-hidden="true" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7603 27.922H24.6817C26.3441 27.922 27.1753 27.922 27.8102 27.5984C28.3687 27.3139 28.8228 26.8598 29.1074 26.3012C29.4309 25.6663 29.4309 24.8351 29.4309 23.1727V15.4936" stroke="#FFCD78" strokeWidth="1.97886" />
<path d="M13.1597 8.07812C13.4182 8.07818 13.6727 8.14336 13.8989 8.26855L16.7554 9.84961C16.9817 9.97485 17.2369 10.041 17.4956 10.041H26.106C26.9492 10.0412 27.6323 10.7251 27.6323 11.5684V24.5371C27.6323 25.3805 26.9483 26.0645 26.105 26.0645H8.09619C7.25281 26.0645 6.56884 25.3805 6.56885 24.5371V9.60449C6.56909 8.76133 7.25297 8.07812 8.09619 8.07812H13.1597ZM9.81592 14.5508V16.5293H24.3999V14.5508H9.81592Z" fill="#FFBC4D" />
</svg>
)
}
/**
@@ -21,8 +21,8 @@
en: 6d7f145ecb1fd140
zh: 666d142ef249f6cb
/deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button:
en: db83aaf5415e988f
zh: 8d8af5b3a504f17c
en: 28b0bc3d4050b0e0
zh: 733b44ad9309f49a
/deepseek-ai-dsh-client-ui-sidebar-right/state:
en: 2b15cdf5f1383291
zh: 57018036d77363ba
@@ -36,8 +36,8 @@
en: 717b86e05b44ebdd
zh: 0f4f694d0ea1d7f8
/deepseek-ai-dsh-client-ui-sidebar-right/the-guide:
en: 937225e990fdf48e
zh: bf28954a0f9b8338
en: 601f9f6635babc4d
zh: e345004fe30a65b9
/deepseek-ai-dsh-client-ui-sidebar-right/copy:
en: 4315a1c03ca5fef3
zh: c09a09f1d9b99107
+2 -2
View File
@@ -52,7 +52,7 @@ The panel has no header row. Its two controls — the presentation switch and th
<a id="the-expand-button"></a>
## The expand button
The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip shows the current binding and explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective keycaps inline without duplicate tooltips.
The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip shows the current binding and explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective shortcuts as unboxed text inline without duplicate tooltips.
While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It uses the shared compact Button, matching the adjacent More action’s radius and hover fill. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel.
@@ -112,7 +112,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod
<a id="the-guide"></a>
## The guide
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Shortcut labels align at the right, with space reserved for the terminal shell menu.
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title.
A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
@@ -52,7 +52,7 @@ kind: "package-reference"
<a id="the-expand-button"></a>
## 展开按钮
快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示显示当前键位,并说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内显示有效键帽,不额外弹出重复提示。
快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示显示当前键位,并说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内以无背景文字显示有效快捷键,不额外弹出重复提示。
面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它使用公共紧凑 Button,与相邻更多操作按钮共用圆角和 hover 底色。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
@@ -112,7 +112,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
<a id="the-guide"></a>
## 引导页
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。快捷键标签右对齐,并为终端 Shell 菜单预留空间。
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。
终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
@@ -20,17 +20,18 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
* @returns every string the kit renders.
*/
export function dockLabels(t: TranslateNS<'sidebarRight'>, split?: ShortcutCatalogEntry, close?: ShortcutCatalogEntry): DockLabels {
const splitHint = (label: string) => split?.keys.length ? t('shortcut.hint', { label, keys: split.keys.join(' ') }) : label
return {
emptyPane: t('dock.emptyPane'),
splitPane: t('dock.splitPane'),
splitPaneTooltip: splitHint(t('dock.splitPane')),
splitPaneTooltip: t('dock.splitPane'),
splitPaneShortcut: split?.aria,
splitPaneDisabled: splitHint(t('dock.splitPaneDisabled')),
splitPaneNarrow: splitHint(t('dock.splitPaneNarrow')),
splitPaneKeys: split?.keys,
splitPaneDisabled: t('dock.splitPaneDisabled'),
splitPaneNarrow: t('dock.splitPaneNarrow'),
closeTab: t('dock.closeTab'),
closeTabTooltip: close?.keys.length ? t('shortcut.hint', { label: t('dock.closeTab'), keys: close.keys.join(' ') }) : t('dock.closeTab'),
closeTabTooltip: t('dock.closeTab'),
closeTabShortcut: close?.aria,
closeTabKeys: close?.keys,
addTab: t('dock.addTab'),
dockFloat: t('dock.dockFloat'),
closeFloat: t('dock.closeFloat'),
@@ -37,7 +37,7 @@ export function ExpandButton({ sessionId, useStore, actions, t, useShortcuts }:
const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false)
if (expanded) return null
return (
<Tooltip label={shortcut?.keys.length ? t('shortcut.hint', { label: t('chrome.expand'), keys: shortcut.keys.join(' ') }) : t('chrome.expand')} side="bottom" delayMs={500}>
<Tooltip label={t('chrome.expand')} shortcutKeys={shortcut?.keys} side="bottom" delayMs={500}>
<Button
size="sm"
className={css.button}
@@ -274,7 +274,7 @@ function PanelChrome({ sessionId, fullscreen, actions, t, shortcuts, toggleFulls
const toggle = shortcuts.find(entry => entry.id === 'sidebar.right.toggle')
return (
<>
<Tooltip label={mode?.keys.length ? t('shortcut.hint', { label: modeLabel, keys: mode.keys.join(' ') }) : modeLabel} side="bottom" delayMs={500}>
<Tooltip label={modeLabel} shortcutKeys={mode?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.iconButton}
@@ -286,7 +286,7 @@ function PanelChrome({ sessionId, fullscreen, actions, t, shortcuts, toggleFulls
{fullscreen ? <ExitFullscreenGlyph /> : <FullscreenGlyph />}
</button>
</Tooltip>
<Tooltip label={toggle?.keys.length ? t('shortcut.hint', { label: t('chrome.collapse'), keys: toggle.keys.join(' ') }) : t('chrome.collapse')} side="bottom" delayMs={500}>
<Tooltip label={t('chrome.collapse')} shortcutKeys={toggle?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.iconButton}
@@ -52,7 +52,7 @@
width: 380px;
max-width: 100%;
min-height: 56px;
padding: 14px 64px 14px 20px;
padding: 14px 20px;
color: var(--dsw-alias-label-primary);
font: inherit;
text-align: left;
@@ -106,8 +106,8 @@
/* One line under the title on the quieter ink; it ellipsizes like the title. */
.entryDescription {
overflow: hidden;
color: var(--dsw-alias-label-caption);
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
text-overflow: ellipsis;
@@ -69,7 +69,7 @@ function EntryBox({ entry, described, onPick, shortcut }: {
<span className={css.entryTitle}>{entry.title()}</span>
{description !== undefined && <span className={css.entryDescription}>{description}</span>}
</span>
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys keys={shortcut.keys} />}
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys variant="plain" keys={shortcut.keys} />}
</button>
)
}
@@ -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', () => {
@@ -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')
})
@@ -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
@@ -25,9 +25,9 @@ Choose an installed shell from the right sidebar's Start page to run commands in
<a id="use-this-package"></a>
## 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
<details>
<summary>Implementation internals — click to expand</summary>
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.
@@ -25,9 +25,9 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
命令 `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"
<details>
<summary>实现细节</summary>
插件向右侧栏注册 `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 只停止浏览器工作。
@@ -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; }
@@ -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 <div className={css.entry} data-sidebar-right-guide-entry={kind}>
<span className={css.mainAnchor}>
<Button variant="ghost" className={css.main} aria-keyshortcuts={shortcut?.aria} onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }}>
<TerminalGuideIcon size={description === undefined ? 22 : 26} className={css.icon} />
<span className={css.text}>
<span className={css.title}>{title}</span>
{description !== undefined && <span className={css.description}>{description}</span>}
</span>
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys keys={shortcut.keys} />}
</Button>
<Button variant="ghost" className={css.main} aria-label={description === undefined ? title : `${title} ${description}`} aria-keyshortcuts={shortcut?.aria}
onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }} />
<span className={css.icon} aria-hidden="true"><PluginArtworkTerminal size={description === undefined ? 22 : 26} /></span>
<span className={css.text}>
<span className={css.titleRow}>
<span className={css.title} aria-hidden="true">{title}</span>
<Menu
open={open} portal autoFocus align="end" className={css.menu}
items={items.length === 0 ? [{ id: 'empty', label: t('shellEmpty'), disabled: true }] : items}
selectedId={state.phase === 'ready' ? state.choices.selectedShell : undefined}
onClose={() => { 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={<Button variant="ghost" className={css.trigger} aria-label={t('shell')} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setState({ phase: 'loading' }); setOpen(value => !value) }}>
<IconChevronDownOutlineRegular />
</Button>}
/>
</span>
{description !== undefined && <span className={css.description} aria-hidden="true">{description}</span>}
</span>
<Menu
open={open} portal autoFocus align="end" className={css.menu}
items={items.length === 0 ? [{ id: 'empty', label: t('shellEmpty'), disabled: true }] : items}
selectedId={state.phase === 'ready' ? state.choices.selectedShell : undefined}
onClose={() => { 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={<Button variant="ghost" className={css.trigger} aria-label={t('shell')} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setState({ phase: 'loading' }); setOpen(value => !value) }}>
<IconChevronDownOutlineRegular />
</Button>}
/>
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys variant="plain" keys={shortcut.keys} className={css.shortcut} />}
</div>
}
@@ -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 {
<path d="M9 12H13" stroke="currentColor" />
</svg>
}
/**
* 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 <svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" aria-hidden="true">
<path d="M22 5H6C4.34315 5 3 6.34315 3 8V21C3 22.6569 4.34315 24 6 24H22C23.6569 24 25 22.6569 25 21V8C25 6.34315 23.6569 5 22 5Z" fill="#17191D" />
<path d="M8 10L12 14L8 18M15 18H17.5H20" stroke="white" strokeWidth="1.7" strokeLinejoin="round" />
</svg>
}
@@ -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))
@@ -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([
@@ -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' }))
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`。
### 全局面板入口
@@ -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 (
<div className={css.controls}>
<Tooltip label={shortcut?.keys.length ? t('shortcut.hint', { label: t('toggle.open'), keys: shortcut.keys.join(' ') }) : t('toggle.open')} delayMs={500}>
<Tooltip label={t('toggle.open')} shortcutKeys={shortcut?.keys} delayMs={500}>
<button
type="button"
className={css.iconButton}
@@ -40,7 +39,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, useShortcut
<IconPanelLeftOutlineRegular size={16} />
</button>
</Tooltip>
<Tooltip label={newHint} delayMs={500}>
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
<button
type="button"
className={css.iconButton}
@@ -385,6 +385,7 @@
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
corners); collapsed it renders as the rail's plain icon control. */
.newSession {
position: relative;
flex: none;
display: flex;
align-items: center;
@@ -453,6 +454,20 @@
max-width: 0;
}
.newSessionContent { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.newSessionShortcut {
position: absolute;
right: 12px;
display: inline-flex;
opacity: 0;
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;
@@ -16,10 +16,10 @@
* scrollbar indirection away while it is elsewhere, so a list the user is not
* pointing at carries no bar.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo, IconNewChatOutlineMedium, IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip,
FishLogo, IconNewChatOutlineMedium, IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, ShortcutKeys, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {
@@ -100,8 +100,12 @@ export function SidebarRoot({
const panels = usePanels(snapshot => snapshot)
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')
const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse')
const newSessionButton = useRef<HTMLButtonElement>(null)
const newSessionContent = useRef<HTMLSpanElement>(null)
const newSessionKeys = useRef<HTMLSpanElement>(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)
@@ -116,6 +120,23 @@ 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
@@ -178,7 +199,7 @@ export function SidebarRoot({
// (the expand affordance, figma sidebar-hover flow). Expanded it is a plain
// panel icon.
const toggle = (
<Tooltip label={shortcut?.keys.length ? t('shortcut.hint', { label: toggleLabel, keys: shortcut.keys.join(' ') }) : toggleLabel} delayMs={500} side={captionTooltipSide}>
<Tooltip label={toggleLabel} shortcutKeys={shortcut?.keys} delayMs={500} side={captionTooltipSide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
@@ -243,7 +264,7 @@ export function SidebarRoot({
return darwinDesktop
? <span className={clsx(css.brand, css.wide)}>{identity}</span>
: (
<Tooltip label={newHint} delayMs={500}>
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
<button
type="button"
className={clsx(css.brand, css.wide)}
@@ -259,21 +280,26 @@ export function SidebarRoot({
{!darwinDesktop && toggle}
</div>
{/* Expanded, the button carries its own label — tooltip only on the rail. */}
<Tooltip label={newHint} delayMs={500} side={captionTooltipSide}>
{/* The centered action uses inline keys only when its trailing space fits them. */}
<Tooltip label={wide ? '' : t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500} side={wide ? 'bottom' : captionTooltipSide} disabled={wide && (newShortcutFits || !newShortcut?.keys.length)}>
<button
type="button"
className={css.newSession}
ref={newSessionButton}
data-shortcut-inline={newShortcutFits || undefined}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={() => { startSession() }}
>
{/* The rail draws Regular: Medium's 1.3px stroke scaled to the rail's
larger glyph reads visibly heavier than the neighboring 1px icons. */}
{wide
? <IconNewChatOutlineMedium size={14} />
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
<span ref={newSessionContent} className={css.newSessionContent}>
{wide
? <IconNewChatOutlineMedium size={14} />
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
</span>
{wide && newShortcut !== undefined && newShortcut.keys.length > 0 && <span ref={newSessionKeys} className={css.newSessionShortcut} aria-hidden="true">
<ShortcutKeys keys={newShortcut.keys} variant="plain" />
</span>}
</button>
</Tooltip>
@@ -40,16 +40,20 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
class="newSession"
type="button"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="16"
stroke-width="1"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionContent"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="16"
stroke-width="1"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
/>
</span>
</button>
<div
class="regionArea"
@@ -170,20 +174,24 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
class="newSession"
type="button"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
class="newSessionContent"
>
New Session
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
>
New Session
</span>
</span>
</button>
<div
@@ -276,16 +284,20 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
class="newSession"
type="button"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="18"
stroke-width="1"
viewBox="0 0 16 16"
width="18"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionContent"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="18"
stroke-width="1"
viewBox="0 0 16 16"
width="18"
xmlns="http://www.w3.org/2000/svg"
/>
</span>
</button>
<div
class="regionArea"
@@ -406,20 +418,24 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
class="newSession"
type="button"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
class="newSessionContent"
>
New Session
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
>
New Session
</span>
</span>
</button>
<div
@@ -541,20 +557,24 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
class="newSession"
type="button"
>
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
class="newSessionContent"
>
新会话
<svg
aria-hidden="true"
data-content="47b5e433"
fill="none"
height="14"
stroke-width="1.3"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
>
新会话
</span>
</span>
</button>
<div
@@ -106,6 +106,11 @@ describe('SidebarRoot shell', () => {
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(<HeaderLeadingControls toggleSidebar={vi.fn()} startSession={vi.fn()} selectPanel={vi.fn()} t={t}
usePanelInfo={neverHook} useSessions={neverHook} useSessionStatus={neverHook}
@@ -114,7 +119,7 @@ describe('SidebarRoot shell', () => {
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'])
})
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
@@ -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);
@@ -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"
]
@@ -294,7 +294,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
)}
/>
)}
<Tooltip label={newShortcut?.keys.length ? t('shortcut.hint', { label: t('actions.newSession'), keys: newShortcut.keys.join(' ') }) : t('actions.newSession')} side="bottom" align="end" delayMs={500}>
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500}>
<button
type="button"
className={css.iconButton}
@@ -870,8 +870,6 @@ export function WorkspaceBrowser({
const searchShortcut = shortcuts.find(row => row.id === 'session.search')
const addShortcut = shortcuts.find(row => row.id === 'workspace.add')
const shortcutState = useWorkspaceShortcuts(state => state)
const hint = (label: string, keys: readonly string[] | undefined) => keys?.length
? t('shortcut.hint', { label, keys: keys.join(' ') }) : label
// Ordering remains live while the rail or search replaces the list body.
const list = useSessions(state => state)
const storedWorkspaces = useWorkspaces(state => state.items)
@@ -1225,7 +1223,7 @@ export function WorkspaceBrowser({
searchInput.current?.focus()
}}
>
<Tooltip label={hint(t('search'), searchShortcut?.keys)} side="bottom" delayMs={500} disabled={searchExpanded}>
<Tooltip label={t('search')} shortcutKeys={searchShortcut?.keys} side="bottom" delayMs={500} disabled={searchExpanded}>
<button
type="button"
className={css.searchButton}
@@ -1287,7 +1285,7 @@ export function WorkspaceBrowser({
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={hint(t('workspace.add'), addShortcut?.keys)} side="bottom" delayMs={500}>
<Tooltip label={t('workspace.add')} shortcutKeys={addShortcut?.keys} side="bottom" delayMs={500}>
<button
ref={wsPlusRef}
type="button"
@@ -1325,7 +1323,7 @@ export function WorkspaceBrowser({
{/* The collapsed rail keeps search as its own 36px control. */}
{!wide && <div className={css.search}>
<Tooltip label={hint(t('search'), searchShortcut?.keys)}>
<Tooltip label={t('search')} shortcutKeys={searchShortcut?.keys}>
<button
type="button"
className={css.searchButton}