fix(client): polish context menu styling and session action groups

This commit is contained in:
yixiangihsiang
2026-10-22 21:21:40 +08:00
parent a5f17a9933
commit b03c351c0a
39 changed files with 221 additions and 91 deletions
+3 -3
View File
@@ -94,7 +94,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await observe('menu-open', false, true)
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('separator').count()).toBe(1)
expect(await menu.getByRole('separator').count()).toBe(3)
await compareOrRefreshGolden(
SESSION_ACTION_EXPECTED,
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd),
@@ -193,13 +193,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.hover()
await trigger.click()
await menu.waitFor()
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID']
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Archive session']
const remainingItems = menu.getByRole('menuitem')
expect(await remainingItems.count()).toBe(remainingNames.length)
for (const [index, name] of remainingNames.entries()) {
expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1)
}
expect(await menu.getByRole('separator').count()).toBe(0)
expect(await menu.getByRole('separator').count()).toBe(2)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click()
await expect.poll(() => menu.count()).toBe(0)
+24
View File
@@ -64,6 +64,29 @@ describe('web context menus', () => {
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
const names = await menuLabels(menu)
expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder'])
expect(names.at(-1)).toBe('Archive session')
expect(await menu.getByRole('separator').count()).toBe(2)
const alignedSeparators = async () => {
const bounds = await menu.evaluate((node) => {
const row = node.querySelector<HTMLElement>('[role="menuitem"]')!
const rect = row.getBoundingClientRect()
const style = getComputedStyle(row)
return {
left: rect.left + Number.parseFloat(style.paddingLeft),
right: rect.right - Number.parseFloat(style.paddingRight),
lines: [...node.querySelectorAll('[role="separator"]')].map((line) => {
const rect = line.getBoundingClientRect()
return { left: rect.left, right: rect.right }
}),
}
})
for (const line of bounds.lines) {
expect(line.left).toBeCloseTo(bounds.left, 1)
expect(line.right).toBeCloseTo(bounds.right, 1)
}
}
await alignedSeparators()
await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/session.txt'))
expect(names.some(name => name.includes('Rename'))).toBe(true)
expect(await background.getAttribute('aria-selected')).toBe('false')
@@ -71,6 +94,7 @@ describe('web context menus', () => {
await background.getByRole('button', { name: /actions/i }).click()
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
expect(await menuLabels(menu)).toEqual(names)
await alignedSeparators()
await background.getByRole('button', { name: /actions/i }).click()
expect(await menu.count()).toBe(0)
expect(await background.getAttribute('aria-selected')).toBe('false')
@@ -2,8 +2,10 @@
- menuitem "Pin session"
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- separator
- menuitem "Copy Session ID"
- separator
- menuitem "Export session"
- menuitem "Copy session ID"
- separator
- menuitem "Archive session"
@@ -1,6 +1,6 @@
Pin session
Rename
Fork session
Archive session
Copy Session ID
Show in folder
Archive session
@@ -1,24 +1,24 @@
{
"web-light": {
"fill": "rgba(248, 249, 250, 0.58)",
"fill": "rgba(252, 253, 254, 0.7)",
"blur": "blur(40px) saturate(1.5)",
"radius": "16px",
"shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px"
},
"web-dark": {
"fill": "rgba(67, 69, 74, 0.45)",
"fill": "rgba(48, 49, 54, 0.64)",
"blur": "blur(40px) saturate(1.5)",
"radius": "16px",
"shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px"
},
"darwin-light": {
"fill": "rgba(248, 249, 250, 0.58)",
"fill": "rgba(252, 253, 254, 0.7)",
"blur": "blur(40px) saturate(1.5)",
"radius": "16px",
"shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px"
},
"darwin-dark": {
"fill": "rgba(67, 69, 74, 0.45)",
"fill": "rgba(48, 49, 54, 0.64)",
"blur": "blur(40px) saturate(1.5)",
"radius": "16px",
"shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px"
@@ -2,5 +2,7 @@
- menuitem "Pin session"
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- separator
- menuitem "Copy Session ID"
- separator
- menuitem "Archive session"
@@ -2,6 +2,8 @@
- menuitem "Pin session"
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- separator
- menuitem "Copy Session ID"
- menuitem "Show in Finder"
- separator
- menuitem "Archive session"
+9 -4
View File
@@ -49,13 +49,13 @@ it('shares menu transparency and blur across palettes and follows native menu bo
})
expect(overlayFill).toBe(platform === 'darwin'
? dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)'
: dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)')
: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)')
await account.click()
const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Settings', exact: true }) })
await menu.waitFor()
const appearance = await material(menu)
expect(appearance).toMatchObject({
fill: dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)',
fill: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)',
blur: 'blur(40px) saturate(1.5)',
})
expect(appearance.shadow).toContain(`${dark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.04)'} 0px 0px 0px 0.5px`)
@@ -150,9 +150,14 @@ it('shares menu transparency and blur across palettes and follows native menu bo
})
const settled = await sharp(await page.screenshot({ clip })).stats()
expect(settled.channels[0]!.stdev).toBeLessThan(3)
await popup.evaluate((node) => { node.style.opacity = '0.999' })
await popup.evaluate((node) => {
node.style.opacity = '0.999'
// The negative control measures backdrop isolation independently of the theme's fill alpha.
const material = node.querySelector<HTMLElement>(':scope > [aria-hidden="true"]')!
material.style.backgroundColor = 'transparent'
})
const isolated = await sharp(await page.screenshot({ clip })).stats()
expect(isolated.channels[0]!.stdev).toBeGreaterThan(50)
expect(isolated.channels[0]!.stdev).toBeGreaterThan(90)
} finally {
await stripes.evaluate((node) => { node.remove() })
await stripes.dispose()
@@ -18,8 +18,8 @@
en: 056e2bd9e3c78dd8
zh: be35d2ba53be0442
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: d420ddbdb0fa1dde
zh: 1136fdf9d18affa7
en: 0d655005b0613737
zh: 5029f1394197dadd
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
en: a0304e1bd9b44370
zh: af0fdfd01c296dce
+1 -1
View File
@@ -37,7 +37,7 @@ File menus list the discovered associated applications without a separate “Ope
Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control.
Context menus contribute file preview, native application and reveal, original save, URL navigation and copying, and image actions through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file or native saved reference. [ui-file-actions](../ui-file-actions/README.md) owns original bytes and clipboard encoding.
Context menus contribute file preview, native application and reveal, original save, URL navigation and copying, and image actions through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers at order 500 in `sidebar.workspaces.session.menu.item`, between Copy session ID and Archive. Its failures use the shared Toast after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file or native saved reference. [ui-file-actions](../ui-file-actions/README.md) owns original bytes and clipboard encoding.
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
+1 -1
View File
@@ -37,7 +37,7 @@ kind: "package-reference"
使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开与定位、原始文件保存、链接导航与复制、图片操作。会话定位沿用已安装文件管理器目录。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件或原生保存引用。[ui-file-actions](../ui-file-actions/README.zh.md) 负责原始字节与剪贴板编码。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开与定位、原始文件保存、链接导航与复制、图片操作。会话定位沿用已安装文件管理器目录,以顺序 500 注册在 `sidebar.workspaces.session.menu.item`,位于复制 Session ID 与归档之间。失败提示通过共享 Toast 在菜单关闭后显示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件或原生保存引用。[ui-file-actions](../ui-file-actions/README.zh.md) 负责原始字节与剪贴板编码。
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
+4 -2
View File
@@ -53,7 +53,8 @@
"@deepseek-ai/dsh-client-ui-context-menu",
"@deepseek-ai/dsh-client-ui-file-actions",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-api-workspace-files"
"@deepseek-ai/dsh-api-workspace-files",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
@@ -89,6 +90,7 @@
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
"@deepseek-ai/dsh-api-workspace-files": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*",
"mime": "^4.1.0"
"mime": "^4.1.0",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*"
}
}
@@ -0,0 +1,33 @@
/** Native directory reveal in the shared Session action list. */
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { IconFolderOpenOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import type { OpenInAppKey } from './locales.ts'
/** Installed directory applications and a launch callback that reports failures through a toast. */
export interface SessionRevealInjected {
hooks: { directoryApps: ObservableSnapshot<readonly string[] | null> }
reveal: (application: string, directory: string) => Promise<void>
}
type Props = Pick<PropsRuntime<'sidebar.workspaces.session.menu.item'>, 'sessionId' | 'useSessions' | 'useMenuOpenState'> & PropsLocale<'open-in-app'> & InjectFace<SessionRevealInjected>
/**
* Reveal the captured Session directory without changing the selected Session.
* @param props - Session identity, availability, localized labels, and native launch.
* @returns The directory action between copy ID and archive.
*/
export function SessionRevealMenuItem({ sessionId, useSessions, useMenuOpenState, useDirectoryApps, reveal, t }: Props) {
const directory = useSessions(state => state.byId[sessionId]?.cwd)
const applications = useDirectoryApps(value => value)
const manager = applications?.find(id => ['finder', 'explorer', 'filemanager'].includes(id))
const [, setMenuOpen] = useMenuOpenState()
const label: OpenInAppKey = applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'
return <MenuItemButton icon={<IconFolderOpenOutlineRegular />} disabled={!directory || manager === undefined}
onSelect={() => {
if (!directory || manager === undefined) return
setMenuOpen(false)
void reveal(manager, directory)
}}>{t(label)}</MenuItemButton>
}
@@ -43,17 +43,6 @@ export function registerContextMenus(ctx: Context, paths: OpenInAppPathControlle
return undefined
}
}
ctx.effect(() => ctx.menus.register({
id: 'open-in-app.session', target: 'session', order: 500,
resolve: async ({ sessionId }, signal) => {
await directories.load()
signal.throwIfAborted()
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const manager = directories.apps.getSnapshot()?.find(id => ['finder', 'explorer', 'filemanager'].includes(id))
return [{ id: 'reveal', symbol: 'folder', afterContent: true, label: fileManager(), disabled: !cwd || manager === undefined,
...cwd && manager ? { run: () => directories.launch(manager, cwd) } : {} }]
},
}), 'open-in-app: Session context menu')
ctx.effect(() => ctx.menus.register({
id: 'open-in-app.file', target: 'file', order: 100,
resolve: async (target, signal) => {
@@ -23,6 +23,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { OpenInAppController } from './controller.ts'
import { SessionRevealMenuItem, type SessionRevealInjected } from './SessionRevealMenuItem.tsx'
import { OpenInAppAction, type OpenInAppActionInjected, type OpenInAppActionProps } from './OpenInAppAction.tsx'
import { OpenInAppPathController } from './open-path.ts'
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
@@ -57,6 +58,20 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'open-in-app: dictionaries')
const t = ctx.locale.bind(NS)
registerContextMenus(ctx, paths, t, controller)
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
name: 'sidebar.workspaces.session.menu.item', id: 'open-in-app.reveal', order: 500, locale: NS,
inject: (): SessionRevealInjected => ({
hooks: { directoryApps: controller.apps },
reveal: async (application, directory) => {
try {
await controller.launch(application, directory)
} catch (_error) {
// The shared toast remains visible after the Session menu closes.
ctx.menus.notify(t('path.revealError'))
}
},
}),
}, SessionRevealMenuItem))
const target = () => {
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== null) return undefined
const session = Object.values(ctx.sessions.list.getSnapshot().byId)
@@ -22,6 +22,7 @@ import { apply as nodeApply } from '../src/index.ts'
import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
import { OpenPathAction } from '../src/client/OpenPathAction.tsx'
import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx'
import { SessionRevealMenuItem, type SessionRevealInjected } from '../src/client/SessionRevealMenuItem.tsx'
import { en, NS, zh } from '../src/client/locales.ts'
afterEach(() => {
@@ -45,6 +46,7 @@ async function bench() {
ctx.slots.register({
name: 'root',
children: {
'sidebar.workspaces.session.menu.item': { kind: 'list', scope: 'root' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
'sidebar.right.tab.files.actions': { kind: 'list', scope: 'session' },
'sidebar.right.tab.document.actions': { kind: 'list', scope: 'session' },
@@ -60,7 +62,8 @@ async function bench() {
getSnapshot: () => layoutStore.getSnapshot().panelInfo,
subscribe: listener => layoutStore.subscribe(listener),
})
ctx.provide('menus', { register: () => () => {}, open: vi.fn() } as never)
const notify = vi.fn()
ctx.provide('menus', { register: () => () => {}, open: vi.fn(), notify } as never)
ctx.provide('layout', layout)
ctx.provide('sessions', { list })
ctx.provide('shortcuts', { register: (command: ShortcutCommand) => { commands.set(command.id, command); return () => { commands.delete(command.id) } }, catalog: createSnapshotStore([]) })
@@ -73,7 +76,7 @@ async function bench() {
ctx.provide('remote.session', remote.session as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber, list, commands, layout }
return { ctx, fiber, list, commands, layout, notify }
}
function headerEntryIds(ctx: Context): (string | undefined)[] {
@@ -81,6 +84,26 @@ function headerEntryIds(ctx: Context): (string | undefined)[] {
}
describe('open-in-app browser half', () => {
it('orders Session reveal between copy ID and archive, reports failure, and unregisters on disposal', async () => {
const fetcher = vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps'
? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('', { status: 502 }))
vi.stubGlobal('fetch', fetcher)
const { ctx, fiber, notify } = await bench()
try {
const entry = ctx.slots.entries('sidebar.workspaces.session.menu.item')[0]!
expect(entry.component).toBe(SessionRevealMenuItem)
expect(entry.options).toMatchObject({ id: 'open-in-app.reveal', order: 500 })
const face: Partial<SessionRevealInjected> | undefined = entry.inject?.()
if (face?.hooks === undefined || face.reveal === undefined) throw new Error('Expected Session reveal injection')
await vi.waitFor(() => { expect(face.hooks!.directoryApps.getSnapshot()).toEqual(['finder']) })
await face.reveal('finder', '/captured')
expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({
body: JSON.stringify({ app: 'finder', path: '/captured' }),
}))
expect(notify).toHaveBeenCalledWith(en['path.revealError'])
} finally { await fiber.dispose() }
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
})
it('refuses absent workspaces and reports failed keyboard launches', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps'
? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('failed', { status: 500 })))
@@ -74,21 +74,6 @@ async function boot() {
sidebar, browserTab, notify, failure, writeText }
}
it('reveals only sessions with a directory and an installed file manager', async () => {
const h = await boot()
await h.choose({ kind: 'session', value: { sessionId, displayTitle: 'Session' } }, 'open-in-app.session/reveal')
await vi.waitFor(() => { expect(h.directoryFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
method: 'POST', body: JSON.stringify({ app: 'finder', path: '/workspace' }),
})) })
const withoutDirectory = await h.resolve({ kind: 'session', value: { sessionId: SessionId('absent'), displayTitle: 'Absent' } })
expect(withoutDirectory.source.getSnapshot()[0]).toMatchObject({ disabled: true, label: 'menu.finder' })
h.directories.apps.set(['vscode'])
const unavailable = await h.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Session' } })
expect(unavailable.source.getSnapshot()[0]).toMatchObject({ disabled: true, label: 'menu.reveal' })
h.directories.apps.set(null)
expect((await h.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Session' } })).source.getSnapshot()[0]?.disabled).toBe(true)
})
it('routes file actions through the captured pane, line, native association and original bytes', async () => {
const h = await boot()
const file = { kind: 'file', value: { sessionId, path: 'file.svg', paneId: 'pane' as never, line: 24 } } as const
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { SessionRevealMenuItem } from '../src/client/SessionRevealMenuItem.tsx'
import { en } from '../src/client/locales.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
afterEach(cleanup)
it.each([
[['finder'], '/workspace', 'Show in Finder', false],
[['explorer'], '/workspace', 'Show in folder', false],
[['vscode'], '/workspace', 'Show in folder', true],
[null, '/workspace', 'Show in folder', true],
[['finder'], undefined, 'Show in Finder', true],
] as const)('reveals the captured directory with catalog %j and path %s', (apps, cwd, label, disabled) => {
const sessionId = SessionId('captured')
const state: SessionListState = { ids: [sessionId], byId: {
[sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} },
}, phase: 'ready', projectionsBySession: {} }
const dismiss = vi.fn(), reveal = vi.fn(async () => {})
const props: ComponentProps<typeof SessionRevealMenuItem> = {
sessionId, useSessions: select => select(state), useDirectoryApps: select => select(apps),
useMenuOpenState: () => [true, dismiss], reveal, t: makeTranslate(en, commonEn),
}
const view = render(<SessionRevealMenuItem {...props} />)
const item = view.getByRole('menuitem', { name: label })
expect(item.hasAttribute('disabled')).toBe(disabled)
fireEvent.click(item)
if (disabled) {
expect(reveal).not.toHaveBeenCalled()
expect(dismiss).not.toHaveBeenCalled()
} else {
expect(dismiss).toHaveBeenCalledWith(false)
expect(reveal).toHaveBeenCalledWith(apps?.[0], cwd)
expect(dismiss.mock.invocationCallOrder[0]).toBeLessThan(reveal.mock.invocationCallOrder[0]!)
}
})
@@ -79,6 +79,9 @@
},
{
"path": "../ui-sidebar-browser/tsconfig.client.json"
},
{
"path": "../ui-workspace"
}
]
}
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: d2f5701274be5110
zh: d60e2617136a6f26
en: 18df36d5620dbbd2
zh: fc44b2f843907cc9
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 75e1681dcfc61a14
zh: 3eda3143730f83e1
+1 -1
View File
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
<a id="use-this-package"></a>
## Use this package
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menu row separators align with the horizontal content edges of their rows. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。菜单行的分割线与该行内容区的左右边缘对齐。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
@@ -153,7 +153,7 @@
}
.compactList .separator {
margin: 2px;
margin: 2px 6px;
}
.compactList .label {
@@ -258,11 +258,11 @@
color: var(--dsw-alias-label-tertiary);
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not
* l1, for the same reason as .footer: l1 is near-invisible on this surface. */
/* Separator endpoints align with the row content. The l2 stroke stays visible
* on the menu surface. */
.separator {
height: 0.5px;
margin: 3px 2px;
margin: 3px 8px;
background: var(--dsw-alias-border-l2);
}
+2 -2
View File
@@ -33,8 +33,8 @@
en: 3eb9b0e3365b2db3
zh: c73acc55dd897266
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
en: e0f0bce3e166e71a
zh: bf5a68e7f440aca0
en: e761ede0059e10a6
zh: ec05be6d457c15f5
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
en: bf76e7c34a331595
zh: 5accd0260ef7c61e
+1 -1
View File
@@ -87,7 +87,7 @@ The `--dsw-alias-turn-trigger-*` tokens provide separate resting and hover backg
`corner-shape.css` smooths every rounded corner: inside `@supports (corner-shape: superellipse(1.5))` it defines `--dsw-corner-shape` and applies it to all elements and their `::before`/`::after` through the universal selector, so engines without `corner-shape` keep circular corners. Full-round shapes — `border-radius: 50%` circles and pill radii — pair `corner-shape: round` with their radius in the owning component sheet because a superellipse deforms them; the corner-shape stylesheet spec enforces that pairing across every package stylesheet.
`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small variants stay fixed. The code variants (`--dsw-font-markdown-code`, `-code-block`, `-code-block-small`) shift size and line height by `--dsh-code-font-delta`, the difference between `--dsh-code-font-size` and 11 px. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`), the translucent-menu `--dsw-menu-backdrop-filter`, and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft` (the composer's larger-blur, lower-alpha tier) layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline; the derived tokens are re-declared per element so a surface's stroke-color rebind takes effect. An elevated surface that paints `--dsw-specific-menu` also applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` ([styling reference](../../../docs/web-styling.md#component-rules)). Dark menus use a 45%-opaque gray fill and the `border-l3` stroke; light menus retain their `border-l1` stroke.
`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small variants stay fixed. The code variants (`--dsw-font-markdown-code`, `-code-block`, `-code-block-small`) shift size and line height by `--dsh-code-font-delta`, the difference between `--dsh-code-font-size` and 11 px. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`), the translucent-menu `--dsw-menu-backdrop-filter`, and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft` (the composer's larger-blur, lower-alpha tier) layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline; the derived tokens are re-declared per element so a surface's stroke-color rebind takes effect. An elevated surface that paints `--dsw-specific-menu` also applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` ([styling reference](../../../docs/web-styling.md#component-rules)). Light menus use a 70%-opaque fill and dark menus use a 64%-opaque fill; dark menus use the `border-l3` stroke and light menus use `border-l1`.
`brand-font.css` references the bundled `montserrat-regular.woff2` / `montserrat-light.woff2` / `montserrat-medium.woff2`, Montserrat Regular, Light, and Medium under the SIL Open Font License shipped with the stylesheet and WOFF2 under `lib/styles/`. `--dsw-font-family-brand` selects this face for brand text; ordinary UI keeps the system font stack. The source is Google Fonts' Montserrat distribution. The Web entry imports the package's `./brand-font.css` export so Vite emits and resolves the font asset; the Web build also includes its license. The Web application, including Desktop onboarding, loads the font offline. The native credential welcome retains its system font.
+1 -1
View File
@@ -87,7 +87,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
`corner-shape.css` 平滑所有圆角:在 `@supports (corner-shape: superellipse(1.5))` 内定义 `--dsw-corner-shape`,并通过通配选择器应用到所有元素及其 `::before`/`::after`,因此不支持 `corner-shape` 的引擎保持普通圆弧。正圆形状——`border-radius: 50%` 的圆与胶囊半径——因超级椭圆会使其变形,须在所属组件样式表中把 `corner-shape: round` 与半径声明配对;corner-shape 样式表 spec 跨全部包样式表强制这一配对。
`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13 px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本变体保持固定字号。代码变体(`--dsw-font-markdown-code`、`-code-block`、`-code-block-small`)按 `--dsh-code-font-delta`(`--dsh-code-font-size` 与 11 px 之差)调整字号与行高。阶梯之外,用户气泡与 composer 草稿直接读取正文字号变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)、半透明菜单使用的 `--dsw-menu-backdrop-filter` 与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5 px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft`(composer 专用的更大模糊、更低透明度档)在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不会产生占布局的轮廓;派生 token 逐元素重声明,使表面对描边色的重绑真实生效。绘制 `--dsw-specific-menu` 的高层级表面还会应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`([样式参考](../../../docs/web-styling.zh.md#component-rules))。 深色菜单使用不透明度为 45% 的灰色底与 `border-l3` 描边;浅色菜单保留 `border-l1` 描边。
`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13 px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本变体保持固定字号。代码变体(`--dsw-font-markdown-code`、`-code-block`、`-code-block-small`)按 `--dsh-code-font-delta`(`--dsh-code-font-size` 与 11 px 之差)调整字号与行高。阶梯之外,用户气泡与 composer 草稿直接读取正文字号变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)、半透明菜单使用的 `--dsw-menu-backdrop-filter` 与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5 px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft`(composer 专用的更大模糊、更低透明度档)在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不会产生占布局的轮廓;派生 token 逐元素重声明,使表面对描边色的重绑真实生效。绘制 `--dsw-specific-menu` 的高层级表面还会应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`([样式参考](../../../docs/web-styling.zh.md#component-rules))。 浅色菜单使用不透明度为 70% 的填充,深色菜单使用不透明度为 64% 的填充;深色菜单使用 `border-l3` 描边,浅色菜单使用 `border-l1` 描边。
`brand-font.css` 引用随包提供的 `montserrat-regular.woff2` / `montserrat-light.woff2` / `montserrat-medium.woff2`,其中包含 Montserrat Regular、Light 和 Medium 字体,SIL Open Font License 与样式表和 WOFF2 一同随包保存在 `lib/styles/`。`--dsw-font-family-brand` 为品牌文字选择该字体,普通界面仍使用系统字体栈。源文件来自 Google Fonts 的 Montserrat 发布。Web 入口导入包的 `./brand-font.css` 导出,由 Vite 输出并解析字体资源,Web 构建也包含其许可。Web 应用(包括 Desktop 引导)可离线加载字体;原生凭证欢迎页保留系统字体。
@@ -280,7 +280,7 @@ body {
--dsw-specific-bubble: var(--dsw-static-deepseek-50);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-50);
--dsw-menu-surface-fill: rgba(248, 249, 250, 0.58);
--dsw-menu-surface-fill: rgba(252, 253, 254, 0.7);
--dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-60);
@@ -403,7 +403,7 @@ body[data-ds-dark-theme] {
--dsw-specific-bubble: var(--dsw-static-neutral-bluish-850);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-850);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-900);
--dsw-menu-surface-fill: rgba(67, 69, 74, 0.45);
--dsw-menu-surface-fill: rgba(48, 49, 54, 0.64);
--dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-800);
@@ -64,8 +64,8 @@ describe('elevation tokens', () => {
.filter(rule => rule.selectors.includes(selector))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === token)?.[1]
expect(value('body')).toBe('rgba(248, 249, 250, 0.58)')
expect(value('body[data-ds-dark-theme]')).toBe('rgba(67, 69, 74, 0.45)')
expect(value('body')).toBe('rgba(252, 253, 254, 0.7)')
expect(value('body[data-ds-dark-theme]')).toBe('rgba(48, 49, 54, 0.64)')
expect(value('body', '--dsw-specific-menu')).toBe('var(--dsw-menu-surface-fill)')
expect(value("html[data-platform='darwin'] body", '--dsw-specific-menu')).toBe('rgba(248, 249, 250, 0.94)')
expect(value("html[data-platform='darwin'] body[data-ds-dark-theme]", '--dsw-specific-menu')).toBe('rgba(48, 49, 54, 0.94)')
@@ -27,14 +27,14 @@
en: 29facc4eae028dae
zh: 754ebac4cc294e65
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 353ed9b66fab6486
zh: e38a4a37627a9426
en: 48d225818b93d7b6
zh: 5f8258c766b56adb
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: a772a5db0103c548
zh: 1f27b7483c1d37fa
en: 9d875ebe74acb658
zh: 6bfe97cba9e31ea0
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 96a37e74555a36b7
zh: d03941523509a5cf
@@ -42,8 +42,8 @@
en: 868904dbfb5713f8
zh: 225906bfef7398bf
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions:
en: 5bbba6e462faf3dc
zh: 4734263208ee07c9
en: b2d766c9f9eca8b7
zh: cb4f3cde3ce02c18
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin:
en: 36934671b80b11ae
zh: 8e0aea6162e35e77
+3 -3
View File
@@ -53,7 +53,7 @@ Collapsed search is one header action beside the view and add actions: activatin
### Managing sessions
The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host.
The Copy Session ID item is in the identification group of a Session row's "..." menu. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. When the click that activates an unfocused window is not delivered to the page, as on macOS, the next click does not open Rename. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
@@ -71,7 +71,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr
Grouped and flat Session rows show a clock mark when the Session has active scheduled tasks. It is an occupant of the row's `sidebar.session.row.leading` seat, so it renders only on a row whose primary status is idle and never beside the row's own status dot; an archived row keeps that cell blank, and a search result has no leading seat and shows no mark. It is not a button, has no tab stop, and a press on its area does not open the row. The mark's own read and the meaning of its active-task condition belong to [ui-schedule](../ui-schedule/README.md).
Session rows share their existing action contributions between right-click and the ellipsis trigger through [ui-context-menu](../ui-context-menu/README.md). The captured Session stays the target without changing selection; `sidebar.workspaces.session.menu.item` remains the component extension position.
Session rows share their existing action contributions between right-click and the ellipsis trigger through [ui-context-menu](../ui-context-menu/README.md). The captured Session stays the target without changing selection; `sidebar.workspaces.session.menu.item` remains the component extension position. The shipped groups are Pin, Rename, Fork; Copy session ID and the optional native directory reveal; then Archive, with separators before Copy session ID and Archive.
-----
@@ -113,7 +113,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
### Session row actions
The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action.
The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `session-id` (450), and `archive` (menu 600, button 100) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action.
An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive, and fork renders nothing when `useSessionAccess(sessionId, 'session/fork')` is false), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request.
+3 -3
View File
@@ -53,7 +53,7 @@ kind: "package-reference"
### 管理会话
Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。
“复制 Session ID”位于 Session 行“...”菜单的标识信息组。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。当激活未聚焦窗口的那次点击不会传递给页面时(如在 macOS 上),下一次点击不会打开 Rename。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
@@ -71,7 +71,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
当成组的与平铺的 Session 行所对应的 Session 存在活动定时任务时,该行显示时钟标记。它是行首 `sidebar.session.row.leading` seat 的占用方,因此仅在该行主状态为 idle 时渲染,绝不与行自身的状态点同时出现;归档行该格同样留空,搜索结果没有该 seat,也不显示标记。它不是按钮,没有 Tab stop,点击其所在区域不会打开整行。标记自身的读取及其「活动」条件的确切含义由 [ui-schedule](../ui-schedule/README.zh.md) 负责。
会话行通过 [ui-context-menu](../ui-context-menu/README.zh.md) 在右键与省略号按钮间共享现有操作贡献。操作对象固定为被点击的会话,不改变选中状态;`sidebar.workspaces.session.menu.item` 仍是组件扩展位置。
会话行通过 [ui-context-menu](../ui-context-menu/README.zh.md) 在右键与省略号按钮间共享现有操作贡献。操作对象固定为被点击的会话,不改变选中状态;`sidebar.workspaces.session.menu.item` 仍是组件扩展位置。内置分组依次为置顶、重命名、分叉;复制 Session ID 和可选的本机目录显示;归档,复制 Session ID 与归档之前各有一条分割线。
-----
@@ -113,7 +113,7 @@ Session 行可通过 Tab 聚焦,使用不带修饰键的 Enter 或空格打开
### Session 行 action
Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。
Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`session-id`(450)和 `archive`(菜单 600、按钮 100),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。
条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染;`useSessionAccess(sessionId, 'session/fork')` 为 false 时 fork 不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。
@@ -152,7 +152,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* The rows of one Session's "..." menu, in ascending `order`. ui-workspace
* registers the shipped rows here — `pin` (100), `rename` (200), `fork`
* (300), `archive` (400), and `session-id` (450) — so a plugin row is placed
* (300), `session-id` (450), and `archive` (600) — so a plugin row is placed
* by its own `order` among them. Use a package-namespaced `id`; reusing a shipped id at
* another `priority` shadows that row. Each entry renders one
* `role="menuitem"` `<button>` (the shipped rows use ui-primitives'
@@ -326,7 +326,7 @@ export function apply(ctx: Context): void {
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'pin', order: 100, locale: NS, inject: pinInjected }, PinSessionMenuItem)
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'rename', order: 200, locale: NS, inject: renameInjected }, RenameSessionMenuItem)
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'fork', order: 300, locale: NS, inject: forkInjected }, ForkSessionMenuItem)
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'archive', order: 400, locale: NS, inject: archiveInjected }, ArchiveSessionMenuItem)
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'archive', order: 600, locale: NS, inject: archiveInjected }, ArchiveSessionMenuItem)
yield ctx.slots.register({ name: 'sidebar.workspaces.session.menu.item', id: 'session-id', order: 450, locale: NS, inject: sessionIdInjected }, SessionIdMenuItem)
})
ctx.slots.inject('sidebar.workspaces.session.row.action', function* () {
@@ -24,7 +24,7 @@ import css from '../rows/Rows.module.css'
import browserCss from '../rows/WorkspaceBrowser.module.css'
/**
* Menu row (order 400): archive, or restore an archived row.
* Menu row (order 600): archive, or restore an archived row.
* @param props - owner share, the archive share, and the menu open state.
* @returns the row.
*/
@@ -36,6 +36,7 @@ export function ArchiveSessionMenuItem({
const archived = useArchived(set => set.has(sessionId))
return (
<MenuItemButton
separatorBefore
shortcut={archived ? undefined : shortcut}
icon={archived ? <IconUnarchiveOutlineRegular size={14} /> : <IconArchiveOutlineRegular size={14} />}
onSelect={() => {
@@ -5,12 +5,12 @@ import type { SessionIdInjected, SessionMenuItemProps } from '../contract/slots.
/**
* Copy the complete ID after dismissing the row menu.
* @param props - Row identity, menu dismissal, clipboard action, and locale.
* @returns The final built-in menu item.
* @returns The copy action at the start of the identification group.
*/
export function SessionIdMenuItem({ sessionId, useMenuOpenState, copySessionId, t }: SessionMenuItemProps<SessionIdInjected>) {
const [, setMenuOpen] = useMenuOpenState()
return (
<MenuItemButton icon={<IconCopyOutlineRegular />} onSelect={() => {
<MenuItemButton separatorBefore icon={<IconCopyOutlineRegular />} onSelect={() => {
setMenuOpen(false)
void copySessionId(sessionId)
}}>
@@ -312,8 +312,8 @@ describe('ui-workspace apply', () => {
['pin', 100, PinSessionMenuItem, 'workspace'],
['rename', 200, RenameSessionMenuItem, 'workspace'],
['fork', 300, ForkSessionMenuItem, 'workspace'],
['archive', 400, ArchiveSessionMenuItem, 'workspace'],
['session-id', 450, SessionIdMenuItem, 'workspace'],
['archive', 600, ArchiveSessionMenuItem, 'workspace'],
])
expect(rows(ROW_ACTION)).toEqual([
['archive', 100, ArchiveSessionRowButton, 'workspace'],
@@ -113,12 +113,8 @@ describe('session rename through the assembled browser', () => {
},
)
}
// `order` places plugin rows after the shipped rows (100/200/300/400/450)
// even when the later registration has the lower shadowing priority
// assigned to dynamic browser packages; the first plugin row opens the
// plugin group with a hairline.
registerAction('export', 500, -1, 'Export action')
registerAction('last', 600, -2, 'Last action')
registerAction('last', 700, -2, 'Last action')
const view = runtime.renderRoot()
const row = (await view.findByText('Persisted title')).closest('[role="treeitem"]')!
@@ -126,15 +122,17 @@ describe('session rename through the assembled browser', () => {
fireEvent.click(trigger)
await view.findByRole('menuitem', { name: '置顶会话' })
expect(view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'置顶会话', '重命名', '分叉会话', '归档会话', '复制 Session ID', 'Export action', 'Last action',
'置顶会话', '重命名', '分叉会话', '复制 Session ID', 'Export action', '归档会话', 'Last action',
])
expect(view.getAllByRole('separator')).toHaveLength(1)
expect(view.getAllByRole('separator')).toHaveLength(3)
const last = view.getByRole('menuitem', { name: 'Last action' })
const exportRow = view.getByRole('menuitem', { name: 'Export action' })
trigger.focus()
fireEvent.keyDown(trigger, { key: 'End' })
expect(document.activeElement).toBe(last)
fireEvent.keyDown(last, { key: 'ArrowUp' })
expect(document.activeElement).toBe(view.getByRole('menuitem', { name: '归档会话' }))
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(document.activeElement).toBe(exportRow)
fireEvent.click(exportRow)
expect(selected).toHaveBeenCalledWith('export', SID, 'Persisted title')
@@ -4851,7 +4851,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
kind: 'list',
scope: 'root',
summary: 'The rows of one Session\'s "..." menu, in ascending `order`.',
doc: 'The rows of one Session\'s "..." menu, in ascending `order`. ui-workspace\nregisters the shipped rows here — `pin` (100), `rename` (200), `fork`\n(300), `archive` (400), and `session-id` (450) — so a plugin row is placed\nby its own `order` among them. Use a package-namespaced `id`; reusing a shipped id at\nanother `priority` shadows that row. Each entry renders one\n`role="menuitem"` `<button>` (the shipped rows use ui-primitives\'\n`MenuItemButton`, which adds the host styling and `separatorBefore`),\ndecides its own visibility from its own state, and dismisses the menu\nthrough the injected `useMenuOpenState` hook after acting; the list\'s\nkeyboard walk and focus return read the DOM, so any such button joins\nthem. Labels come from the contributing package\'s locale namespace.',
doc: 'The rows of one Session\'s "..." menu, in ascending `order`. ui-workspace\nregisters the shipped rows here — `pin` (100), `rename` (200), `fork`\n(300), `session-id` (450), and `archive` (600) — so a plugin row is placed\nby its own `order` among them. Use a package-namespaced `id`; reusing a shipped id at\nanother `priority` shadows that row. Each entry renders one\n`role="menuitem"` `<button>` (the shipped rows use ui-primitives\'\n`MenuItemButton`, which adds the host styling and `separatorBefore`),\ndecides its own visibility from its own state, and dismisses the menu\nthrough the injected `useMenuOpenState` hook after acting; the list\'s\nkeyboard walk and focus return read the DOM, so any such button joins\nthem. Labels come from the contributing package\'s locale namespace.',
registerOptions: [
{
name: 'id',
@@ -4894,6 +4894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '{ hooks: { menuOpenState: SlotHookFactory<\'sidebar.workspaces.session.menu.item\', UseMenuOpenState> shortcuts: HostObservable<readonly ShortcutCatalogEntry[]> } }',
declaredBy: 'an entry in \'contextMenu.content\' (client-ui-workspace), so it exists while that entry is mounted',
occupants: [
'client-ui-open-in-app SessionRevealMenuItem id \'open-in-app.reveal\'',
'client-ui-workspace PinSessionMenuItem id \'pin\'',
'client-ui-workspace RenameSessionMenuItem id \'rename\'',
'client-ui-workspace ForkSessionMenuItem id \'fork\'',
+3
View File
@@ -4903,6 +4903,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:*
version: link:../ui-slots
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:*
version: link:../ui-workspace
'@deepseek-ai/dsh-host-open-in-app':
specifier: workspace:*
version: link:../../host/open-in-app