浏览代码

fix(web): align terminal launcher controls and guide appearance

Yichen Jiang 1 月之前
父节点
当前提交
11b2114786

+ 1 - 3
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -1,5 +1,3 @@
 - text: Shell
-- combobox "Shell":
-  - option "bash — /bin/bash" [selected]
-  - option "sh — /bin/sh"
+- button "Shell": bash — /bin/bash
 - button "Start terminal"

+ 5 - 0
apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md

@@ -0,0 +1,5 @@
+- menu:
+  - menuitem "bash — /bin/bash":
+    - text: bash — /bin/bash
+    - img
+  - menuitem "sh — /bin/sh"

+ 12 - 5
apps/web/tests/sidebar-terminal.e2e.ts

@@ -161,14 +161,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
     await page.locator('[data-sidebar-right-expand]').click()
-    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
-    const selector = page.getByRole('combobox', { name: 'Shell', exact: true })
+    const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+    expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace')
+    await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
+    await entry.click()
+    const selector = page.getByRole('button', { name: 'Shell', exact: true })
     await selector.waitFor()
-    expect(await selector.inputValue()).toBe('/bin/bash')
+    expect(await selector.innerText()).toContain('bash — /bin/bash')
     expect(handles).toHaveLength(0)
     await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
       await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
-    await selector.selectOption('/bin/sh')
+    await selector.click()
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
+      await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
+    await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
+    await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click()
     expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
     await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
     await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
@@ -184,7 +191,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     await page.locator('[data-dockkit-add-tab]').click()
     await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
     await selector.waitFor()
-    expect(await selector.inputValue()).toBe('/bin/sh')
+    expect(await selector.innerText()).toContain('sh — /bin/sh')
     expect(handles).toHaveLength(1)
     await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
     await expect.poll(() => handles.length).toBe(2)

+ 2 - 2
packages/client/ui-sidebar-terminal/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: d36e42ccea3a4b419231e39c1c6e8d5b3bf754aa
-README.zh.md: 1493828388e82c24d5d3a95a8a2e1dc379f152cf
+README.md: dec4c0a88649b74e90afa23ef07d3f11a9dc67cb
+README.zh.md: a10cde602ad4edfd13652a93535c4f5d7fd26b52

+ 2 - 2
packages/client/ui-sidebar-terminal/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration.
+Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration.
 
 ## Table of Contents
 
@@ -39,7 +39,7 @@ Displaying a Session after a page reload reopens its retained Host terminals as
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon 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 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. xterm.js and FitAddon 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.
 
 A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; 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.
 

+ 2 - 2
packages/client/ui-sidebar-terminal/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。
+从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。
 
 ## 目录
 
@@ -39,7 +39,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节</summary>
 
-插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
+插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
 
 Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
 

+ 6 - 4
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -4,9 +4,11 @@
 .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; }
 .screen :global(.xterm) { height: 100%; }
 .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); }
-.root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
-.root button:hover { background: var(--dsw-alias-bg-l2); }
+.status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
+.status button:hover { background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
 .launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; }
-.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
-.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); }
+.shellField { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
+.shellMenu { width: 100%; min-width: 0; }
+.shellTrigger { width: 100%; min-width: 0; justify-content: space-between; gap: 8px; }
+.shellValue { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

+ 34 - 10
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx

@@ -1,5 +1,6 @@
 /** Sidebar terminal screen and connection recovery. */
-import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
+import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import { Button, Menu, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
@@ -34,15 +35,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP
   const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
   return (
     <section className={css.root} data-sidebar-terminal>
-      {state.phase === 'selecting' && <form className={css.launch} onSubmit={(event) => { event.preventDefault(); void model.start() }}>
-        <label className={css.shellLabel}>
-          {t('shell')}
-          <select aria-label={t('shell')} value={state.selectedShell ?? ''} onChange={(event) => { model.selectShell(event.currentTarget.value) }}>
-            {state.shells?.map(shell => <option key={shell.path} value={shell.path}>{shell.name} — {shell.path}</option>)}
-          </select>
-        </label>
-        <button type="submit" disabled={state.selectedShell === undefined}>{t('start')}</button>
-      </form>}
+      {state.phase === 'selecting' && <TerminalLauncher state={state} model={model} t={t} />}
       {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
         {status}
         {readOnly && <>{t('readonly')} <button type="button" onClick={() => { model.connect() }}>{t('control')}</button></>}
@@ -56,6 +49,37 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP
   )
 }
 
+function TerminalLauncher({ state, model, t }: {
+  state: TerminalViewState
+  model: TerminalView
+  t: TerminalBodyProps['t']
+}): ReactNode {
+  const [open, setOpen] = useState(false)
+  const selectionId = useId()
+  const selected = state.shells?.find(shell => shell.path === state.selectedShell)
+  return <form className={css.launch} onSubmit={(event) => { event.preventDefault(); void model.start() }}>
+    <div className={css.shellField}>
+      <span>{t('shell')}</span>
+      <Menu
+        open={open} autoFocus portal className={css.shellMenu}
+        items={state.shells?.map(shell => ({ id: shell.path, label: `${shell.name} — ${shell.path}` })) ?? []}
+        selectedId={state.selectedShell}
+        onClose={() => { setOpen(false) }}
+        onSelect={(path) => { model.selectShell(path); setOpen(false) }}
+        anchor={<Button
+          variant="outline" className={css.shellTrigger}
+          aria-label={t('shell')} aria-describedby={selectionId} aria-haspopup="menu" aria-expanded={open}
+          disabled={selected === undefined} onClick={() => { setOpen(value => !value) }}
+        >
+          <span id={selectionId} className={css.shellValue}>{selected === undefined ? t('shell') : `${selected.name} — ${selected.path}`}</span>
+          <span aria-hidden="true"><IconChevronDownOutline14 /></span>
+        </Button>}
+      />
+    </div>
+    <Button type="submit" variant="outline" disabled={selected === undefined}>{t('start')}</Button>
+  </form>
+}
+
 /* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */
 function TerminalScreen({ state, model, visible, label }: {
   state: TerminalViewState

+ 18 - 2
packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx

@@ -1,9 +1,25 @@
-/** Terminal prompt glyph shared by the guide and tab title. */
+/** Terminal glyphs for the sidebar guide and tab title. */
 import type { ReactNode } from 'react'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 
-/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */
+/**
+ * Render the tab title's terminal prompt in the surrounding text color.
+ * @returns a decorative sixteen-pixel line glyph.
+ */
 export function TerminalIcon(): ReactNode {
   return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
     <path d="m3 4 4 4-4 4M9 12h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
   </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">
+    <rect x="3" y="5" width="22" height="19" rx="3" fill="#17191d" />
+    <path d="m8 10 4 4-4 4M15 18h5" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
+  </svg>
+}

+ 2 - 2
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 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 { TerminalIcon } from './TerminalIcon.tsx'
+import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import { TerminalBody } from './TerminalBody.tsx'
 import { TerminalTitle } from './TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
@@ -38,7 +38,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: [{ order: 20, title: () => t('new'), icon: TerminalIcon }],
+    guide: [{ order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }],
   }), 'ui-sidebar-terminal.type')
   ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => {
     ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id))

+ 2 - 2
packages/client/ui-sidebar-terminal/src/client/locales.ts

@@ -10,7 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Simplified Chinese terminal copy. */
 export const zh = {
   recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端',
-  shell: 'Shell', start: '启动终端',
+  shell: 'Shell', start: '启动终端', description: '在会话工作区运行命令',
   title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…',
   connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接',
   readonly: '此页面当前只读。', control: '接管输入',
@@ -27,7 +27,7 @@ export const zh = {
 /** English terminal copy. */
 export const en = {
   recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery',
-  shell: 'Shell', start: 'Start terminal',
+  shell: 'Shell', start: 'Start terminal', description: 'Run commands in the Session workspace',
   title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…',
   connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect',
   readonly: 'This view is read-only.', control: 'Take control',

+ 7 - 1
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -1,5 +1,7 @@
 // @vitest-environment jsdom
 /** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
 import { Context } from '@deepseek-ai/cordis'
 import { expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -69,7 +71,11 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
   try {
     const definition = h.tabs.get('terminal')!
     expect(definition.title('sidebar://terminal')).toBe('title')
-    expect(definition.guide?.map(entry => [entry.order, entry.title()])).toEqual([[20, 'new']])
+    expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])).toEqual([[20, 'new', 'description']])
+    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(definition.multiple).toBe(true)
     expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh })
     expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([

+ 10 - 2
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -218,6 +218,7 @@ it.each(titleSurfaces)('shows saved and attached terminal names in the $name', (
   title.rerender(chip())
   expect(title.container.textContent).toBe('bash')
   expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
+  expect(title.container.querySelector('svg rect')).toBeNull()
 })
 
 it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => {
@@ -314,9 +315,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho
 it('lets the user select an installed shell and start it before rendering a screen', () => {
   const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] }
   const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path })
-  expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path)
+  const selector = h.view.getByRole('button', { name: 'Shell' })
+  expect(selector.textContent).toContain(other.path)
   expect(fake.terminals).toHaveLength(0)
-  fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } })
+  fireEvent.click(selector)
+  expect(h.view.getByRole('menu')).toBeTruthy()
+  fireEvent.keyDown(document, { key: 'Escape' })
+  expect(h.view.queryByRole('menu')).toBeNull()
+  fireEvent.click(selector)
+  fireEvent.click(h.view.getByRole('menuitem', { name: `bash — ${info.shell.path}` }))
+  expect(h.view.queryByRole('menu')).toBeNull()
   expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path)
   fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' }))
   expect(h.model.start).toHaveBeenCalledOnce()