fix(ui): polish session hints, task menus and plugin examples

This commit is contained in:
Yif committed 2026-09-24 19:40:02 +08:00
1 parent 4611e32e64
commit 0701068a31
47 files changed
+1205 -143

No files matched your search

@@ -0,0 +1,35 @@
# Background jobs above the Browser dock
```json
{
"collapsed": {
"dockMode": "push",
"bodyMounted": true,
"fixed": true,
"anchored": true,
"viewportBounded": true,
"overlapsBrowser": true,
"aboveBrowser": true
},
"expanded": {
"dockMode": "push",
"bodyMounted": true,
"fixed": true,
"anchored": true,
"viewportBounded": true,
"overlapsBrowser": true,
"aboveBrowser": true
},
"resized": {
"dockMode": "push",
"bodyMounted": true,
"fixed": true,
"anchored": true,
"viewportBounded": true,
"overlapsBrowser": true,
"aboveBrowser": true
},
"escapeRestoresFocus": true,
"outsideDismisses": true
}
```
@@ -3,7 +3,7 @@
- button "关闭"
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
- textbox "包名或地址":
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
- /placeholder: 例如 dsh-plugin-whale-pet
- text: slow-package
- button "插件安装引导和示例"
- button "安装源 默认安装源"
@@ -0,0 +1,30 @@
- listitem:
- button "查看 slow-package": slow-package
- switch "启用 slow-package" [checked]
[
{
"colorScheme": "light",
"reducedMotion": "no-preference",
"boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px",
"animated": true
},
{
"colorScheme": "light",
"reducedMotion": "reduce",
"boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px",
"animated": false
},
{
"colorScheme": "dark",
"reducedMotion": "no-preference",
"boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px",
"animated": true
},
{
"colorScheme": "dark",
"reducedMotion": "reduce",
"boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px",
"animated": false
}
]
@@ -2,14 +2,14 @@
- heading "添加插件" [level=2]
- button "关闭"
- textbox "插件包名":
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
- /placeholder: 例如 dsh-plugin-whale-pet
- button "收起引导" [expanded]
- button "安装源 中国大陆镜像源"
- list:
- listitem:
- text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
- code: "@deepseek-ai/dsh-subagent-codex"
- button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例
- code: dsh-plugin-whale-pet
- button "填入示例 dsh-plugin-whale-pet": 填入示例
- listitem:
- text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例:
- code: https://github.com/author/dsh-plugin
@@ -2,7 +2,7 @@
- heading "添加插件" [level=2]
- button "关闭"
- textbox "插件包名":
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
- /placeholder: 例如 dsh-plugin-whale-pet
- button "插件安装引导和示例"
- button "安装源 中国大陆镜像源"
- button "安装" [disabled]
@@ -0,0 +1,46 @@
[
{
"stage": "details",
"details": true,
"tooltips": []
},
{
"stage": "more-hover",
"details": true,
"tooltips": []
},
{
"stage": "archive-before-dwell",
"details": true,
"tooltips": []
},
{
"stage": "archive",
"details": false,
"tooltips": [
"Archive"
]
},
{
"stage": "pin",
"details": false,
"tooltips": [
"Pin"
]
},
{
"stage": "back-to-title",
"details": false,
"tooltips": []
},
{
"stage": "menu-closed",
"details": false,
"tooltips": []
},
{
"stage": "row-reentered",
"details": true,
"tooltips": []
}
]
@@ -0,0 +1,47 @@
[
{
"stage": "first-before-dwell",
"first": false,
"second": false
},
{
"stage": "first-shown",
"first": true,
"second": false
},
{
"stage": "second-instant",
"first": false,
"second": true
},
{
"stage": "first-instant",
"first": true,
"second": false
},
{
"stage": "readable-portal",
"first": true,
"second": false
},
{
"stage": "portal-return-before-dwell",
"first": false,
"second": false
},
{
"stage": "portal-return-shown",
"first": false,
"second": true
},
{
"stage": "list-reentry-before-dwell",
"first": false,
"second": false
},
{
"stage": "list-reentry-shown",
"first": true,
"second": false
}
]
@@ -0,0 +1,47 @@
[
{
"stage": "first-before-dwell",
"first": false,
"second": false
},
{
"stage": "first-shown",
"first": true,
"second": false
},
{
"stage": "second-instant",
"first": false,
"second": true
},
{
"stage": "first-instant",
"first": true,
"second": false
},
{
"stage": "readable-portal",
"first": true,
"second": false
},
{
"stage": "portal-return-before-dwell",
"first": false,
"second": false
},
{
"stage": "portal-return-shown",
"first": false,
"second": true
},
{
"stage": "list-reentry-before-dwell",
"first": false,
"second": false
},
{
"stage": "list-reentry-shown",
"first": true,
"second": false
}
]
+185
View File
@@ -0,0 +1,185 @@
/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { JobId } from '@deepseek-ai/dsh-jobs'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
const EXPECTED_DIR = fileURLToPath(new URL('./expected/jobs-menu-layering', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = SessionId('jobs-menu-layering-web-e2e')
// The private scaffold cwd owns the barrier; the loop bounds an orphan's lifetime.
const RELEASE = '.jobs-menu-layering.release'
const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done`
/** Measure actual overlap and hit testing; ARIA alone cannot expose clipping or stacking. */
function placement(page: Page) {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('ul[aria-label="Background jobs"]')
const anchor = document.querySelector<HTMLElement>('button[aria-label="1 background job running"]')
const frame = document.querySelector<HTMLElement>('[data-sidebar-browser-frame]')
const dock = document.querySelector<HTMLElement>('[data-sidebar-right-panel][data-sidebar-right-open]')
if (list === null || anchor === null || frame === null || dock === null) {
throw new Error('job menu, trigger, or Browser dock is missing')
}
const menu = list.closest<HTMLElement>('[data-menu-material]') ?? list
const box = menu.getBoundingClientRect()
const trigger = anchor.getBoundingClientRect()
const browser = frame.getBoundingClientRect()
const left = Math.max(box.left, browser.left)
const right = Math.min(box.right, browser.right)
const top = Math.max(box.top, browser.top)
const bottom = Math.min(box.bottom, browser.bottom)
const overlapsBrowser = right - left > 8 && bottom - top > 8
const aboveBrowser = overlapsBrowser && [0.25, 0.5, 0.75].every(x =>
[0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint(
left + (right - left) * x, top + (bottom - top) * y,
))))
const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12)
const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12)
return {
dockMode: dock.dataset.sidebarRightPanel,
bodyMounted: menu.parentElement === document.body,
fixed: getComputedStyle(menu).position === 'fixed',
anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1,
viewportBounded: box.left >= 11 && box.top >= 11
&& box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11,
overlapsBrowser,
aboveBrowser,
}
})
}
describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let agent: Agent
let jobId: JobId | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
})
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.route('https://browser.test/**', async route => route.fulfill({
contentType: 'text/html', body: '<h1>Jobs menu browser fixture</h1>',
}))
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
})
afterAll(async () => {
const failures: unknown[] = []
if (scaffold !== undefined) {
try {
await writeFile(join(scaffold.workspaceCwd, RELEASE), '')
if (jobId !== undefined) {
const settled = await scaffold.ctx.jobs.wait(jobId, 15_000, agent.id)
if (settled.status === 'running' || settled.status === 'stopping') {
throw new Error('jobs menu fixture did not settle after releasing its barrier')
}
}
} catch (error) { failures.push(error) }
}
try { await browser?.close() } catch (error) { failures.push(error) }
try { await scaffold?.close() } catch (error) { failures.push(error) }
if (failures.length > 0) throw new AggregateError(failures, 'jobs menu layering teardown failed')
})
it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering'))
const session = page.locator(`[data-row-key="session:${SEED_ID}"]`)
await expect.poll(async () => {
if (await session.isVisible()) return true
const group = page.locator('[role="treeitem"][aria-expanded]').first()
if (await group.count() > 0 && await group.getAttribute('aria-expanded') === 'false') await group.click()
return session.isVisible()
}, { timeout: 15_000 }).toBe(true)
await session.click()
await expect.poll(() => scaffold.ctx.agents.get(SEED_ID), { timeout: 30_000 }).toBeDefined()
const opened = scaffold.ctx.agents.get(SEED_ID)
if (opened === undefined) throw new Error('opened session has no live Agent')
agent = opened
const started = await scaffold.ctx.tools.execute({
signal: new AbortController().signal,
callId: ToolCallId('jobs-menu-layering-e2e'),
name: 'bash',
arguments: { command: COMMAND, description: 'Emit output and hold the jobs menu fixture', run_in_background: true },
agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-\d+\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
jobId = JobId(matched[0])
await page.locator('[data-sidebar-right-expand]').click()
const column = page.locator('[data-rightbar-col]')
await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
const address = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
await address.fill('https://browser.test/jobs-menu')
await address.press('Enter')
await page.frameLocator('[data-sidebar-browser-frame]')
.getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor()
const trigger = page.getByRole('button', { name: '1 background job running', exact: true })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
const list = page.getByRole('list', { name: 'Background jobs' })
await list.waitFor()
const expectedPlacement = {
dockMode: 'push', bodyMounted: true, fixed: true, anchored: true,
viewportBounded: true, overlapsBrowser: true, aboveBrowser: true,
}
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
const collapsed = await placement(page)
await list.getByRole('button', { name: /Show live output/ }).click()
await list.getByText('jobs-menu-output', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
const expanded = await placement(page)
await page.setViewportSize({ width: 1280, height: 760 })
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
const resized = await placement(page)
await list.getByRole('button', { name: /Hide live output/ }).focus()
await page.keyboard.press('Escape')
await list.waitFor({ state: 'detached' })
const escapeRestoresFocus = await trigger.evaluate(node => node === document.activeElement)
expect(escapeRestoresFocus).toBe(true)
await trigger.click()
await list.waitFor()
await address.click()
await list.waitFor({ state: 'detached' })
const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false'
expect(outsideDismisses).toBe(true)
expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await mkdir(EXPECTED_DIR, { recursive: true })
await compareOrRefreshGolden(join(EXPECTED_DIR, 'geometry.expected.md'), [
'# Background jobs above the Browser dock', '',
'```json',
JSON.stringify({ collapsed, expanded, resized, escapeRestoresFocus, outsideDismisses }, null, 2),
'```',
].join('\n'), MODE)
await assertFixtureInventory(EXPECTED_DIR, ['geometry.expected.md'])
})
})
+42 -2
View File
@@ -9,7 +9,7 @@ import { expect, it } from 'vitest'
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
it('cancels installation through the UI, restores files, and offers the spec again', async () => {
it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
const overlay = join(scratch, 'cordis.patch.yml')
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
@@ -39,6 +39,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
`)
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const tripwire = watchConsole(page)
await page.clock.install()
await page.goto(scaffold.authenticatedUrl)
await page.waitForSelector('[class*="frame"]')
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
@@ -135,7 +136,46 @@ it('cancels installation through the UI, restores files, and offers the spec aga
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
await dialog.getByText('Retry completed', { exact: true }).waitFor()
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
// Freeze the real highlight's expiry timer and CSS first frame independently.
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' })
try {
await dialog.getByRole('button', { name: '立即启用', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]')
await card.waitFor({ state: 'visible' })
expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1)
expect(await card.getAttribute('data-plugin-status')).toBe('running')
const styles = []
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
for (const reducedMotion of ['no-preference', 'reduce'] as const) {
await page.emulateMedia({ reducedMotion })
const style = await card.evaluate((element) => {
const computed = getComputedStyle(element)
return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration }
})
expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/)
expect(style.boxShadow).toContain('0px 0px 0px 2px')
if (reducedMotion === 'reduce') {
expect(style.animationName).toBe('none')
} else {
expect(style.animationName).toContain('dsh-plugin-highlight')
expect(style.animationDuration).toBe('2.4s')
}
styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' })
}
}
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)),
`${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode())
await page.clock.runFor(2400)
await card.waitFor({ state: 'detached' })
} finally {
await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) })
await page.emulateMedia({ colorScheme: null, reducedMotion: null })
await page.clock.resume()
}
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
await dialog.getByRole('textbox').fill('recovered-package')
await page.route('**/api/pluginManager/installBundle', async (route) => {
+164 -5
View File
@@ -507,21 +507,21 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
* blank Session from a deleted Workspace may also be visible, without actions.
* @returns the session row locator, already present.
*/
async function seededSessionRow() {
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
async function seededSessionRow(target = page) {
const ungroupedRow = target.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
// Initial-current auto-expansion can race this gesture; converge on
// expanded rather than assuming which update wins first.
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
await page.waitForTimeout(50)
await target.getByText('Ungrouped', { exact: true }).click()
await target.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// CSS includes the actions button while it is hidden until row hover.
const rows = ungroupedSection.locator('[role="treeitem"]')
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
.filter({ has: target.locator('button[aria-label^="Session actions for "]') })
await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1)
return rows.first()
}
@@ -561,6 +561,165 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps session details dismissed after a row action until the pointer leaves the row', async () => {
const hoverPage = await newEnglishPage(browser)
try {
const consoleWatch = watchConsole(hoverPage)
await hoverPage.goto(scaffold.authenticatedUrl)
const row = await seededSessionRow(hoverPage)
const title = row.locator('[class*="title"]')
const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` })
const tooltip = hoverPage.getByRole('tooltip')
const observations: { stage: string; details: boolean; tooltips: string[] }[] = []
const observe = async (stage: string): Promise<void> => {
observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() })
}
const clockStart = Date.now()
await hoverPage.clock.install({ time: clockStart })
await hoverPage.clock.pauseAt(clockStart + 1000)
await title.hover()
await hoverPage.clock.runFor(800)
await card.waitFor()
await observe('details')
await row.getByRole('button', { name: /^Session actions for / }).hover()
await hoverPage.clock.runFor(1000)
expect(await card.count()).toBe(1)
await observe('more-hover')
await row.getByRole('button', { name: 'Archive session', exact: true }).hover()
await hoverPage.clock.runFor(499)
expect(await card.count()).toBe(1)
expect(await tooltip.count()).toBe(0)
await observe('archive-before-dwell')
await hoverPage.clock.runFor(1)
await tooltip.filter({ hasText: 'Archive' }).waitFor()
expect(await card.count()).toBe(0)
await observe('archive')
await row.getByRole('button', { name: 'Pin session', exact: true }).hover()
await hoverPage.clock.runFor(500)
await tooltip.filter({ hasText: 'Pin' }).waitFor()
expect(await card.count()).toBe(0)
await observe('pin')
await title.hover()
await tooltip.waitFor({ state: 'detached' })
// Advance beyond both the 800ms card dwell and the 500ms action tooltip dwell.
await hoverPage.clock.runFor(1600)
expect(await card.count()).toBe(0)
await observe('back-to-title')
const menu = row.getByRole('button', { name: /^Session actions for / })
await menu.click()
await hoverPage.getByRole('menu').waitFor()
expect(await card.count()).toBe(0)
await hoverPage.keyboard.press('Escape')
await hoverPage.getByRole('menu').waitFor({ state: 'detached' })
await title.hover()
await hoverPage.clock.runFor(1600)
expect(await card.count()).toBe(0)
await observe('menu-closed')
await hoverPage.getByRole('button', { name: 'Settings' }).hover()
await title.hover()
await hoverPage.clock.runFor(799)
expect(await card.count()).toBe(0)
await hoverPage.clock.runFor(1)
await card.waitFor()
await observe('row-reentered')
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/session-row-hover/actions.expected.json', import.meta.url)),
JSON.stringify(observations, null, 2), MODE,
)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await hoverPage.close()
}
}, 60_000)
it.each(['In one list', 'Workspace Tree'])('shares the first session dwell only within the physical %s list', async (layout) => {
const hoverScaffold = await launchWebScaffold({})
let hoverPage: Page | undefined
try {
const seed = await readFile(SEED, 'utf8')
await seedSession(hoverScaffold, seed, 'hover-first')
await seedSession(hoverScaffold, seed, 'hover-second')
await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-first'), title: 'Hover first' })
await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-second'), title: 'Hover second' })
hoverPage = await newEnglishPage(browser)
const ui = hoverPage
const consoleWatch = watchConsole(ui)
await ui.goto(hoverScaffold.authenticatedUrl)
await ui.getByRole('button', { name: 'View options' }).click()
await ui.getByRole('menuitem', { name: layout, exact: true }).click()
if (layout !== 'In one list') {
const header = ui.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await expect.poll(async () => {
if (await header.getAttribute('aria-expanded') !== 'true') await ui.getByText('Ungrouped', { exact: true }).click()
return header.getAttribute('aria-expanded')
}).toBe('true')
}
const first = ui.locator('[data-row-key="session:hover-first"]').getByText('Hover first', { exact: true })
const second = ui.locator('[data-row-key="session:hover-second"]').getByText('Hover second', { exact: true })
await first.waitFor()
await second.waitFor()
const firstCard = ui.getByRole('button', { name: 'Copy: Hover first', exact: true })
const secondCard = ui.getByRole('button', { name: 'Copy: Hover second', exact: true })
const observations: { stage: string; first: boolean; second: boolean }[] = []
const observe = async (stage: string): Promise<void> => {
observations.push({ stage, first: await firstCard.count() > 0, second: await secondCard.count() > 0 })
}
const clockStart = Date.now()
await ui.clock.install({ time: clockStart })
await ui.clock.pauseAt(clockStart + 1000)
await first.hover()
await ui.clock.runFor(799)
expect(await firstCard.count()).toBe(0)
await observe('first-before-dwell')
await ui.clock.runFor(1)
await firstCard.waitFor()
await observe('first-shown')
await second.hover()
expect(await secondCard.count()).toBe(1)
expect(await firstCard.count()).toBe(0)
await observe('second-instant')
await first.hover()
expect(await firstCard.count()).toBe(1)
expect(await secondCard.count()).toBe(0)
await observe('first-instant')
await firstCard.hover()
await ui.clock.runFor(500)
expect(await firstCard.isVisible()).toBe(true)
await observe('readable-portal')
await second.hover()
await ui.clock.runFor(799)
expect(await secondCard.count()).toBe(0)
await observe('portal-return-before-dwell')
await ui.clock.runFor(1)
await secondCard.waitFor()
await observe('portal-return-shown')
await ui.getByRole('button', { name: 'Settings' }).hover()
await ui.clock.runFor(300)
await first.hover()
await ui.clock.runFor(799)
expect(await firstCard.count()).toBe(0)
await observe('list-reentry-before-dwell')
await ui.clock.runFor(1)
await firstCard.waitFor()
await observe('list-reentry-shown')
const expected = layout === 'In one list' ? 'flat' : 'tree'
await compareOrRefreshGolden(
fileURLToPath(new URL(`./expected/session-row-hover/${expected}.expected.json`, import.meta.url)),
JSON.stringify(observations, null, 2), MODE,
)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await hoverPage?.close()
await hoverScaffold.close()
}
}, 60_000)
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
const sessionRow = await seededSessionRow()
+1
View File
@@ -65,6 +65,7 @@
"tests/excel-opc.e2e.ts",
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
"tests/jobs-menu-layering.e2e.ts",
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
@@ -1,7 +1,7 @@
/** An independent, expandable notice explaining a non-human Turn trigger. */
import { useId, useState, type ComponentType } from 'react'
import {
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular,
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
type IconProps,
@@ -20,7 +20,7 @@ const TRIGGER_ICONS: Record<TurnTriggerIcon, ComponentType<IconProps>> = {
subagent: IconAgentPresetOutlineRegular,
github: IconBranchOutlineRegular,
webhook: IconGlobeOutlineRegular,
schedule: IconAlarmClockOutlineRegular,
schedule: IconClockOutlineRegular,
job: IconQueueOutlineRegular,
plugin: IconCordisPluginOutlineRegular,
}
@@ -24,8 +24,8 @@
en: 91b35cbebe8c2a1d
zh: a4fee6d7102bc874
/chat-conversation-node-rules/whole-turn-folding:
en: 8db4991626210708
zh: e18c8aa3cb434980
en: f8a8a59b3be4c9cc
zh: f43b4c19a3fad0a8
/chat-conversation-node-rules/display-modes:
en: 42342a06ef189075
zh: 1a554b50125fcdda
@@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no
| Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. |
| Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. |
Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify automation tasks, background work, and plugin updates. Automation task notices use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
| Turn condition | Current behavior |
|---|---|
@@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示自动化任务、后台任务和插件更新。自动化任务通知与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
| 轮次条件 | 当前行为 |
|---|---|
+2 -2
View File
@@ -57,7 +57,7 @@ export const zh = {
'message.trigger.subagent': '子任务状态更新',
'message.trigger.github': '收到 GitHub 事件',
'message.trigger.webhook': '收到外部事件',
'message.trigger.schedule': '定时任务',
'message.trigger.schedule': '自动化任务',
'message.trigger.job': '后台任务状态更新',
'message.trigger.plugin': '插件状态更新',
'message.trigger.explanation': '这条通知触发了本轮回复。',
@@ -250,7 +250,7 @@ export const en = {
'message.trigger.subagent': 'Subtask status updated',
'message.trigger.github': 'GitHub event received',
'message.trigger.webhook': 'External event received',
'message.trigger.schedule': 'Scheduled task',
'message.trigger.schedule': 'Automation task',
'message.trigger.job': 'Background task updated',
'message.trigger.plugin': 'Plugin status updated',
'message.trigger.explanation': 'This notification triggered this response.',
@@ -6,7 +6,8 @@ import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts'
import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts'
import { en } from '../src/client/locale.ts'
import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { en, zh } from '../src/client/locale.ts'
afterEach(cleanup)
@@ -24,7 +25,7 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> {
describe('Turn trigger notices', () => {
it.each([
[{ kind: 'schedule' }, 'schedule', 'Scheduled task'],
[{ kind: 'schedule' }, 'schedule', 'Automation task'],
[{ kind: 'tool-jobs' }, 'job', 'Background task updated'],
[{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'],
[{ kind: 'goal' }, 'goal', 'Continuing goal'],
@@ -45,6 +46,19 @@ describe('Turn trigger notices', () => {
expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z')
})
it.each([
{ locale: en, title: 'Automation task' },
{ locale: zh, title: '自动化任务' },
])('uses the automation task label $title and shared clock', ({ locale, title }) => {
const node = trigger({ kind: 'schedule' })
const view = render(<TurnTriggerNodeView node={node} t={makeTranslate(locale)} />)
expect(view.getByRole('button').textContent).toContain(title)
const reference = render(<IconClockOutlineRegular size={14} />)
expect(view.getByRole('button').querySelector('svg')?.outerHTML)
.toBe(reference.container.querySelector('svg')?.outerHTML)
expect(node.data.source).toEqual({ kind: 'schedule' })
})
it('opens the recorded notice body and closes it independently of the Turn', () => {
const view = render(<TurnTriggerNodeView node={trigger({ kind: 'schedule' })} t={makeTranslate(en)} />)
const button = view.getByRole('button')
+2 -2
View File
@@ -24,8 +24,8 @@
en: c34dad62fee63306
zh: 167d9da7928a9488
/deepseek-ai-dsh-client-ui-jobs/understand-the-implementation:
en: f6f5d497ab340b9e
zh: 94832761177f12ca
en: 89891fe2f5895e68
zh: 3298efa604b2ec40
/deepseek-ai-dsh-client-ui-jobs/further-exploration:
en: 01eaaecb5850f391
zh: 2eabd14cde191ab8
+1 -1
View File
@@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
<details>
<summary>Implementation internals — click to expand</summary>
One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. The shared anchoring hook tracks scrolling, resizing, and panel-size changes. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
| File | Role |
|---|---|
+1 -1
View File
@@ -45,7 +45,7 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。共享锚点 hook 跟踪滚动、窗口尺寸及面板尺寸变化。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
| 文件 | 角色 |
|---|---|
+3 -1
View File
@@ -46,7 +46,9 @@
"@deepseek-ai/dsh-jobs": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"@types/react-dom": "~18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
@@ -39,31 +39,32 @@
}
.menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
position: fixed;
z-index: 100;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1px;
width: 500px;
max-width: min(560px, calc(100vw - 32px));
max-height: min(480px, calc(100vh - 140px));
margin: 0;
padding: 3px;
overflow: auto;
list-style: none;
border: 0;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
overflow: hidden;
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
}
.menuBody {
display: flex;
flex-direction: column;
gap: 1px;
min-height: 0;
margin: 0;
padding: 3px;
overflow: auto;
list-style: none;
}
/* Section labels over the live rows and the settled tail. */
.sectionHeader {
display: flex;
@@ -1,8 +1,10 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer,
IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock,
useAnchoredPosition, useDismissOnOutsidePointer,
type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -338,10 +340,10 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | undefined>(undefined)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLUListElement>(null)
// Horizontal shift applied to the trigger-anchored popover so it stays
// inside the viewport (the stylesheet alone cannot see the anchor offset).
const [menuShift, setMenuShift] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
const menuPosition = useAnchoredPosition({
open, anchorRef: triggerRef, panelRef: menuRef, gap: 5, margin: VIEWPORT_MARGIN,
})
const rows = useMemo(() => ordered(jobs), [jobs])
const liveRows = useMemo(() => rows.filter(isLive), [rows])
@@ -352,7 +354,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
const settledExpanded = settledOpen ?? liveRows.length === 0
const visibleCount = liveRows.length + settledRows.length
useDismissOnOutsidePointer(rootRef, open, setOpen)
useDismissOnOutsidePointer(rootRef, open, setOpen, menuRef)
// The roster follows the mounted session: one stream while this control
// lives, released with it.
@@ -366,32 +368,6 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
return () => { clearInterval(timer) }
}, [open, liveRows.length])
// Fit the open popover to the viewport: shift left when the anchored width
// would cross the right edge, never past the left margin.
useLayoutEffect(() => {
if (!open) {
setMenuShift(0)
return
}
const fit = (): void => {
const root = rootRef.current
const menu = menuRef.current
/* v8 ignore next -- both refs are attached while the open popover renders. */
if (root === null || menu === null) return
const width = menu.offsetWidth
// Unlaid-out nodes (and jsdom) measure 0: keep the pure CSS anchor.
if (width === 0) return
const anchorLeft = root.getBoundingClientRect().left
setMenuShift(Math.max(
VIEWPORT_MARGIN - anchorLeft,
Math.min(0, window.innerWidth - VIEWPORT_MARGIN - width - anchorLeft),
))
}
fit()
window.addEventListener('resize', fit)
return () => { window.removeEventListener('resize', fit) }
}, [open])
// Observation follows visibility: the stream opens when an observable panel
// expands and closes when it collapses, unmounts, or the popover closes.
const expandedRow = open && expandedKey !== undefined
@@ -526,8 +502,9 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
<IconChevronDownOutlineRegular size={12} className={open ? css.triggerOpen : undefined} />
</button>
{open
? (
<ul ref={menuRef} className={css.menu} style={{ left: menuShift }} aria-label={t('list.aria')}>
? createPortal(
<MenuSurface ref={menuRef} className={css.menu} style={menuPosition ?? { visibility: 'hidden' }}>
<ul className={css.menuBody} aria-label={t('list.aria')}>
{liveRows.length > 0
? <li className={css.sectionHeader} aria-hidden="true">{t('section.live')}</li>
: null}
@@ -552,6 +529,8 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
: null}
{settledExpanded ? settledRows.map(item) : null}
</ul>
</MenuSurface>,
document.body,
)
: null}
</div>
@@ -10,6 +10,7 @@ import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})
@@ -282,41 +283,86 @@ describe('JobListAction observation', () => {
expect(writeText).toHaveBeenCalledWith('pnpm run build')
})
it('shifts an overflowing popover back inside the viewport and follows resizes', () => {
it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => {
vi.stubGlobal('innerWidth', 700)
vi.stubGlobal('innerHeight', 600)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440)
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect)
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
render(<JobListAction {...props([outputJob()])} />)
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin.
expect(menu.style.left).toBe('-96px')
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
const { container } = render(<JobListAction {...props([outputJob()])} />)
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
const anchor = vi.spyOn(trigger, 'getBoundingClientRect')
.mockReturnValue(new DOMRect(344, 40, 180, 28))
fireEvent.click(trigger)
const list = screen.getByRole('list', { name: zh['list.aria'] })
const menu = list.closest<HTMLElement>('[data-menu-material]')
expect(menu?.parentElement).toBe(document.body)
expect(container.contains(list)).toBe(false)
expect(menu?.style.left).toBe('248px')
expect(menu?.style.top).toBe('73px')
window.innerWidth = 900
vi.stubGlobal('innerWidth', 900)
fireEvent(window, new Event('resize'))
// 900 - 12 - 440 - 344 = 104 > 0: the anchored position fits again.
expect(menu.style.left).toBe('0px')
} finally {
Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true })
}
expect(menu?.style.left).toBe('344px')
anchor.mockReturnValue(new DOMRect(80, 110, 180, 28))
fireEvent.scroll(container)
expect(menu?.style.left).toBe('80px')
expect(menu?.style.top).toBe('143px')
})
it('never crosses the left viewport margin for an oversized popover', () => {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800)
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 4 } as DOMRect)
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
it('keeps a near-edge anchored list inside both viewport margins', () => {
vi.stubGlobal('innerWidth', 700)
vi.stubGlobal('innerHeight', 600)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
render(<JobListAction {...props([outputJob()])} />)
openList()
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28))
fireEvent.click(trigger)
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin.
expect(menu.style.left).toBe('8px')
} finally {
Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true })
}
.closest<HTMLElement>('[data-menu-material]')
expect(menu?.style.left).toBe('12px')
expect(menu?.style.top).toBe('468px')
})
it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => {
const stop = vi.fn()
const observe = vi.fn(() => stop)
const { container, unmount } = render(<JobListAction {...props([outputJob()], observe)} />)
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
trigger.focus()
fireEvent.click(trigger)
const list = screen.getByRole('list', { name: zh['list.aria'] })
expect(container.contains(list)).toBe(false)
const row = screen.getByRole('button', {
name: zh['row.expandAria'].replace('{label}', 'pnpm run build'),
})
fireEvent.pointerDown(row)
fireEvent.click(row)
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list)
expect(observe).toHaveBeenCalledTimes(1)
expect(stop).not.toHaveBeenCalled()
const kill = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build'))
fireEvent.pointerDown(kill)
fireEvent.click(kill)
expect(kill.getAttribute('data-kill-state')).toBe('armed')
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list)
row.focus()
fireEvent.keyDown(row, { key: 'Escape' })
expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull()
expect(document.activeElement).toBe(trigger)
expect(stop).toHaveBeenCalledTimes(1)
fireEvent.click(trigger)
expect(observe).toHaveBeenCalledTimes(2)
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull()
expect(stop).toHaveBeenCalledTimes(2)
fireEvent.click(trigger)
expect(observe).toHaveBeenCalledTimes(3)
const reopened = screen.getByRole('list', { name: zh['list.aria'] })
unmount()
expect(document.body.contains(reopened)).toBe(false)
expect(stop).toHaveBeenCalledTimes(3)
})
it('ignores other keys while open and closes once the roster empties', () => {
@@ -361,10 +407,10 @@ describe('JobListAction observation', () => {
gapBefore: false,
streaming: true,
}
const { container } = render(<JobListAction {...props([job()], undefined, { 'bash-1': view })} />)
render(<JobListAction {...props([job()], undefined, { 'bash-1': view })} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(container.querySelector('[data-running]')).not.toBeNull()
expect(screen.getByRole('list', { name: zh['list.aria'] }).querySelector('[data-running]')).not.toBeNull()
expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull()
})
@@ -146,7 +146,7 @@
@keyframes dsh-plugin-highlight {
0%,
55% {
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
}
100% {
@@ -157,7 +157,7 @@
@media (prefers-reduced-motion: reduce) {
.card[data-plugin-highlight] {
animation: none;
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
}
}
@@ -48,11 +48,11 @@ export const zh = {
installTitle: '添加插件',
installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。',
installSpecLabel: '包名或地址',
installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex',
installSpecPlaceholder: '例如 dsh-plugin-whale-pet',
installGuideToggle: '插件安装引导和示例',
installGuideHide: '收起引导',
installGuideIdTitle: '包名',
installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
installGuideIdExample: 'dsh-plugin-whale-pet',
installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
installGuideGitTitle: 'GitHub 仓库地址',
installGuideGitExample: 'https://github.com/author/dsh-plugin',
@@ -241,11 +241,11 @@ export const en = {
installTitle: 'Add plugin',
installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.',
installSpecLabel: 'Package name or address',
installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex',
installSpecPlaceholder: 'for example dsh-plugin-whale-pet',
installGuideToggle: 'Install guide and examples',
installGuideHide: 'Hide the guide',
installGuideIdTitle: 'Package name',
installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
installGuideIdExample: 'dsh-plugin-whale-pet',
installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.',
installGuideGitTitle: 'GitHub repository address',
installGuideGitExample: 'https://github.com/author/dsh-plugin',
@@ -708,6 +708,19 @@ describe('PluginManagerPage', () => {
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
})
it.each([
{ locale: en, placeholder: 'for example dsh-plugin-whale-pet' },
{ locale: zh, placeholder: '例如 dsh-plugin-whale-pet' },
])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => {
const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
setLanguage(locale)
expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle }))
expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') }))
expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet')
})
it('opens a guide under the field and drops an example into it', () => {
const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
@@ -18,11 +18,11 @@
en: 91ac7c457248a593
zh: 744c9cea337bb20f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 33299e518ff2cad1
zh: 880c7da903bc20d4
en: 02587f024614dd2e
zh: 3d5e982dc612bdc7
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 83052e14c3fb34cf
zh: 0adb09885723296a
en: 73f93b4e56ea9ba1
zh: 630ac01aad8e758a
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: d79e20306593c4bf
zh: 5c0f1100e2cd6fcc
+3 -1
View File
@@ -56,7 +56,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. |
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
@@ -85,6 +85,8 @@ The catalog above lists what each export is for; this section covers the behavio
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render.
`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately until the pointer leaves the list. Physical exit, including entry into a portaled card, resets the shared dwell without bypassing that card's pointer grace or copy behavior. Cards without `grouped` remain independent. `dismissOnTooltip` separately opts into dismissal only when an anchor tooltip activates; the card stays dismissed until anchor exit, even after the tooltip hides. Without it, tooltip suppression remains temporary.
### Rendering agent output
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+3 -1
View File
@@ -56,7 +56,7 @@ kind: "package-library"
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
@@ -85,6 +85,8 @@ kind: "package-library"
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。
`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:之后的卡片立即替换当前卡片,直到指针离开列表。离开物理列表(包括进入 portal 卡片)会重置共享等待,但不会绕过该卡片的指针宽限或复制行为。未设置 `grouped` 的卡片保持独立。`dismissOnTooltip` 可单独选择仅在锚点 tooltip 激活后关闭卡片;即使 tooltip 隐藏,卡片也保持关闭直到指针离开锚点。不设置时,tooltip 仍只暂时遮蔽卡片。
### 渲染 agent 输出
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
import clsx from 'clsx'
import { createPortal } from 'react-dom'
@@ -6,6 +6,7 @@ import { writeClipboard } from './clipboard.ts'
import { usePointerGrace } from './pointer-grace.ts'
import { overlayTopMargin } from './overlay-top-margin.ts'
import { TooltipSuppression } from './Tooltip.tsx'
import { HoverCardGroupContext } from './HoverCardGroup.tsx'
import css from './HoverCard.module.css'
/** Preview opacity transition and retained lifetime during dismissal. */
@@ -27,6 +28,8 @@ const VIEWPORT_MARGIN = 8
* @param props.widthAnchorRef - optional element whose width and horizontal position size the preview.
* @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus.
* @param props.disabled - suppress opening; turning true dismisses an open card.
* @param props.grouped - share first-display dwell and exclusive display with the surrounding HoverCardGroup.
* @param props.dismissOnTooltip - a visible anchor tooltip dismisses this card until the pointer leaves the anchor.
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
* @param props.copyLabel - localized accessible activation-label prefix.
@@ -34,7 +37,7 @@ const VIEWPORT_MARGIN = 8
* @returns anchor wrapper with the conditional portaled card.
*/
export function HoverCard({
anchor, content, openDelayMs = 500, disabled = false,
anchor, content, openDelayMs = 500, disabled = false, grouped = false, dismissOnTooltip = false,
copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false,
}: {
anchor: ReactNode
@@ -43,6 +46,8 @@ export function HoverCard({
content: ReactNode
openDelayMs?: number
disabled?: boolean
grouped?: boolean
dismissOnTooltip?: boolean
variant?: 'compact' | 'preview'
widthAnchorRef?: RefObject<HTMLElement | null>
} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | {
@@ -65,6 +70,11 @@ export function HoverCard({
const positioned = pos !== null
const [copied, setCopied] = useState(false)
const [suppressed, setSuppressed] = useState(false)
const parentGroup = useContext(HoverCardGroupContext)
const group = grouped ? parentGroup : null
const [groupOwner] = useState(() => Symbol('hover-card'))
const dismissedForVisit = useRef(false)
const anchorSuppressed = useRef(false)
const clearCopied = useCallback(() => {
if (copyTimerRef.current !== null) {
@@ -83,12 +93,36 @@ export function HoverCard({
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
const clearTimer = () => {
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const dismissImmediately = useCallback(() => {
clearTimer()
cancelClose()
copyEpochRef.current += 1
clearCopied()
setPhase('closed')
}, [clearTimer, cancelClose, clearCopied])
const suppressionPolicy = useRef({ dismissOnTooltip, dismissImmediately })
suppressionPolicy.current = { dismissOnTooltip, dismissImmediately }
const onTooltipSuppression = useCallback((active: boolean) => {
anchorSuppressed.current = active
setSuppressed(active)
if (active && suppressionPolicy.current.dismissOnTooltip) {
dismissedForVisit.current = true
suppressionPolicy.current.dismissImmediately()
}
}, [])
const groupVisible = phase === 'open' && positioned && !suppressed && !disabled && !dismissedForVisit.current
useLayoutEffect(() => {
if (!groupVisible || group === null) return
group.shown(groupOwner, dismissImmediately)
return () => { group.release(groupOwner) }
}, [group, groupOwner, groupVisible, dismissImmediately])
useEffect(() => {
if (!closing) return
@@ -269,15 +303,18 @@ export function HoverCard({
if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() }
} : undefined}
onPointerEnter={(event) => {
if (disabled || (inline && event.pointerType === 'touch')) return
if (disabled || (inline && event.pointerType === 'touch')
|| (dismissOnTooltip && (dismissedForVisit.current || anchorSuppressed.current))) return
// Coming back inside during the grace (the gap, or the card itself)
// keeps the current card rather than restarting the dwell.
cancelClose()
if (open) { setPhase('open'); return }
clearTimer()
timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs)
if (group?.isWarm()) setPhase('open')
else timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs)
}}
onPointerLeave={() => {
dismissedForVisit.current = false
clearTimer()
// Leaving a closed card schedules a no-op close; only arm while
// open, matching Menu's shape.
@@ -286,7 +323,7 @@ export function HoverCard({
onPointerDownCapture={dismissFromAnchor}
onClickCapture={dismissFromAnchor}
>
<TooltipSuppression.Provider value={setSuppressed}>{anchor}</TooltipSuppression.Provider>
<TooltipSuppression.Provider value={onTooltipSuppression}>{anchor}</TooltipSuppression.Provider>
{open && !suppressed && copyable && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
{card !== false && createPortal(card, document.body)}
</span>
@@ -0,0 +1,55 @@
import { createContext, useState, type ReactNode } from 'react'
interface GroupState {
isWarm: () => boolean
enter: () => void
leave: () => void
shown: (owner: symbol, dismiss: () => void) => void
release: (owner: symbol) => void
}
/** Private coordination shared only by opted-in HoverCards inside one mounted group. */
export const HoverCardGroupContext = createContext<GroupState | null>(null)
/**
* Share the first displayed card's dwell across one physical list, without closing readable portals on list exit.
* @param props.children - list contents, including opted-in HoverCards.
* @param props.className - classes for the physical list container.
* @returns the list container and its private hover coordinator.
*/
export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) {
const [group] = useState<GroupState>(() => {
let inside = false
let warm = false
let active: { owner: symbol; dismiss: () => void } | null = null
return {
isWarm: () => warm,
enter: () => { inside = true },
leave: () => { inside = false; warm = false },
shown: (owner, dismiss) => {
if (inside) warm = true
const previous = active
active = { owner, dismiss }
if (previous !== null && previous.owner !== owner) previous.dismiss()
},
release: (owner) => { if (active?.owner === owner) active = null },
}
})
return (
<HoverCardGroupContext.Provider value={group}>
<div
className={className}
onPointerOverCapture={(event) => {
if (event.currentTarget.contains(event.target as Node)) group.enter()
}}
onPointerOutCapture={(event) => {
// React portals bubble through this component but are outside the physical list.
if (event.currentTarget.contains(event.target as Node)
&& !(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) group.leave()
}}
>
{children}
</div>
</HoverCardGroupContext.Provider>
)
}
@@ -31,6 +31,7 @@ export { useAnchoredPosition } from './useAnchoredPosition.ts'
export type { AnchoredPositionOptions } from './useAnchoredPosition.ts'
export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
export { HoverCard } from './HoverCard.tsx'
export { HoverCardGroup } from './HoverCardGroup.tsx'
export { ShortcutKeys } from './ShortcutKeys.tsx'
export { observeComposition } from './keyboard-composition.ts'
export { focusWithoutRing } from './focus.ts'
@@ -12,13 +12,22 @@ import type { IconProps } from './icons/props.ts'
*/
const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}`
/** Terminal plugin artwork (prompt chevron and cursor bar). */
export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => (
/** Terminal prompt artwork in a muted cyan-to-blue palette. */
export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => {
const uid = useArtworkId()
return (
<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={`url(#${uid}a)`} strokeWidth="3.5" />
<path d="M20.1211 24.4946H26.8685" stroke={`url(#${uid}a)`} strokeWidth="3.5" />
<defs>
<linearGradient id={`${uid}a`} x1="18" y1="11" x2="18" y2="24.4948" gradientUnits="userSpaceOnUse">
<stop stopColor="#4DD9D4" />
<stop offset="1" stopColor="#6085EB" />
</linearGradient>
</defs>
</svg>
)
)
}
/** Agent-loop plugin artwork (four leaves circling a center). */
export const PluginArtworkLoop = ({ size = 36, className }: IconProps) => {
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '../src/HoverCard.tsx'
import { HoverCardGroup } from '../src/HoverCardGroup.tsx'
import { Tooltip } from '../src/Tooltip.tsx'
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
// jsdom lacks PointerEvent; MouseEvent preserves relatedTarget for physical and React ancestry checks.
function move(from: Element, to: Element): void {
fireEvent(from, new MouseEvent('pointerout', { bubbles: true, relatedTarget: to }))
fireEvent(to, new MouseEvent('pointerover', { bubbles: true, relatedTarget: from }))
fireEvent(from, new MouseEvent('mouseout', { bubbles: true, relatedTarget: to }))
fireEvent(to, new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }))
}
function tick(ms: number): void { act(() => { vi.advanceTimersByTime(ms) }) }
function Card({ name, grouped = true, disabled = false }: { name: string; grouped?: boolean; disabled?: boolean }) {
return <HoverCard grouped={grouped} dismissOnTooltip disabled={disabled} openDelayMs={800}
anchor={<span>{name}<Tooltip label={`Archive ${name}`} delayMs={500}><button type="button">action {name}</button></Tooltip></span>}
content={<span>details {name}</span>} copyText={name} copyLabel="Copy" copiedLabel="Copied" />
}
describe('HoverCardGroup', () => {
it('waits for the first actual display, then switches instantly within one physical list', () => {
render(<HoverCardGroup><Card name="A" /><span>gap</span><Card name="B" /></HoverCardGroup>)
const a = screen.getByText('A')
const b = screen.getByText('B')
const gap = screen.getByText('gap')
move(document.body, a)
tick(799)
expect(screen.queryByText('details A')).toBeNull()
tick(1)
expect(screen.getByText('details A')).toBeTruthy()
move(a, b)
expect(screen.queryByText('details A')).toBeNull()
expect(screen.getByText('details B')).toBeTruthy()
// Repositioning the old anchor must not let it claim ownership again.
fireEvent(window, new Event('resize'))
tick(300)
expect(screen.queryByText('details A')).toBeNull()
expect(screen.getByText('details B')).toBeTruthy()
move(b, gap)
tick(300)
move(gap, a)
expect(screen.getByText('details A')).toBeTruthy()
move(a, document.body)
tick(300)
move(document.body, b)
expect(screen.queryByText('details B')).toBeNull()
tick(799)
expect(screen.queryByText('details B')).toBeNull()
tick(1)
expect(screen.getByText('details B')).toBeTruthy()
})
it('does not warm a list from a cancelled opening or an active tooltip', () => {
render(<HoverCardGroup><Card name="A" /><Card name="B" /></HoverCardGroup>)
const a = screen.getByText('A')
const b = screen.getByText('B')
move(document.body, a)
tick(400)
move(a, b)
tick(799)
expect(screen.queryByText('details A')).toBeNull()
expect(screen.queryByText('details B')).toBeNull()
const action = screen.getByRole('button', { name: 'action B' })
move(b, action)
// The card may open before the tooltip reaches its independent 500ms dwell.
tick(1)
expect(screen.getByText('details B')).toBeTruthy()
move(action, document.body)
tick(300)
move(document.body, action)
tick(500)
expect(screen.getByRole('tooltip').textContent).toBe('Archive B')
tick(500)
expect(screen.queryByText('details B')).toBeNull()
move(action, a)
expect(screen.queryByText('details A')).toBeNull()
tick(800)
expect(screen.getByText('details A')).toBeTruthy()
})
it('resets warmth on physical portal exit without closing readable, copyable content', async () => {
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
render(<HoverCardGroup><Card name="A" /><Card name="B" /></HoverCardGroup>)
const a = screen.getByText('A')
const b = screen.getByText('B')
move(document.body, a)
tick(800)
const portal = screen.getByRole('button', { name: 'Copy: A' })
move(a, portal)
tick(500)
expect(portal.isConnected).toBe(true)
await act(async () => { fireEvent.click(portal); await Promise.resolve() })
expect(writeText).toHaveBeenCalledWith('A')
expect(screen.getByRole('status').textContent).toBe('Copied')
move(portal, b)
expect(screen.queryByText('details B')).toBeNull()
tick(799)
expect(screen.queryByText('details B')).toBeNull()
tick(1)
expect(screen.getByText('details B')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Copy: A' })).toBeNull()
} finally {
if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', previous)
}
})
it('rejects stale clipboard feedback after a group handoff and return', async () => {
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
let finishWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
render(<HoverCardGroup><Card name="A" /><Card name="B" /></HoverCardGroup>)
const a = screen.getByText('A')
const b = screen.getByText('B')
move(document.body, a)
tick(800)
fireEvent.keyDown(screen.getByRole('button', { name: 'Copy: A' }), { key: 'Enter' })
expect(writeText).toHaveBeenCalledWith('A')
move(a, b)
expect(screen.getByText('details B')).toBeTruthy()
move(b, a)
expect(screen.getByText('details A')).toBeTruthy()
await act(async () => { finishWrite?.(); await Promise.resolve() })
expect(screen.queryByText('Copied')).toBeNull()
expect(screen.getByText('details A')).toBeTruthy()
} finally {
finishWrite?.()
if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', previous)
}
})
it('keeps separate groups cold and does not enroll ungrouped cards', () => {
render(<><HoverCardGroup><Card name="A" /><Card name="Project" grouped={false} /></HoverCardGroup>
<HoverCardGroup><Card name="B" /></HoverCardGroup></>)
const a = screen.getByText('A')
const project = screen.getByText('Project')
const b = screen.getByText('B')
move(document.body, a)
tick(800)
move(a, project)
expect(screen.queryByText('details Project')).toBeNull()
tick(800)
expect(screen.getByText('details Project')).toBeTruthy()
move(project, b)
expect(screen.queryByText('details B')).toBeNull()
tick(800)
expect(screen.getByText('details B')).toBeTruthy()
})
it('releases an unmounted active card without cooling its still-mounted list', () => {
const markup = (showA: boolean, groupKey = 'first') => <HoverCardGroup key={groupKey}>
{showA && <Card name="A" />}<Card name="B" />
</HoverCardGroup>
const view = render(markup(true))
const a = screen.getByText('A')
move(document.body, a)
tick(800)
view.rerender(markup(false))
move(a, screen.getByText('B'))
expect(screen.getByText('details B')).toBeTruthy()
view.rerender(markup(false, 'new'))
move(document.body, screen.getByText('B'))
expect(screen.queryByText('details B')).toBeNull()
tick(800)
expect(screen.getByText('details B')).toBeTruthy()
view.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -54,6 +54,42 @@ function installClipboard(writeText: (text: string) => Promise<void>): () => voi
}
describe('HoverCard', () => {
it.each(['disabled', 'unmounted'] as const)('keeps opt-in dismissal after a tooltip is %s without changing default suppression', (released) => {
const markup = (state: 'active' | 'disabled' | 'unmounted', content = 'card body') => <HoverCard
dismissOnTooltip openDelayMs={800}
anchor={<div>row{state !== 'unmounted' && <Tooltip label="Archive" delayMs={500} disabled={state === 'disabled'}>
<button type="button">action</button>
</Tooltip>}</div>}
content={<div>{content}</div>}
/>
const view = render(markup('active'))
const wrapper = screen.getByText('row').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(800) })
const action = screen.getByRole('button', { name: 'action' })
fireEvent.mouseEnter(action)
act(() => { vi.advanceTimersByTime(499) })
expect(screen.getByText('card body')).toBeTruthy()
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseLeave(action)
act(() => { vi.advanceTimersByTime(100) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.mouseEnter(action)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByRole('tooltip').textContent).toBe('Archive')
view.rerender(markup('active', 'changed body'))
expect(screen.getByRole('tooltip').textContent).toBe('Archive')
expect(screen.queryByText('changed body')).toBeNull()
view.rerender(markup(released, 'changed body'))
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByRole('tooltip')).toBeNull()
expect(screen.queryByText('changed body')).toBeNull()
fireEvent.pointerLeave(wrapper)
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(800) })
expect(screen.getByText('changed body')).toBeTruthy()
})
it.each(['disabled', 'unmounted'] as const)('restores its preview after a nested tooltip is %s', (released) => {
const markup = (state: 'active' | 'disabled' | 'unmounted') => (
<HoverCard
@@ -33,6 +33,16 @@ describe('plugin artwork', () => {
expect(container.querySelector('foreignObject div')).not.toBeNull()
})
it('uses a muted cyan-to-blue gradient for both terminal strokes', () => {
const { container } = render(<PluginArtworkTerminal />)
const gradient = container.querySelector('linearGradient')!
const colors = [...container.querySelectorAll('stop')].map(stop => stop.getAttribute('stop-color'))
expect(colors).toEqual(['#4DD9D4', '#6085EB'])
expect(gradient.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke'))
expect(strokes).toEqual([`url(#${gradient.id})`, `url(#${gradient.id})`])
})
it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => {
const { container } = render(
<>
@@ -43,8 +53,8 @@ describe('plugin artwork', () => {
for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36')
const ids = [...container.querySelectorAll('[id]')].map(node => node.id)
expect(new Set(ids).size).toBe(ids.length)
for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) {
const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1)
for (const reference of container.querySelectorAll('[fill^="url("], [stroke^="url("], [clip-path^="url("]')) {
const target = (reference.getAttribute('fill') ?? reference.getAttribute('stroke') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1)
expect(ids).toContain(target)
}
})
@@ -54,8 +54,8 @@
en: 83031e7505fc228a
zh: 03e17c9428fe29fd
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
en: 34668e3c2e9c77b7
zh: c8bb72e29a3f2490
en: 1c352a4c1719e50a
zh: b4b9fff9ae67bfb3
/deepseek-ai-dsh-client-ui-workspace/further-exploration:
en: 2abbf27abbd71ba4
zh: ace33fa2ea042357
+1 -1
View File
@@ -172,7 +172,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu
### Hover cards
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state.
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Session card in a physical list waits 800ms; after a card is displayed, moving to another Session in the same list replaces it immediately. Leaving the physical list resets that shared dwell, while a portaled card remains readable and copyable through the existing pointer grace. A row's action tooltip dismisses the card only after the tooltip's own dwell; returning to the title does not restore the card during that pointer visit. Hovering More preserves the card; opening its menu dismisses it. Workspace cards keep their independent dwell.
</details>
+1 -1
View File
@@ -172,7 +172,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序
### 悬浮卡片
Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。
Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Session 会立即替换卡片。离开物理列表会重置共享等待状态,而 portal 卡片仍通过原有指针宽限保持可阅读、可复制。行内操作按钮的 tooltip 只有在自身等待结束后才关闭卡片;同次指针停留中回到标题不会恢复卡片。悬停「更多」保留卡片,打开菜单才关闭。Workspace 卡片保持独立等待。
</details>
@@ -252,14 +252,12 @@
flex: none;
}
/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656):
bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside
the 16px glyphs, so the buttons sit at 10. */
/* Trailing action buttons surface on hover with bare 16px glyphs. */
.rowActions {
flex: none;
display: none;
align-items: center;
gap: 10px;
gap: 15px;
}
.projectRow:hover .rowActions,
@@ -694,6 +694,8 @@ export function SessionNodeItem({
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} renderSlot={renderSlot} t={t} />}
openDelayMs={800}
grouped
dismissOnTooltip
disabled={menuOpen || drag?.active === true}
copyText={row.blank ? undefined : row.title}
copyLabel={t('copy')}
@@ -17,7 +17,7 @@
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular,
Button, HoverCardGroup, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular,
IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular,
IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular,
IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular,
@@ -606,7 +606,7 @@ function SessionTree({
const groupRows = rootGroups.map(group => renderGroup(group, 0))
return (
<div className={clsx(css.treeBody, css.wide)}>
<HoverCardGroup className={clsx(css.treeBody, css.wide)}>
{workspaceDropAtListStart && <span className={css.listTopDropIndicator} aria-hidden="true" />}
<AnimatedRows
className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)}
@@ -621,7 +621,7 @@ function SessionTree({
{groupRows}
</AnimatedRows>
<span className={css.fade} />
</div>
</HoverCardGroup>
)
}
@@ -670,7 +670,7 @@ function FlatList({
}
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<HoverCardGroup className={clsx(css.treeBody, css.wide)}>
<AnimatedRows
className={clsx(css.list, css.flatList)}
label={t('section.sessions')}
@@ -724,7 +724,7 @@ function FlatList({
})}
</AnimatedRows>
<span className={css.fade} />
</div>
</HoverCardGroup>
)
}
@@ -6,7 +6,7 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/clie
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import { MenuItemButton, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { MenuOpenState, SessionRowOwnerProps } from '../src/client/contract/slots.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -730,6 +730,84 @@ describe('workspace browser rows', () => {
expect(onOpen).not.toHaveBeenCalled()
})
it.each([400, 800])('keeps the preview available when hovering More at %dms', (dwell) => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('more-hover'), title: 'More hover', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
const title = screen.getByText('More hover')
const more = screen.getByRole('button', { name: '会话“More hover”的操作' })
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(dwell) })
fireEvent(title, new MouseEvent('pointerout', { bubbles: true, relatedTarget: more }))
fireEvent(more, new MouseEvent('pointerover', { bubbles: true, relatedTarget: title }))
act(() => { vi.advanceTimersByTime(800) })
expect(screen.getByRole('button', { name: '复制: More hover' })).toBeTruthy()
fireEvent.click(more)
expect(screen.queryByRole('button', { name: '复制: More hover' })).toBeNull()
} finally {
cleanup()
vi.useRealTimers()
}
})
it.each([0, 400, 800])('dismisses the card only after the action tooltip dwell, then until row exit (%dms)', (dwell) => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('action-hover'), title: 'Action hover', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
}
const renderSlot: RowRenderSlot = (name: RowSlotName) => name === 'sidebar.workspaces.session.row.action'
? <Tooltip label="Archive" delayMs={500}><button type="button" aria-label="Archive" /></Tooltip>
: null
const { rerender } = render(<SessionNodeItem node={node} currentId={undefined} now={0}
onOpen={vi.fn()} renderSlot={renderSlot} t={t} />)
const row = screen.getByRole('treeitem')
const title = screen.getByText('Action hover')
const archive = screen.getByRole('button', { name: 'Archive' })
const card = () => screen.queryByRole('button', { name: '复制: Action hover' })
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(dwell) })
if (dwell === 800) expect(card()).not.toBeNull()
fireEvent(title, new MouseEvent('pointerout', { bubbles: true, relatedTarget: archive }))
fireEvent(archive, new MouseEvent('pointerover', { bubbles: true, relatedTarget: title }))
fireEvent.mouseEnter(archive)
if (dwell === 800) expect(card()).not.toBeNull()
act(() => { vi.advanceTimersByTime(499) })
expect(screen.queryByRole('tooltip')).toBeNull()
if (dwell === 0) expect(card()).toBeNull()
else expect(card()).not.toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('tooltip').textContent).toBe('Archive')
expect(card()).toBeNull()
fireEvent(archive, new MouseEvent('pointerout', { bubbles: true, relatedTarget: title }))
fireEvent(title, new MouseEvent('pointerover', { bubbles: true, relatedTarget: archive }))
fireEvent.mouseLeave(archive, { relatedTarget: title })
rerender(<SessionNodeItem node={{ ...node, running: true }} currentId={undefined} now={0}
onOpen={vi.fn()} renderSlot={renderSlot} t={t} />)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByRole('tooltip')).toBeNull()
expect(card()).toBeNull()
fireEvent.pointerLeave(row, { relatedTarget: document.body })
fireEvent.pointerEnter(row, { relatedTarget: document.body })
act(() => { vi.advanceTimersByTime(799) })
expect(card()).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(card()).not.toBeNull()
} finally {
cleanup()
vi.useRealTimers()
}
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
+6
View File
@@ -3844,9 +3844,15 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/[email protected])
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1([email protected])
packages/client/ui-layout:
devDependencies:
+1
View File
@@ -147,6 +147,7 @@
"apps/web/tests/subagent-interrupt-ui.e2e.ts",
"apps/web/tests/sidebar-subagent-activity.e2e.ts",
"apps/web/tests/background-job-list.e2e.ts",
"apps/web/tests/jobs-menu-layering.e2e.ts",
"apps/web/tests/live-job-stream.e2e.ts",
"apps/web/tests/bash-abort-row.e2e.ts",
"apps/web/tests/skill-tool-row.e2e.ts",