'blob:original')}
- variant="single" labels={labels} openImageMenu={openImageMenu} />)
+ variant="single" labels={labels} getImageMenuProps={contextMenuHook(openImageMenu)().getTriggerProps} />)
const image = await view.findByAltText('history.png')
fireEvent.contextMenu(image, { clientX: 18, clientY: 29 })
expect(openImageMenu).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'blob:original', name: 'history.png' }), { x: 18, y: 29 })
diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts
index db177c89fe..706dc4531b 100644
--- a/packages/client/ui-attachment/tests/plugin.client.spec.ts
+++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts
@@ -21,7 +21,7 @@ async function bench() {
await ctx.fiber.dispose()
})
ctx.provide('sessions', sessions)
- ctx.provide('menus', { open: vi.fn() } as never)
+ ctx.provide('contextMenus', { open: vi.fn() } as never)
ctx.provide('locale', new LocaleRuntime(ctx))
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
@@ -50,7 +50,7 @@ describe('attachment plugin', () => {
it('registers all entries and removes them with the plugin fiber', async () => {
const { ctx, fiber, register, registerFactory, readFileDrag } = await bench()
- expect(inject).toEqual(['slots', 'fileDrag', 'menus'])
+ expect(inject).toEqual(['slots', 'fileDrag'])
expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
})
diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json
index 075a30e9f8..853d6a70e2 100644
--- a/packages/client/ui-attachment/tsconfig.json
+++ b/packages/client/ui-attachment/tsconfig.json
@@ -46,9 +46,6 @@
},
{
"path": "../ui-context-menu"
- },
- {
- "path": "../../util/context-menu"
}
]
}
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 9f192f6382..b8748707e4 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -16,12 +16,12 @@
zh: 3172d1a2acbb27b7
/deepseek-ai-dsh-client-ui-chat/reference-previews:
en: 1333217883f84d52
- zh: 4ad049b97fbc39dc
+ zh: 7b26db26cde15c1e
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: cbf1f0a2205046a5
zh: 2e59d0815351349f
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
- en: aeeafe2f987fb899
+ en: 22e121f51bbfd863
zh: aab4c04d43520460
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
en: cfdfe3d99d3f4e09
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 15e37e17ab..ef56ead2ae 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -63,7 +63,7 @@ When an Assistant attempt retires without a visible message, Chat hides its alre
Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. A Turn that restart recovery closed as `interrupted` renders a localized stopped row stating that the DSH process exited mid-Turn, without a code chip, and its process summary reads Stopped.
-Reference components declare their context-menu targets through the scoped Markdown delegate. Tool-file controls receive `openContextMenu` explicitly through the flow and node Slots. Both use the recorded path rather than a display label. Chat supplies Session and path context without interpreting plugin DOM attributes or undeclared links. Shift+F10 and the context key open the focused reference menu; images and editable controls keep their own handlers.
+Reference components declare their context-menu targets through the scoped Markdown delegate. Tool-file controls receive `getContextMenuProps` explicitly through the flow and node Slots. Both use the recorded path rather than a display label. Chat supplies Session and path context without interpreting plugin DOM attributes or undeclared links. Shift+F10 and the context key open the focused reference menu; images and editable controls keep their own handlers.
-----
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 3ddde46f6f..93f4fab980 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -37,7 +37,7 @@ kind: "package-reference"
已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。
-引用组件通过带作用域的 Markdown delegate 声明右键目标。工具文件控件通过消息流和节点 Slot 显式接收 `openContextMenu`。两者均使用记录的路径,而不是显示标签。Chat 补齐 Session 与路径上下文,不解释插件 DOM 属性或未声明的链接。Shift+F10 和菜单键打开焦点引用的菜单;图片与可编辑控件保留自己的处理器。
+引用组件通过带作用域的 Markdown delegate 声明右键目标。工具文件控件通过消息流和节点 Slot 显式接收 `getContextMenuProps`。两者均使用记录的路径,而不是显示标签。Chat 补齐 Session 与路径上下文,不解释插件 DOM 属性或未声明的链接。Shift+F10 和菜单键打开焦点引用的菜单;图片与可编辑控件保留自己的处理器。
原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index a677dbd54c..c8a468b48c 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -99,8 +99,7 @@
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*",
- "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
- "@deepseek-ai/dsh-util-context-menu": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 47a2a9990b..6c6e497b92 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -77,7 +77,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
/** Services required by the Chat target and its presentation registrations. */
export const inject = [
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
- 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag', 'menus',
+ 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag',
]
/**
@@ -277,18 +277,18 @@ export function apply(ctx: Context): void {
else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
await Promise.resolve()
},
- openContextMenu: (target, point) => {
+ resolveContextMenuTarget: (target) => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const current = session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined
const path = (value: string) => resolveWorkspacePath(current ?? cwd, value)
switch (target.kind) {
- case 'file': ctx.menus.open({ kind: 'file', value: { sessionId, path: target.base === 'workspace' ? resolveWorkspacePath(cwd, target.path) : path(target.path),
- ...(target.line === undefined ? {} : { line: target.line }) } }, point); break
- case 'url': ctx.menus.open({ kind: 'url', value: { sessionId, url: target.url } }, point); break
- case 'image': ctx.menus.open({ kind: 'image', value: { sessionId, url: target.url,
+ case 'file': return { kind: 'file', value: { sessionId, path: target.base === 'workspace' ? resolveWorkspacePath(cwd, target.path) : path(target.path),
+ ...(target.line === undefined ? {} : { line: target.line }) } }
+ case 'url': return { kind: 'url', value: { sessionId, url: target.url } }
+ case 'image': return { kind: 'image', value: { sessionId, url: target.url,
...(target.name === undefined ? {} : { name: target.name }),
...(target.mediaType === undefined ? {} : { mediaType: target.mediaType }),
- ...(target.path === undefined ? {} : { path: path(target.path) }) } }, point); break
+ ...(target.path === undefined ? {} : { path: path(target.path) }) } }
}
},
openExternalLink: (url) => {
diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
index 455f90f9a2..d39cd8ed30 100644
--- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
@@ -8,7 +8,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace(() => {
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
- return { turn, seq: data.finalNode.seq, openFile }
- }, [data.finalNode, openFile, tail, turn])
+ return { turn, seq: data.finalNode.seq, openFile, getContextMenuProps }
+ }, [data.finalNode, openFile, getContextMenuProps, tail, turn])
const mentions = useMemo(
() => owner === undefined ? undefined : fileMentions(owner),
[fileMentions, owner],
diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
index f6f3d46a12..07a096e9c2 100644
--- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
@@ -27,7 +27,7 @@ export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState,
useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction,
- cwd, openFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
+ cwd, openFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}: ChatFlowSlotProps) {
const nodeStore = useChat(snapshot => snapshot.nodes)
const running = useSession(snapshot => snapshot.running)
@@ -47,7 +47,7 @@ export const ChatFlow = memo(function ChatFlow({
const seatProps = {
nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation,
useStore, actions, renderSlot, t, useGroupAction, deferCollapse,
- cwd, openFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
+ cwd, openFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}
const rows = entries.map((entry) => {
switch (entry.kind) {
diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
index f5875aed6f..4e32eedafa 100644
--- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
@@ -49,7 +49,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
*/
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
- cwd, openFile, openContextMenu, inspectCall, forkAt,
+ cwd, openFile, getContextMenuProps, inspectCall, forkAt,
loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction,
}: ChatNodeSeatProps) {
const bottom = useChatNodeBottom(nodeKey)
@@ -135,14 +135,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
...groupPart === undefined ? {} : { groupPart },
cwd,
openFile,
- openContextMenu,
+ getContextMenuProps,
inspectCall,
forkAt,
loadImage,
fileMentions,
turnProcess,
}, [
- node, groupPart, cwd, openFile, openContextMenu, inspectCall, forkAt,
+ node, groupPart, cwd, openFile, getContextMenuProps, inspectCall, forkAt,
loadImage, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index f0ad4b0cc4..ca944577a4 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -60,9 +60,13 @@ const MOTION_TAIL_MS = 800
*/
export function ChatView({
useSession, useChat, useConversation, useSessions, renderSlot,
- sessionId, openFile, openExternalLink, openContextMenu, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
+ sessionId, openFile, openExternalLink, resolveContextMenuTarget, useContextMenu,
+ loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag,
}: ChatViewSlotProps) {
+ const { getTriggerProps } = useContextMenu()
+ const getContextMenuProps = useCallback>(
+ target => getTriggerProps(() => resolveContextMenuTarget(target)), [getTriggerProps, resolveContextMenuTarget])
const order = useChat(s => s.order)
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
const entries = useMemo(() => groupedEntries
@@ -231,14 +235,14 @@ export function ChatView({
)}
{renderSlot('conversation.chat.flow', {
entries, pendingInputs, lastInputTurn, deferCollapse,
- cwd, openFile: requestOpenFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
+ cwd, openFile: requestOpenFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}, { hookContext: flowContext })}
mode) === 'detailed'
const forkAllowed = useAccess('session/fork')
@@ -45,7 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
: undefined
if (turn === undefined) return null
const closing = data.closing
- const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
+ const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile, getContextMenuProps }
const tail = renderSlot('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null :
{tail}
// Interruption-frozen partials carry no messageId, so they address no
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 08be32417e..354235c7e9 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -60,6 +60,7 @@ export interface TurnTailOwnerProps {
turn: TurnLocation
seq: number
openFile: (path: string) => void
+ getContextMenuProps?: MarkdownDelegate['getContextMenuProps']
}
/** Owner currency of finalized-assistant actions. */
@@ -231,7 +232,7 @@ export interface ChatNodeOwnerProps {
cwd?: string | undefined
openFile: (path: string, options?: OpenFileOptions) => void
/** Object-menu callback bound to the originating Session and path base. */
- openContextMenu?: MarkdownDelegate['openContextMenu']
+ getContextMenuProps?: MarkdownDelegate['getContextMenuProps']
inspectCall: ((callId: ToolCallId) => void) | undefined
forkAt: (seq: number) => void
/** Session-authorized image loader supplied by the Chat view. */
@@ -317,8 +318,8 @@ export interface PerformanceUsageInjected {
export interface ChatViewInjected {
/** File preparation and failure callbacks owned by the file-drag plugin. */
readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
- /** Open the captured reference without navigating or changing Session selection. */
- openContextMenu: NonNullable
+ /** Resolve the reference against this Session and its current path base when the gesture begins. */
+ resolveContextMenuTarget: (target: import('@deepseek-ai/dsh-client-ui-primitives').MarkdownMenuTarget) => import('@deepseek-ai/dsh-client-ui-context-menu/client').MenuTarget
/** Dispatch one gesture to the Conversation plugin registry. */
dispatchInteraction: (gesture: TextInteractionGesture) => string | null
hooks: {
@@ -362,7 +363,7 @@ export type ChatFlowDataInjected = Pick & {
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick {
+ 'cwd' | 'openFile' | 'getContextMenuProps' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
readonly entries: readonly RenderEntry[]
readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[]
readonly lastInputTurn: number | undefined
diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
index 3154e8c952..effde9971e 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -317,28 +317,23 @@ describe('Chat inject API', () => {
it('resolves relative file paths against the Session\'s current working directory', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
- vi.spyOn(b.runtime.ctx.menus, 'open')
+ vi.spyOn(b.runtime.ctx.contextMenus, 'open')
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
- injected.openContextMenu({ kind: 'file', path: 'src/a.ts', line: 7 }, { x: 1, y: 2 })
- expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: {
+ expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'src/a.ts', line: 7 })).toEqual({ kind: 'file', value: {
sessionId: ROOT, path: '/proj/.agents/worktrees/w/src/a.ts', line: 7,
- } }, { x: 1, y: 2 })
- injected.openContextMenu({ kind: 'file', path: 'src/a.ts', base: 'workspace' }, { x: 3, y: 4 })
- expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: {
+ } })
+ expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'src/a.ts', base: 'workspace' })).toEqual({ kind: 'file', value: {
sessionId: ROOT, path: '/proj/src/a.ts',
- } }, { x: 3, y: 4 })
- injected.openContextMenu({ kind: 'url', url: 'https://example.com/' }, { x: 5, y: 6 })
- expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'url', value: { sessionId: ROOT, url: 'https://example.com/' } }, { x: 5, y: 6 })
- injected.openContextMenu({ kind: 'image', url: 'blob:image', path: 'image.png', name: 'image.png', mediaType: 'image/png' }, { x: 7, y: 8 })
- expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: {
+ } })
+ expect(injected.resolveContextMenuTarget({ kind: 'url', url: 'https://example.com/' })).toEqual({ kind: 'url', value: { sessionId: ROOT, url: 'https://example.com/' } })
+ expect(injected.resolveContextMenuTarget({ kind: 'image', url: 'blob:image', path: 'image.png', name: 'image.png', mediaType: 'image/png' })).toEqual({ kind: 'image', value: {
sessionId: ROOT, url: 'blob:image', path: '/proj/.agents/worktrees/w/image.png', name: 'image.png', mediaType: 'image/png',
- } }, { x: 7, y: 8 })
+ } })
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', null)
- injected.openContextMenu({ kind: 'image', url: 'https://example.com/image' }, { x: 9, y: 10 })
- injected.openContextMenu({ kind: 'file', path: 'file.ts' }, { x: 9, y: 10 })
- expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: ROOT, path: '/proj/file.ts' } }, { x: 9, y: 10 })
+ expect(injected.resolveContextMenuTarget({ kind: 'image', url: 'https://example.com/image' })).toEqual({ kind: 'image', value: { sessionId: ROOT, url: 'https://example.com/image' } })
+ expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'file.ts' })).toEqual({ kind: 'file', value: { sessionId: ROOT, path: '/proj/file.ts' } })
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
// A leading `./` names the same file, so the Sidebar reuses its tab.
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index efea109389..8b09381034 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { ProcessGroupData } from '../src/client/contract/process-groups.ts'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
@@ -492,7 +493,7 @@ function makeHarness(
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}>
const props: HarnessStandardProps = {
fileDrag,
- openContextMenu: vi.fn(),
+ resolveContextMenuTarget: vi.fn(() => ({ kind: 'image' as const, value: { url: 'unused' } })),
renderFactorySlot,
usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
@@ -506,6 +507,7 @@ function makeHarness(
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
useSessionRetainInfo: () => undefined,
+ useContextMenu: contextMenuHook(),
useResource,
useSessionStatus: bindSnapshotSelector(
createSnapshotStore(new Map()),
@@ -4716,5 +4718,5 @@ it('does not infer menu targets from links or data attributes inside a plugin fl
expect(fireEvent.contextMenu(view.getByRole('button', { name: 'Undeclared file' }))).toBe(true)
fireEvent.contextMenu(view.getByRole('button', { name: 'Custom target' }))
expect(custom).toHaveBeenCalledOnce()
- expect(h.props.openContextMenu).not.toHaveBeenCalled()
+ expect(h.props.resolveContextMenuTarget).not.toHaveBeenCalled()
})
diff --git a/packages/client/ui-chat/tests/context-menu.client.spec.tsx b/packages/client/ui-chat/tests/context-menu.client.spec.tsx
index 251aee41cc..d5f7dce571 100644
--- a/packages/client/ui-chat/tests/context-menu.client.spec.tsx
+++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
-import { contextMenuTrigger } from '@deepseek-ai/dsh-util-context-menu'
import { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -9,10 +9,10 @@ afterEach(cleanup)
it('leaves undeclared plugin links alone and binds only explicitly rendered Markdown targets', () => {
const open = vi.fn()
const custom = vi.fn()
- const view = render( {}}>
+ const view = render( {}}>
Plugin link
-
+
)
expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true)
expect(open).not.toHaveBeenCalled()
diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
index f67f18eb92..a3e9ec4fd8 100644
--- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
+++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -49,7 +50,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty
useWorkspaces: emptyWorkspaces(),
useSessionRetainInfo: () => undefined,
useResource,
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useTranscriptView: bindSnapshotSelector(source),
useCollapseTiming: bindSnapshotSelector(collapseTiming),
useFoldedReplies: bindSnapshotSelector(foldedReplies),
diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml
index d9abc128a4..57a7370b4d 100644
--- a/packages/client/ui-context-menu/README.i18n.yaml
+++ b/packages/client/ui-context-menu/README.i18n.yaml
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package:
- en: 5d2cfa2a0ff18a18
- zh: 634f233df5850a24
+ en: c84c65c7d24d51c3
+ zh: 98c8f40a326a8f0b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions:
- en: 4807c2e0c057ab7d
- zh: 9eda005d182dae28
+ en: 6ebc75b7209ac890
+ zh: 20f93e83bed67667
/deepseek-ai-dsh-client-ui-context-menu/understand-the-implementation:
en: 5b7717fad8457145
zh: 19a06fca8060cf62
diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md
index 4b45f4ff6e..72d6dc839b 100644
--- a/packages/client/ui-context-menu/README.md
+++ b/packages/client/ui-context-menu/README.md
@@ -25,23 +25,23 @@ Add actions to right-click menus without importing a React component. Targets re
## Use this package
-The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `menus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback.
+The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `contextMenus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback.
-Ordinary React controls bind `contextMenuTrigger(open)` from [`util/context-menu`](../../util/context-menu/README.md) to their actual DOM element. The callback supplies an explicit target to `ctx.menus.open()`; no DOM attributes are interpreted as target metadata. Nested controls can prevent handling, and portals bind at their rendered element. Special editors and canvases can call `open()` directly.
+The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly.
### Register actions
-Call `ctx.effect(() => ctx.menus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication.
+Call `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication.
An action’s optional `commandId` associates it with an existing shortcut command. The overlay subscribes to `ctx.shortcuts.catalog`, including configured, removed, and reset bindings; nested and footer actions use the same presentation. This association never dispatches the command: selection invokes only the action’s captured `run` callback. The menu plugin requires the `shortcuts` service.
A contribution can declare `observe(target)` sources for permissions, membership, or localized labels. Source updates re-resolve that contribution against the same captured target and replace its callbacks; outdated asynchronous preparations are cancelled. Dismissal and plugin unload release every subscription.
-Extend `MenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family, then open it with `ctx.menus.open({ kind, value }, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata.
+Extend `ContextMenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family, then open it with `ctx.contextMenus.open({ kind, value }, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata.
-`ctx.menus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance.
+`ctx.contextMenus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance.
-`ctx.menus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again.
+`ctx.contextMenus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again.
Shared popups wait for every contribution to settle or time out before appearing. A repeated gesture within four CSS pixels closes the same target; a farther gesture replaces it with a separate fade. `ready` gives custom renderers the same preparation point. Actions pass `icon?: ReactNode` directly to the shared Menu. Contributing plugins create glyph elements or an `
` for application icons; icons are neither serialized nor mapped by the registry. Structured action icons align within each divider group; component contributions retain their own layout. Normal dismissal retains the popup for 80ms, replacement for 10ms; opening waits at least 20ms and fades in over 40ms. Reduced motion suppresses the fade.
diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md
index 3bd6dda274..1d7d786ffc 100644
--- a/packages/client/ui-context-menu/README.zh.md
+++ b/packages/client/ui-context-menu/README.zh.md
@@ -25,23 +25,23 @@ kind: "package-reference"
## 使用本包
-Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `menus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。
+Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `contextMenus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。
-普通 React 控件将 [`util/context-menu`](../../util/context-menu/README.zh.md) 的 `contextMenuTrigger(open)` 绑定到实际 DOM 元素。回调向 `ctx.menus.open()` 提供显式目标;DOM 属性不作为目标元数据解释。嵌套控件可以阻止处理,Portal 在实际渲染元素上绑定。特殊编辑器和 Canvas 可直接调用 `open()`。
+插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。
### 注册操作
-在贡献插件中调用 `ctx.effect(() => ctx.menus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。
+在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。
动作可通过可选 `commandId` 关联已有快捷键命令。浮层订阅 `ctx.shortcuts.catalog`,跟随已配置、移除和重置的绑定更新;子菜单和底部动作采用相同展示。此关联不会调度命令:选择菜单项只调用该动作捕获的 `run` 回调。菜单插件依赖 `shortcuts` 服务。
贡献可通过 `observe(target)` 声明权限、成员状态或本地化标签的数据源。数据源更新时,针对同一捕获目标重新解析该贡献并替换回调;过期的异步准备会被取消。关闭菜单和卸载插件都会释放所有订阅。
-通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `MenuTargetMap`,即可增加目标类别,再通过 `ctx.menus.open({ kind, value }, { x, y })` 打开。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。
+通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `ContextMenuTargetMap`,即可增加目标类别,再通过 `ctx.contextMenus.open({ kind, value }, { x, y })` 打开。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。
-`ctx.menus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。
+`ctx.contextMenus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。
-`ctx.menus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。
+`ctx.contextMenus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。
共享弹窗等待全部贡献项完成或超时后才显示。同一目标在四个 CSS 像素以内重复触发会关闭菜单;更远的触发以独立淡入淡出替换菜单。`ready` 向自定义渲染器提供同一个准备完成时点。操作将 `icon?: ReactNode` 直接传给共享 Menu。贡献插件提供图标元素,或用 `
` 包装应用图标;registry 不序列化或映射图标。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。
diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json
index 951f2f7a35..6aacc8717f 100644
--- a/packages/client/ui-context-menu/package.json
+++ b/packages/client/ui-context-menu/package.json
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"react": "^18.2.0",
+ "react-dom": "^18.2.0",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:*",
diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts
index 37e37fe7fa..96f7221200 100644
--- a/packages/client/ui-context-menu/src/client/index.ts
+++ b/packages/client/ui-context-menu/src/client/index.ts
@@ -12,14 +12,20 @@ import { createMenuStore } from './store.ts'
import { dictionaries } from './locales.ts'
import { MenuOverlay } from './View.tsx'
import type { ContextMenus, MenuResolution, MenuTarget } from './types.ts'
+import { createContextMenuBindings } from './trigger.ts'
-export type { ContextMenus, MenuTarget, MenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts'
+export type { ContextMenus, MenuTarget, ContextMenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts'
+export type { ContextMenuBindings, ContextMenuTriggerProps, UseContextMenu } from './types.ts'
export { createMenuStore } from './store.ts'
declare module '@deepseek-ai/cordis' {
- interface Context { /** Extensible context menus for explicit clicked objects. */ menus: ContextMenus }
+ interface Context { /** Extensible context menus for explicit clicked objects. */ contextMenus: ContextMenus }
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface GlobalStandardProps {
+ /** Menu bindings for explicit objects, available in every Slot scope. */
+ useContextMenu: import('./types.ts').UseContextMenu
+ }
interface SlotMap {
/** Optional React rows for existing extension positions; data contributions need no renderer. */
'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: MenuTarget; dismiss: () => void } }
@@ -76,7 +82,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
closeTimer = setTimeout(() => { instance.actions.close() }, replacing ? MENU_TRANSITION_MS.replace : MENU_TRANSITION_MS.dismiss)
}
const close = (): void => { dismiss(false) }
- const menus: ContextMenus = {
+ const contextMenus: ContextMenus = {
active: targetSource,
register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close,
open: (target, point) => {
@@ -107,7 +113,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
},
}
- ctx.provide('menus', menus)
+ ctx.provide('contextMenus', contextMenus)
+ const bindings = createContextMenuBindings((target, point) => { contextMenus.open(target, point) }, () => !disposed)
+ ctx.slots.provideRoot({ props: { useContextMenu: () => bindings } })
ctx.effect(() => {
const outside = (event: PointerEvent): void => {
if (event.button === 2 || (event.target instanceof Element && event.target.closest('[role="menu"], [data-context-menu-trigger]'))) return
@@ -121,20 +129,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
document.addEventListener('pointerdown', outside)
document.addEventListener('contextmenu', context)
- const keyboard = (event: KeyboardEvent): void => {
- escape(event)
- if (event.defaultPrevented || event.repeat || event.isComposing || event.ctrlKey || event.altKey || event.metaKey
- || !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)
- || !(event.target instanceof HTMLElement)) return
- const gesture = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
- if (!event.target.dispatchEvent(gesture)) { event.preventDefault(); event.stopImmediatePropagation() }
- }
- window.addEventListener('keydown', keyboard, true)
+ window.addEventListener('keydown', escape, true)
return () => {
disposed = true; cancelOpening(); clearTimeout(closeTimer); registry.dispose(); instance.actions.close()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('contextmenu', context)
- window.removeEventListener('keydown', keyboard, true)
+ window.removeEventListener('keydown', escape, true)
}
}, 'context menu: lifetime')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts
index 764802a034..c829375922 100644
--- a/packages/client/ui-context-menu/src/client/registry.ts
+++ b/packages/client/ui-context-menu/src/client/registry.ts
@@ -1,12 +1,12 @@
/** Ordered menu resolution with cancellation and contribution-owned action lifetimes. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, MenuTarget, MenuTargetMap } from './types.ts'
+import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, MenuTarget, ContextMenuTargetMap } from './types.ts'
interface Provider {
readonly id: string
readonly order: number
- readonly kind: keyof MenuTargetMap
+ readonly kind: keyof ContextMenuTargetMap
readonly lifetime: AbortController
observe(target: MenuTarget): readonly ObservableSnapshot[]
resolve(target: MenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise
@@ -24,14 +24,14 @@ export class MenuRegistry {
* @param input - provider definition.
* @returns removal of this contribution and its pending resolutions.
*/
- register(input: MenuContribution): () => void {
+ register(input: MenuContribution): () => void {
if (this.disposed) throw new Error('Menu registry is disposed')
const key = `${encodeURIComponent(input.target)}/${encodeURIComponent(input.id)}`
if (this.providers.has(key)) throw new Error(`Duplicate menu contribution: ${key}`)
const provider: Provider = {
id: input.id, kind: input.target, order: input.order, lifetime: new AbortController(),
- observe: target => input.observe?.(target.value as MenuTargetMap[K]) ?? [],
- resolve: (target, signal) => input.resolve(target.value as MenuTargetMap[K], signal),
+ observe: target => input.observe?.(target.value as ContextMenuTargetMap[K]) ?? [],
+ resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal),
}
this.providers.set(key, provider)
return () => {
diff --git a/packages/client/ui-context-menu/src/client/trigger.ts b/packages/client/ui-context-menu/src/client/trigger.ts
new file mode 100644
index 0000000000..6aa3ab4304
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/trigger.ts
@@ -0,0 +1,43 @@
+/** DOM gesture bindings owned by one menu plugin instance. */
+import type { SyntheticEvent } from 'react'
+import type { ContextMenuTriggerProps, MenuPoint, MenuTarget } from './types.ts'
+
+/**
+ * Create stable bindings for the plugin's injected hook.
+ * @param open - Open against the supplied captured target and viewport position.
+ * @param active - Whether the plugin still owns its service.
+ * @returns Ordinary trigger-prop factory; no React subscription or context is created.
+ */
+export function createContextMenuBindings(
+ open: (target: Target, point: MenuPoint) => void, active: () => boolean,
+): { getTriggerProps: (target: Target | (() => Target) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps } {
+ return {
+ getTriggerProps(target, handlers = {}) {
+ if (target === undefined) return handlers
+ const show = (event: SyntheticEvent, x: number, y: number): void => {
+ if (event.defaultPrevented || !active()) return
+ const captured = typeof target === 'function' ? target() : target
+ event.preventDefault()
+ event.stopPropagation()
+ event.currentTarget.focus({ preventScroll: true })
+ open(captured, { x, y })
+ }
+ return {
+ ...handlers,
+ tabIndex: handlers.tabIndex ?? 0,
+ onContextMenu(event) {
+ handlers.onContextMenu?.(event)
+ const rect = event.clientX === 0 && event.clientY === 0 ? event.currentTarget.getBoundingClientRect() : undefined
+ show(event, rect?.left ?? event.clientX, rect?.bottom ?? event.clientY)
+ },
+ onKeyDown(event) {
+ handlers.onKeyDown?.(event)
+ if (event.defaultPrevented || event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey
+ || !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)) return
+ const rect = event.currentTarget.getBoundingClientRect()
+ show(event, rect.left, rect.bottom)
+ },
+ }
+ },
+ }
+}
diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts
index bda49107c1..8fc7302d44 100644
--- a/packages/client/ui-context-menu/src/client/types.ts
+++ b/packages/client/ui-context-menu/src/client/types.ts
@@ -4,9 +4,27 @@ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { HTMLAttributes } from 'react'
+
+/** DOM handlers bound to an explicit object; existing handlers can prevent menu activation. */
+export type ContextMenuTriggerProps = Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>
+
+/** Functions supplied by the globally injected useContextMenu hook. */
+export interface ContextMenuBindings {
+ /**
+ * Bind an object or deferred business target to one element. This is an ordinary function, safe in loops.
+ * @param target - Object captured when the gesture runs; undefined retains native behavior.
+ * @param handlers - Existing handlers run first and can cancel with preventDefault().
+ * @returns Combined pointer and keyboard handlers plus a keyboard focus stop.
+ */
+ getTriggerProps: (target: MenuTarget | (() => MenuTarget) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps
+}
+
+/** Read menu gesture bindings owned by the current menu plugin lifetime. */
+export type UseContextMenu = () => ContextMenuBindings
/** External plugins extend this map with their own target families. */
-export interface MenuTargetMap {
+export interface ContextMenuTargetMap {
session: { readonly sessionId: SessionId; readonly displayTitle: string }
file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId }
directory: { readonly sessionId: SessionId; readonly path: string }
@@ -22,7 +40,9 @@ export interface MenuTargetMap {
}
/** Captured target; changing selection never changes an open menu's target. */
-export type MenuTarget = { [K in keyof MenuTargetMap]: { readonly kind: K; readonly value: MenuTargetMap[K] } }[keyof MenuTargetMap]
+export type MenuTarget = {
+ [K in keyof ContextMenuTargetMap]: { readonly kind: K; readonly value: ContextMenuTargetMap[K] }
+}[keyof ContextMenuTargetMap]
/** Pointer position in CSS pixels; keyboard gestures use the trigger's bottom edge. */
export interface MenuPoint { readonly x: number; readonly y: number }
/** Client presentation fields; callbacks stay in the registry. */
@@ -48,7 +68,7 @@ export interface MenuAction extends Omit {
readonly run?: () => void | Promise
}
/** A namespaced contribution, ordered within its target family. */
-export interface MenuContribution {
+export interface MenuContribution {
readonly id: string
readonly target: K
readonly order: number
@@ -57,13 +77,13 @@ export interface MenuContribution {
* @param target - clicked object.
* @returns sources owned by the contributing feature; unsubscribed on dismissal or unload.
*/
- observe?(target: MenuTargetMap[K]): readonly ObservableSnapshot[]
+ observe?(target: ContextMenuTargetMap[K]): readonly ObservableSnapshot[]
/**
* @param target - clicked object.
* @param signal - aborted on dismissal, plugin unload, or a newer observed preparation.
* @returns available actions.
*/
- resolve(target: MenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise
+ resolve(target: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise
}
/** Independent menu occurrence used by custom renderers and embedded menus. */
export interface MenuResolution {
@@ -88,7 +108,7 @@ export interface ContextMenus {
* @param contribution - target provider.
* @returns unregister.
*/
- register(contribution: MenuContribution): () => void
+ register(contribution: MenuContribution): () => void
/**
* Open the single shared popup.
* @param target - captured object.
diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts
index 84cf576440..e0fd911e96 100644
--- a/packages/client/ui-context-menu/tests/plugin.client.spec.ts
+++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts
@@ -11,7 +11,7 @@ import type { createMenuStore } from '../src/client/store.ts'
afterEach(() => { vi.useRealTimers() })
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
- interface MenuTargetMap { pluginDocument: { readonly id: string } }
+ interface ContextMenuTargetMap { pluginDocument: { readonly id: string } }
}
it('lets an external contribution open another menu and withdraws its actions when its fiber unloads', async () => {
@@ -19,7 +19,7 @@ it('lets an external contribution open another menu and withdraws its actions wh
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
let store!: ReturnType['create']>
- ctx.provide('slots', {
+ ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => {
overlay = entry.inject(); store = entry.store.create(); return () => {}
@@ -30,59 +30,59 @@ it('lets an external contribution open another menu and withdraws its actions wh
await menuFiber.await()
expect(hostApply).not.toThrow()
const run = vi.fn()
- const contribution = ctx.plugin({ inject: ['menus'], apply: (plugin: Context) => {
- plugin.effect(() => plugin.menus.register({
+ const contribution = ctx.plugin({ inject: ['contextMenus'], apply: (plugin: Context) => {
+ plugin.effect(() => plugin.contextMenus.register({
target: 'pluginDocument', id: 'example.document', order: 0,
resolve: ({ id }) => [{ id: 'inspect', label: id, run: () => {
run(id)
- plugin.menus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 })
+ plugin.contextMenus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 })
} }],
}))
} })
await contribution.await()
try {
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 })
await vi.waitFor(() => { expect(store.getSnapshot().items.length).toBeGreaterThan(0) })
overlay.select('example.document/inspect')
expect(run).toHaveBeenCalledExactlyOnceWith('clicked')
- expect(ctx.menus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } })
- const occurrence = ctx.menus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } })
+ expect(ctx.contextMenus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } })
+ const occurrence = ctx.contextMenus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } })
expect(occurrence.source.getSnapshot().map(action => action.label)).toEqual(['retained'])
await contribution.dispose()
expect(occurrence.source.getSnapshot()).toEqual([])
occurrence.select('example.document/inspect')
expect(run).toHaveBeenCalledOnce()
occurrence.dispose()
- ctx.menus.notify('Saved', true)
+ ctx.contextMenus.notify('Saved', true)
expect(store.getSnapshot().notice).toMatchObject({ text: 'Saved', success: true })
- ctx.menus.notify('Unavailable')
+ ctx.contextMenus.notify('Unavailable')
expect(store.getSnapshot().notice).toMatchObject({ text: 'Unavailable', success: false })
const reveal = vi.fn(async () => {})
- ctx.menus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
+ ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
const previousAction = store.getSnapshot().notice!.action!
expect(previousAction.label).toBe('Show in folder')
previousAction.onClick()
previousAction.onClick()
expect(reveal).toHaveBeenCalledOnce()
expect(store.getSnapshot().notice).toBeNull()
- ctx.menus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
+ ctx.contextMenus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
const replaced = store.getSnapshot().notice!.action!
- ctx.menus.notify('New notice')
+ ctx.contextMenus.notify('New notice')
replaced.onClick()
expect(reveal).toHaveBeenCalledOnce()
for (const fail of [() => { throw new Error('denied') }, async () => { throw new Error('offline') }]) {
- ctx.menus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' })
+ ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' })
store.getSnapshot().notice!.action!.onClick()
await vi.waitFor(() => { expect(store.getSnapshot().notice).toMatchObject({ text: 'Reveal failed', success: false }) })
}
- const removeFailure = ctx.menus.register({ target: 'pluginDocument', id: 'failure', order: 0,
+ const removeFailure = ctx.contextMenus.register({ target: 'pluginDocument', id: 'failure', order: 0,
resolve: () => { throw new Error('Metadata unavailable') } })
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 })
expect(store.getSnapshot().notice?.text).toBe('failed')
removeFailure()
- const removeAction = ctx.menus.register({ target: 'pluginDocument', id: 'action', order: 0,
+ const removeAction = ctx.contextMenus.register({ target: 'pluginDocument', id: 'action', order: 0,
resolve: () => [{ id: 'copy', label: 'Copy', failureLabel: 'Copy failed', run: () => { throw new Error('Denied') } }] })
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 })
overlay.select('action/copy')
expect(store.getSnapshot().notice?.text).toBe('Copy failed')
removeAction()
@@ -94,7 +94,7 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences,
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let store!: ReturnType['create']>
- ctx.provide('slots', {
+ ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters[1]) => ctx.effect(register),
register: (entry: { store: ReturnType }) => { store = entry.store.create(); return () => {} },
} as never)
@@ -102,33 +102,33 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences,
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
let finish!: (actions: readonly import('../src/client/types.ts').MenuAction[]) => void
- ctx.menus.register({ target: 'pluginDocument', id: 'slow', order: 0,
+ ctx.contextMenus.register({ target: 'pluginDocument', id: 'slow', order: 0,
resolve: () => new Promise((resolve) => { finish = resolve }) })
const target = { kind: 'pluginDocument' as const, value: { id: 'file' } }
try {
- ctx.menus.open(target, { x: 20, y: 30 })
+ ctx.contextMenus.open(target, { x: 20, y: 30 })
await vi.advanceTimersByTimeAsync(100)
expect(store.getSnapshot().target).toBeNull()
finish([{ id: 'open', label: 'Open' }])
await vi.advanceTimersByTimeAsync(0)
expect(store.getSnapshot().items.map(item => item.label)).toEqual(['Open'])
const first = store.getSnapshot().occurrence
- ctx.menus.open(target, { x: 22, y: 31 })
- expect(ctx.menus.active.getSnapshot()).toBeNull()
+ ctx.contextMenus.open(target, { x: 22, y: 31 })
+ expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
expect(store.getSnapshot().closing).toBe(true)
await vi.advanceTimersByTimeAsync(80)
expect(store.getSnapshot().target).toBeNull()
- ctx.menus.open(target, { x: 80, y: 30 })
+ ctx.contextMenus.open(target, { x: 80, y: 30 })
finish([{ id: 'second', label: 'Second' }])
await vi.advanceTimersByTimeAsync(40)
expect(store.getSnapshot().occurrence).toBe(first + 1)
- ctx.menus.open(target, { x: 180, y: 30 })
+ ctx.contextMenus.open(target, { x: 180, y: 30 })
expect(store.getSnapshot().replacing).toBe(true)
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
finish([{ id: 'late', label: 'Late' }])
await vi.advanceTimersByTimeAsync(100)
expect(store.getSnapshot().target).toBeNull()
- ctx.menus.open(target, { x: 20, y: 30 })
+ ctx.contextMenus.open(target, { x: 20, y: 30 })
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
finish([{ id: 'escape', label: 'Escape' }])
await vi.advanceTimersByTimeAsync(100)
@@ -141,76 +141,56 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
- ctx.provide('slots', {
+ ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} },
} as never)
ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
- const notify = ctx.menus.notify.bind(ctx.menus)
- ctx.menus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] })
+ const notify = ctx.contextMenus.notify.bind(ctx.contextMenus)
+ ctx.contextMenus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] })
const trigger = document.createElement('button'), menu = document.createElement('button')
menu.setAttribute('data-context-menu', '')
menu.setAttribute('role', 'menu')
document.body.append(trigger, menu)
try {
trigger.focus()
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 })
await vi.advanceTimersByTimeAsync(40)
expect(overlay.returnFocus?.()).toBe(trigger)
menu.focus()
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 })
await vi.advanceTimersByTimeAsync(40)
expect(overlay.returnFocus?.()).toBe(trigger)
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 }))
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
- expect(ctx.menus.active.getSnapshot()).not.toBeNull()
+ expect(ctx.contextMenus.active.getSnapshot()).not.toBeNull()
document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true }))
- expect(ctx.menus.active.getSnapshot()).toBeNull()
+ expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
const handled = new MouseEvent('contextmenu', { cancelable: true }); handled.preventDefault()
document.dispatchEvent(handled)
document.dispatchEvent(new Event('pointerdown'))
- expect(ctx.menus.active.getSnapshot()).toBeNull()
+ expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
const active = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
- ctx.menus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 })
active.mockRestore()
expect(overlay.returnFocus?.()).toBeNull()
} finally { trigger.remove(); menu.remove(); await fiber.dispose() }
notify('late')
})
-it('dispatches keyboard menus at focused triggers and leaves unhandled shortcuts available', async () => {
+it('leaves unbound keyboard gestures native', async () => {
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
- ctx.provide('slots', { inject: () => () => {} } as never)
+ ctx.provide('slots', { provideRoot: () => () => {}, inject: () => () => {} } as never)
ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
- const trigger = document.createElement('button')
- document.body.append(trigger)
- const gesture = vi.fn((event: MouseEvent) => { event.preventDefault() })
- trigger.addEventListener('contextmenu', gesture)
try {
- for (const input of [{ key: 'ContextMenu' }, { key: 'F10', shiftKey: true }]) {
- const key = new KeyboardEvent('keydown', { ...input, bubbles: true, cancelable: true })
- trigger.dispatchEvent(key)
- expect(key.defaultPrevented).toBe(true)
- }
- expect(gesture).toHaveBeenCalledTimes(2)
- expect(gesture.mock.calls[0]?.[0]).toMatchObject({ clientX: 0, clientY: 0 })
- for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }]) {
- trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, ...extra }))
- }
- expect(gesture).toHaveBeenCalledTimes(2)
- const prevented = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
- prevented.preventDefault(); trigger.dispatchEvent(prevented)
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true }))
- expect(gesture).toHaveBeenCalledTimes(2)
- trigger.removeEventListener('contextmenu', gesture)
- const unhandled = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
- trigger.dispatchEvent(unhandled)
- expect(unhandled.defaultPrevented).toBe(false)
- } finally { trigger.remove(); await fiber.dispose() }
+ const event = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
+ document.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(false)
+ } finally { await fiber.dispose() }
})
diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts
index 041d7788f1..c533f3b49d 100644
--- a/packages/client/ui-context-menu/tests/registry.client.spec.ts
+++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts
@@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts'
import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
- interface MenuTargetMap { extension: { readonly id: string } }
+ interface ContextMenuTargetMap { extension: { readonly id: string } }
}
const target = { kind: 'extension' as const, value: { id: 'clicked' } }
diff --git a/packages/client/ui-context-menu/tests/target-key.client.spec.ts b/packages/client/ui-context-menu/tests/target-key.client.spec.ts
index d9a877c2d8..71d6b70efa 100644
--- a/packages/client/ui-context-menu/tests/target-key.client.spec.ts
+++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts
@@ -3,7 +3,7 @@ import { menuTargetKey } from '../src/client/target-key.ts'
import type { MenuTarget } from '../src/client/types.ts'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
- interface MenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } }
+ interface ContextMenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } }
}
it('compares JSON targets independently of object key insertion order while preserving arrays', () => {
diff --git a/packages/client/ui-context-menu/tests/trigger.client.spec.tsx b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx
new file mode 100644
index 0000000000..7c80412ed2
--- /dev/null
+++ b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment jsdom
+import { act, cleanup, createEvent, fireEvent, render } from '@testing-library/react'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+import { createPortal } from 'react-dom'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { apply, inject } from '../src/client/index.ts'
+import { createContextMenuBindings } from '../src/client/trigger.ts'
+import type { ContextMenuTriggerProps, MenuTarget } from '../src/client/types.ts'
+
+afterEach(cleanup)
+const target = (id: string): MenuTarget => ({ kind: 'session', value: { sessionId: SessionId(id), displayTitle: id } })
+
+it('binds targets in loops, captures deferred identity before focus changes it, and composes existing handlers', () => {
+ const open = vi.fn(), previous = vi.fn(), clicked = vi.fn()
+ let selected = 'first'
+ const { getTriggerProps } = createContextMenuBindings(open, () => true)
+ const view = render(<>
+ {['one', 'two'].map(id => )}
+
+ >)
+ for (const id of ['one', 'two']) {
+ fireEvent.contextMenu(view.getByRole('button', { name: id }), { clientX: 12, clientY: 23 })
+ expect(open).toHaveBeenLastCalledWith(target(id), { x: 12, y: 23 })
+ }
+ expect(previous).toHaveBeenCalledTimes(2)
+ const button = view.getByRole('button', { name: 'Captured' })
+ button.getBoundingClientRect = () => new DOMRect(30, 40, 50, 20)
+ const focus = vi.spyOn(button, 'focus')
+ fireEvent.keyDown(button, { key: 'F10', shiftKey: true })
+ expect(open).toHaveBeenLastCalledWith(target('first'), { x: 30, y: 60 })
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true })
+ expect(button.tabIndex).toBe(-1)
+ expect(clicked).not.toHaveBeenCalled()
+ fireEvent.contextMenu(button)
+ expect(open).toHaveBeenLastCalledWith(target('changed'), { x: 30, y: 60 })
+})
+
+it('honors prevented and nested gestures, portals, repeats, composition, and missing targets', () => {
+ const open = vi.fn(), outer = vi.fn(), existing = vi.fn()
+ const bindings = createContextMenuBindings(open, () => true)
+ const parent = createContextMenuBindings(outer, () => true)
+ const inner = bindings.getTriggerProps(target('inner'))
+ const composed = bindings.getTriggerProps(target('second'), inner)
+ const view = render(
+
+
+
+ {createPortal(, document.body)}
+
)
+ const button = view.getByRole('button', { name: 'Inner' })
+ fireEvent.contextMenu(button, { clientX: 1, clientY: 2 })
+ fireEvent.keyDown(button, { key: 'ContextMenu' })
+ expect(open).toHaveBeenCalledTimes(2)
+ expect(open).toHaveBeenLastCalledWith(target('inner'), { x: 0, y: 0 })
+ fireEvent.contextMenu(view.getByRole('button', { name: 'Blocked' }))
+ fireEvent.keyDown(view.getByRole('button', { name: 'Blocked' }), { key: 'ContextMenu' })
+ for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }, { key: 'Enter' }]) {
+ fireEvent.keyDown(button, { key: 'ContextMenu', ...extra })
+ }
+ const prevented = createEvent.contextMenu(button)
+ prevented.preventDefault(); fireEvent(button, prevented)
+ expect(open).toHaveBeenCalledTimes(2)
+ fireEvent.contextMenu(view.getByRole('button', { name: 'Portal' }), { clientX: 4, clientY: 5 })
+ expect(open).toHaveBeenCalledTimes(3)
+ expect(outer).not.toHaveBeenCalled()
+ fireEvent.keyDown(view.getByRole('button', { name: 'Native' }), { key: 'a' })
+ expect(existing).toHaveBeenCalledOnce()
+})
+
+it('injects the hook through the root Slot and withdraws both its prop and retained handlers on unload', async () => {
+ const runtime = await SlotTestRuntime.create()
+ onTestFinished(() => runtime.dispose())
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
+ runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
+ let retained: ContextMenuTriggerProps = {}
+ let received = false
+ await runtime.root.declare({}, ({ useContextMenu }) => {
+ received = typeof useContextMenu === 'function'
+ if (!received) return null
+ retained = useContextMenu().getTriggerProps(target('injected'))
+ return
+ })
+ const plugin = await runtime.mount({ inject, apply })
+ const view = runtime.renderRoot()
+ expect(received).toBe(true)
+ act(() => { fireEvent.contextMenu(view.getByRole('button')) })
+ expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('injected'))
+ const source = runtime.ctx.contextMenus.active
+ await plugin.dispose()
+ expect(received).toBe(false)
+ expect(source.getSnapshot()).toBeNull()
+ const detached = render()
+ expect(fireEvent.contextMenu(detached.getByRole('button'))).toBe(true)
+ expect(fireEvent.keyDown(detached.getByRole('button'), { key: 'ContextMenu' })).toBe(true)
+ expect(source.getSnapshot()).toBeNull()
+})
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index dee09d8e15..33d555d294 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 08cf047cee6419bd
zh: 1f464f059753eacb
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
- en: eccbd3c26d8e9cf0
- zh: 177aa9498222c016
+ en: 3d738179dd8cd27e
+ zh: 07241272a043a0f4
/deepseek-ai-dsh-client-ui-conversation/model-experience:
en: e0a05b73ba3e4318
zh: dc85ab8433bb4456
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index dedf0d42df..b205164e0d 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -182,7 +182,7 @@ The selector must be a pure function of the owner currency. Its non-null return
`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback.
-Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery accepts optional injected `openImageMenu` support.
+Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery accepts optional injected `getImageMenuProps` support.
## Model Experience
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 58a1acb682..579ff4e92a 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -182,7 +182,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。
-会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库接受可选注入的 `openImageMenu` 支持。
+会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库接受可选注入的 `getImageMenuProps` 支持。
## 模型体验
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index dbb5fe3939..f168e7f6c8 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -99,8 +99,7 @@
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-message-quotes": "workspace:*",
- "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
- "@deepseek-ai/dsh-util-context-menu": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
"locale/*.json",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index d4a2bd3e35..3328e1e5da 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -59,7 +59,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the Conversation plugin. */
export const inject = [
- 'menus',
'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms',
]
@@ -452,7 +451,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({
hooks: { conversationViews, entryPins: viewPins.unpinned, entryActions: viewActions },
setPinned: viewPins.setPinned,
- openSessionMenu: (sessionId, displayTitle, point) => { ctx.menus.open({ kind: 'session', value: { sessionId, displayTitle } }, point) },
open: (id) => { workspaceNavigation.openSession(id) },
selectView: (view) => {
activateView(sessionId, view)
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 28df60cfa2..369f562a7b 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -204,8 +204,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.images.content': {
scope: 'root'
props: MessageImagesContentInput
- inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
- openImageMenu?(this: void, source: import('@deepseek-ai/dsh-client-ui-context-menu/client').MenuTargetMap['image'], point: { x: number; y: number }): void }
+ inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions }
locale: 'conversation'
}
}
@@ -477,7 +476,6 @@ export interface ConversationViewActionsOwnerProps {
/** Business callbacks injected into the strict Session header. */
export interface ConversationSessionHeaderInjected {
/** Open the clicked title's actions. */
- openSessionMenu(sessionId: SessionId, displayTitle: string, point: { x: number; y: number }): void
/** Package-owned View roster source bound only for the Conversation header. */
readonly hooks: {
readonly conversationViews: ObservableSnapshot
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
index 2f1abdfe72..14602cbca0 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
@@ -1,4 +1,3 @@
-import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
@@ -108,8 +107,9 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
*/
export function ConversationSessionHeader({
sessionId, hideChrome, useSessions, useConversationViews, useStore,
- renderSlot, open, openSessionMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
+ renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
}: ConversationSessionHeaderProps) {
+ const { getTriggerProps } = useContextMenu()
const tabs = useConversationViews(value => value)
const selectedId = useStore(s => s.view)
const active = resolveActiveView(tabs, selectedId)
@@ -177,10 +177,7 @@ export function ConversationSessionHeader({
...last ? {} : { openTitle: () => { open(summary.id) } },
}
return (
- {
- event.preventDefault(); event.stopPropagation()
- openSessionMenu(summary.id, summary.displayTitle, menuPoint(event))
- }}>
+
{index > 0 && /}
{lineage
? summary.subagent
diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
index f4b80c891c..33d461982c 100644
--- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -46,7 +47,7 @@ function mount() {
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: emptyWorkspaces(),
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
setBusyEnter,
t: makeTranslate(en),
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index a657abf806..b9c34d916d 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
// InputBar behavior over the editor + submit-machine wiring: Enter-send
// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
// repeat suppression), running semantics (input stays free; continuable
@@ -194,7 +195,7 @@ function bench(over?: BenchOptions) {
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})),
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'plan'
diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
index 6a18cede9a..f751424886 100644
--- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* Impact-matrix projection tests (row by row): what each
* phase projects onto the InputBar — enter routing, visuals (command chip /
@@ -66,7 +67,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useInput: bindSnapshotSelector(shell.state),
diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
index 380097b5d1..539f341cd1 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* Scenario-chain integration (scenarios A/C/D/H/I): the real per-session
* InputTriggerController pipeline over a real session scope (Client Sessions over
@@ -168,7 +169,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useInput: bindSnapshotSelector(shell.state),
diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
index 4583814093..7378f68d3f 100644
--- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, QueueDock Steer, failure notices, and live retirement.
@@ -117,7 +118,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial
createSnapshotStore(new Map()),
),
useWorkspaces: (() => { throw new Error('unused') }) as never,
- ...allowAllAccess,
+ ...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined) as never,
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'],
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
index e5cb0cc805..5f07662274 100644
--- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createContext, useContext, type ReactNode } from 'react'
@@ -221,7 +222,7 @@ function mount(
}
if (key === 'conversation.session.header') {
return (
- ).hideChrome}
sessionId={SID}
SessionProvider={({ children }) => children}
@@ -235,7 +236,7 @@ function mount(
useTrajectory={useTrajectory}
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
- useResource={useResource}
+ useContextMenu={contextMenuHook()} useResource={useResource}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
@@ -265,7 +266,7 @@ function mount(
useTrajectory={useTrajectory}
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
- useResource={useResource}
+ useContextMenu={contextMenuHook()} useResource={useResource}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
@@ -289,7 +290,7 @@ function mount(
children}
- useResource={useResource}
+ useContextMenu={contextMenuHook()} useResource={useResource}
useSession={useSession}
useConversation={useConversation}
useSessions={props.useSessions}
@@ -353,6 +354,7 @@ function mount(
useTrajectory,
useSessions: bindSnapshotSelector(sessions),
usePanelInfo: selector => selector({ activePanelId: null }),
+ useContextMenu: contextMenuHook(),
useResource,
useSessionStatus,
useSessionRetainInfo: () => undefined,
@@ -386,6 +388,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useSessionStatus,
useSessionRetainInfo: () => undefined,
+ useContextMenu: contextMenuHook(),
useResource,
useWorkspaces: bindSnapshotSelector(workspaces),
...allowAllAccess,
diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json
index 08cec77b8b..0187cf5d72 100644
--- a/packages/client/ui-conversation/tsconfig.client.json
+++ b/packages/client/ui-conversation/tsconfig.client.json
@@ -107,9 +107,6 @@
},
{
"path": "../ui-context-menu"
- },
- {
- "path": "../../util/context-menu"
}
],
"exclude": [
diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json
index 937476d595..4172b1f4c2 100644
--- a/packages/client/ui-deliverables/package.json
+++ b/packages/client/ui-deliverables/package.json
@@ -82,8 +82,7 @@
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
- "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
- "@deepseek-ai/dsh-util-context-menu": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
"locale/*.json",
diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx
index ca4abab6c5..89cfa24afa 100644
--- a/packages/client/ui-deliverables/src/client/Deliverables.tsx
+++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx
@@ -21,7 +21,6 @@ const COLLAPSED_PRESENTED_COUNT = 4
/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
export interface DeliverablesInjected {
- openFileMenu(sessionId: import('@deepseek-ai/dsh-session/types').SessionId, path: string, point: { x: number; y: number }): void
hooks: {
changesDiff: ObservableSnapshot>
showCodeDiff: ObservableSnapshot
@@ -67,11 +66,12 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
* @returns the closing turn's file rows.
*/
export function Deliverables({
- matched, openFile, openFileMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
+ matched, openFile, useContextMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
}: Pick & {
matched: DeliverablesMatch
-} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) {
+} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) {
+ const { getTriggerProps } = useContextMenu()
const [expanded, setExpanded] = useState(false)
const showCodeDiff = useShowCodeDiff(value => value)
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
@@ -110,7 +110,7 @@ export function Deliverables({
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
host={host === 'error' ? null : host} t={t}
onPreview={() => { openFile(file.path) }}
- onMenu={(point) => { openFileMenu(sessionId, file.path, point) }}
+ contextMenuProps={getTriggerProps({ kind: 'file', value: { sessionId, path: file.path } })}
actions={renderSlot('deliverables.file.actions', {
actionUrl: presentedFileUrl(sessionId, file.seq, file.index),
available: host !== null && host !== 'error' && host.available,
diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
index 0e8e284cb7..e18e0353fd 100644
--- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
+++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
@@ -1,4 +1,3 @@
-import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
/** File identity, Sidebar preview, and contributed native actions for one delivery. */
import type { ReactNode } from 'react'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
@@ -20,13 +19,13 @@ function cardDescription(description: string | undefined, fallback: string): str
* @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy.
* @returns the file card and its anchored action menu.
*/
-export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, actions, t }: {
+export function PresentedFileCard({ file, cwd, phase, host, onPreview, contextMenuProps, actions, t }: {
file: PresentedPath
cwd: string | undefined
phase: PresentedOpenPhase | undefined
host: PresentedHost | null
onPreview: () => void
- onMenu: (point: { x: number; y: number }) => void
+ contextMenuProps: import('@deepseek-ai/dsh-client-ui-context-menu/client').ContextMenuTriggerProps
actions: ReactNode
} & PropsLocale) {
const reveal = host?.fileManager ?? 'directory'
@@ -39,7 +38,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a
return