({ hooks: { presentation } }),
+ inject: (sessionId): AssistantNodeInjected => ({
+ hooks: { presentation },
+ resolveFilePath: sessionFilePath(ctx, sessionId),
+ fileMentions: (owner, getFileTriggerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId, getFileTriggerProps),
+ }),
}, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 96028b0ffd..9479bde696 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -17,7 +17,9 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client'
-import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { FileDragActions, MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import type { ChatKey } from '../locale.ts'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read.
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
@@ -136,9 +138,12 @@ export interface ChatFileMentions {
* Resolve prose links for one closing Turn.
* @param owner - closing-Turn identity and file opener.
* @param sessionId - viewed Session, including when history is inherited from a fork.
+ * @param getFileTriggerProps - ordinary gesture binding for a resolved file path.
* @returns link resolver when available.
*/
- forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined
+ forClosing(
+ owner: TurnTailOwnerProps, sessionId: SessionId, getFileTriggerProps?: (path: string) => ContextMenuTriggerProps,
+ ): MarkdownFileMentions | undefined
}
declare module '@deepseek-ai/cordis' {
@@ -233,7 +238,6 @@ export interface ChatNodeOwnerProps {
forkAt: (seq: number) => void
/** Session-authorized image loader supplied by the Chat view. */
loadImage: MessageImageLoader
- fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
/** Turn-process state when this Node belongs to a projected Turn. */
turnProcess?: TurnProcessOwnerProps | undefined
}
@@ -313,7 +317,9 @@ export interface PerformanceUsageInjected {
/** Business callbacks injected into the Chat view. */
export interface ChatViewInjected {
/** File preparation and failure callbacks owned by the file-drag plugin. */
- readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
+ readonly fileDrag: FileDragActions
+ /** Resolve the reference against this Session and its current path base when the gesture begins. */
+ resolveFilePath: (path: string) => string
/** Dispatch one gesture to the Conversation plugin registry. */
dispatchInteraction: (gesture: TextInteractionGesture) => string | null
hooks: {
@@ -344,7 +350,6 @@ export interface ChatViewInjected {
read: () => ChatScrollPosition | null
}
forkAt: (seq: number) => void
- fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Session-owned node sources shared with the view, plus the flow's connection state. */
@@ -357,7 +362,7 @@ export type ChatFlowDataInjected = Pick
& {
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick {
+ 'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage'> {
readonly entries: readonly RenderEntry[]
readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[]
readonly lastInputTurn: number | undefined
@@ -393,7 +398,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Chat target, transcript node, statistics, and details copy. */
- chat: import('../locale.ts').ChatKey
+ chat: ChatKey
}
interface SlotFactoryMap {
diff --git a/packages/client/ui-chat/src/client/file-path.ts b/packages/client/ui-chat/src/client/file-path.ts
new file mode 100644
index 0000000000..9c23fdc21d
--- /dev/null
+++ b/packages/client/ui-chat/src/client/file-path.ts
@@ -0,0 +1,19 @@
+/** Gesture-time path resolution for Chat-owned file references. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+
+/**
+ * Bind path resolution to the originating Session without capturing its current directory.
+ * @param ctx - Chat plugin context.
+ * @param sessionId - Original Session identity.
+ * @returns Resolver using the live working directory, falling back to the workspace root.
+ */
+export function sessionFilePath(ctx: Context, sessionId: SessionId): (path: string) => string {
+ return (path) => {
+ const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
+ const current = ctx.sessions.binding(sessionId)?.session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined
+ return resolveWorkspacePath(current ?? cwd, path)
+ }
+}
diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts
index e56a8e0a7a..c785329f0a 100644
--- a/packages/client/ui-chat/src/client/index.ts
+++ b/packages/client/ui-chat/src/client/index.ts
@@ -1,3 +1,4 @@
+import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
/** Browser Chat target plugin. */
export { apply, inject } from './apply.ts'
export type {} from './conversation-nodes/assistant.ts'
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 9687743249..8ee48b8f65 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -1,4 +1,6 @@
+import type { AssistantNodeInjected } from '../src/client/chat/AssistantNodeView.tsx'
import { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
// @vitest-environment jsdom
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -100,6 +102,8 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.root.declare({
'main': { kind: 'keyed', scope: 'root' },
'settings.general.item': { kind: 'list', scope: 'root' },
@@ -314,9 +318,15 @@ 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.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')
+ expect(injected.resolveFilePath('src/a.ts')).toBe('/proj/.agents/worktrees/w/src/a.ts')
+ await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', null)
+ expect(injected.resolveFilePath('file.ts')).toBe('/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.
await injected.openFile('./src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
@@ -515,12 +525,16 @@ describe('Chat inject API', () => {
expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state)
expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation)
- expect(injected.fileMentions(owner)).toBeUndefined()
+ const entry = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step')!
+ const assistant = entry.inject!(ROOT as never)
+ const fileMentions = assistant.fileMentions as AssistantNodeInjected['fileMentions']
+ const getFileTriggerProps = vi.fn(() => ({}))
+ expect(fileMentions(owner, getFileTriggerProps)).toBeUndefined()
const mentions = { resolve: vi.fn() } as never
const forClosing = vi.fn(() => mentions)
b.runtime.ctx.provide('chatFileMentions', { forClosing } as never)
- expect(injected.fileMentions(owner)).toBe(mentions)
- expect(forClosing).toHaveBeenCalledWith(owner, ROOT)
+ expect(fileMentions(owner, getFileTriggerProps)).toBe(mentions)
+ expect(forClosing).toHaveBeenCalledWith(owner, ROOT, getFileTriggerProps)
expect(injected.chatScroll.read()).toBeNull()
const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 }
diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
index 066a441068..3e61dce8bf 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
@@ -75,6 +76,8 @@ async function bench() {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.root.declare({
'main': { kind: 'keyed', scope: 'root' },
'shell.overlay': { kind: 'list', scope: 'root' },
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 5f8de03ab8..2bc8286e93 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'
@@ -7,6 +8,7 @@ import type { GlobalStandardProps, PropsRenderFactories, PropsRenderSlots } from
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useMemo } from 'react'
+import { useMarkdownDelegate, type MarkdownMenuTarget } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
AssistantMessageNode, ChatFlowHookContext, ChatFlowOwnerProps, ChatFlowSlotProps,
ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, UserBeforeOwnerProps,
@@ -267,7 +269,7 @@ type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode
declare const userChainDispatch: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain']
type UserChainRenderer = typeof userChainDispatch<'conversation.chat.user.before'>
type UserChainState = { readonly renderer: UserChainRenderer | undefined }
-type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories
+type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories & Pick, 'fileMentions'>
function isImageOwner(owner: object): owner is MessageImagesOwnerProps {
return 'images' in owner && Array.isArray(owner.images)
@@ -492,6 +494,7 @@ function makeHarness(
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}>
const props: HarnessStandardProps = {
fileDrag,
+ resolveFilePath: vi.fn((path: string) => path),
renderFactorySlot,
usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
@@ -505,6 +508,7 @@ function makeHarness(
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
useSessionRetainInfo: () => undefined,
+ useContextMenu: contextMenuHook(),
useResource,
useSessionStatus: bindSnapshotSelector(
createSnapshotStore(new Map()),
@@ -706,6 +710,41 @@ function formatRunDuration(ms: number, t: Parameters[1]):
describe('Chat node rendering', () => {
+ it('adapts Markdown targets with the original Session and resolves file paths when each gesture begins', () => {
+ const h = makeHarness()
+ const opened = vi.fn()
+ let directory = '/workspace'
+ h.props.useContextMenu = contextMenuHook(opened)
+ h.props.resolveFilePath = path => `${directory}/${path}`
+ const targets: MarkdownMenuTarget[] = [
+ { kind: 'file', path: 'src/a.ts', line: 24 },
+ { kind: 'file', path: 'src/b.ts' },
+ { kind: 'url', url: 'https://example.com/page' },
+ { kind: 'image', url: 'blob:remote' },
+ { kind: 'image', url: 'blob:local', path: 'image.svg', name: 'Image', mediaType: 'image/svg+xml' },
+ ]
+ function References() {
+ const { getTriggerProps } = useMarkdownDelegate()
+ return <>{targets.map((target, index) => )}>
+ }
+ const view = render( } />)
+ directory = '/workspace/moved'
+ for (const [index, target] of targets.entries()) {
+ const button = view.getByRole('button', { name: String(index) })
+ button.getBoundingClientRect = () => new DOMRect(10, 20, 30, 40)
+ fireEvent.contextMenu(button, { clientX: 50, clientY: 60 })
+ const { kind, ...value } = target
+ const expected = { kind, value: {
+ sessionId: SID, ...value, ...'path' in target ? { path: `${directory}/${target.path}` } : {},
+ } }
+ expect(opened).toHaveBeenLastCalledWith(expected, { x: 50, y: 60 })
+ fireEvent.keyDown(button, { key: 'F10', shiftKey: true })
+ expect(opened).toHaveBeenLastCalledWith(expected, { x: 10, y: 60 })
+ }
+ expect(opened).toHaveBeenCalledTimes(10)
+ expect(h.openFile).not.toHaveBeenCalled()
+ })
+
it('opens Markdown references to unmodified files with line navigation', () => {
const h = makeHarness({
nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)],
@@ -729,13 +768,18 @@ describe('Chat node rendering', () => {
],
turnEnds: new Map([[1, 4]]),
})
- h.props.fileMentions = owner => ({
+ const opened = vi.fn()
+ let directory = '/workspace'
+ h.props.useContextMenu = contextMenuHook(opened)
+ h.props.resolveFilePath = path => `${directory}/${path}`
+ h.props.fileMentions = (owner, getFileTriggerProps) => ({
resolve: (value) => {
if (value !== 'report.html') return undefined
return {
open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
label: '打开 site/report.html',
title: 'site/report.html',
+ contextMenuProps: getFileTriggerProps('site/report.html'),
}
},
})
@@ -746,6 +790,17 @@ describe('Chat node rendering', () => {
expect(mentions).toHaveLength(1)
const mention = view.getByRole('button', { name: '打开 site/report.html' })
expect(mention.getAttribute('title')).toBe('site/report.html')
+ fireEvent.contextMenu(mention, { clientX: 12, clientY: 34 })
+ expect(opened).toHaveBeenLastCalledWith({
+ kind: 'file', value: { sessionId: SID, path: '/workspace/site/report.html' },
+ }, { x: 12, y: 34 })
+ directory = '/workspace/moved'
+ fireEvent.keyDown(mention, { key: 'F10', shiftKey: true })
+ expect(opened).toHaveBeenLastCalledWith({
+ kind: 'file', value: { sessionId: SID, path: '/workspace/moved/site/report.html' },
+ }, { x: 0, y: 0 })
+ expect(opened).toHaveBeenCalledTimes(2)
+ expect(h.openFile).not.toHaveBeenCalled()
fireEvent.click(mention)
// The vocabulary was built from the closing message's own owner currency.
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
@@ -4699,3 +4754,21 @@ const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: ()
it('formats Portuguese elapsed minutes with numeric units', () => {
expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s')
})
+
+it('does not infer menu targets from links or data attributes inside a plugin flow slot', () => {
+ const h = makeHarness()
+ const custom = vi.fn()
+ const view = render( <>
+ Plugin link
+
+
+ >} />)
+ expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true)
+ 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.resolveFilePath).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
new file mode 100644
index 0000000000..545538b18c
--- /dev/null
+++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx
@@ -0,0 +1,29 @@
+// @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 { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+
+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( {}}>
+ Plugin link
+
+
+ )
+ expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true)
+ expect(open).not.toHaveBeenCalled()
+ const file = view.getByRole('button', { name: 'File' })
+ fireEvent.contextMenu(file, { clientX: 40, clientY: 50 })
+ expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 40, y: 50 })
+ file.getBoundingClientRect = () => new DOMRect(30, 40, 100, 20)
+ fireEvent.keyDown(file, { key: 'F10', shiftKey: true })
+ expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 30, y: 60 })
+ expect(document.activeElement).toBe(file)
+ fireEvent.contextMenu(view.getByRole('button', { name: 'Plugin target' }), { clientX: 70, clientY: 80 })
+ expect(custom).toHaveBeenCalledExactlyOnceWith({ kind: 'pluginDocument', id: 'one' }, { x: 70, y: 80 })
+ expect(open).toHaveBeenCalledTimes(2)
+})
diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
index c7f67f3505..5e0f5cd8e3 100644
--- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
+++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
@@ -3,6 +3,7 @@
* that outlives the Chat panel, and history scrollback never does.
*/
// @vitest-environment jsdom
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { afterEach, describe, expect, it } from 'vitest'
import { act, screen } from '@testing-library/react'
@@ -68,6 +69,8 @@ async function bench() {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.declare({
'shell.overlay': { kind: 'list', scope: 'root' },
'conversation.approval.detail': { kind: 'single', scope: 'session' },
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..ca6d67f0f3 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'
@@ -50,6 +51,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty
useSessionRetainInfo: () => undefined,
useResource,
...allowAllAccess,
+ useContextMenu: contextMenuHook(),
useTranscriptView: bindSnapshotSelector(source),
useCollapseTiming: bindSnapshotSelector(collapseTiming),
useFoldedReplies: bindSnapshotSelector(foldedReplies),
diff --git a/packages/client/ui-chat/tests/user-content.client.spec.tsx b/packages/client/ui-chat/tests/user-content.client.spec.tsx
index 1a93864a41..38a786beee 100644
--- a/packages/client/ui-chat/tests/user-content.client.spec.tsx
+++ b/packages/client/ui-chat/tests/user-content.client.spec.tsx
@@ -1,3 +1,4 @@
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
// @vitest-environment jsdom
import { useState, type ReactNode } from 'react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -54,6 +55,7 @@ async function bench() {
const openFile = vi.fn<(path: string) => void>()
const openSkill = vi.fn<(name: string) => void>()
await runtime.mount({ inject: ['slots'], apply: (ctx) => {
+ ctx.slots.provideRoot({ props: { useContextMenu: contextMenuHook() } })
const userText = new UserTextRegistry(ctx)
for (const rule of [
sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule,
@@ -75,7 +77,7 @@ async function bench() {
ctx.slots.register({ name: 'conversation.user.text.node', key: 'text' }, PlainUserText)
ctx.slots.register({ name: 'conversation.user.text.node', key: 'session' }, SessionReference)
ctx.slots.register({ name: 'conversation.user.text.node', key: 'file', locale: 'reference',
- inject: () => ({ openFile }) }, FileReference)
+ inject: () => ({ openFile, resolveFilePath: path => path }) }, FileReference)
ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder' }, FolderReference)
ctx.slots.register({ name: 'conversation.user.text.node', key: 'skill', inject: () => ({ openSkill }) }, SkillReference)
ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ name: 'conversation.chat.user.images' }, function ImageProbe(props) {
diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json
index 42e4b6003c..4040aacd08 100644
--- a/packages/client/ui-chat/tsconfig.client.json
+++ b/packages/client/ui-chat/tsconfig.client.json
@@ -122,6 +122,9 @@
},
{
"path": "../../core/tool-executor-ptc"
+ },
+ {
+ "path": "../ui-context-menu"
}
],
"exclude": [
diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml
new file mode 100644
index 0000000000..1f54beccbb
--- /dev/null
+++ b/packages/client/ui-context-menu/README.i18n.yaml
@@ -0,0 +1,40 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/client/ui-context-menu/README.md
+/:
+ en: c7a386986f8aa0d7
+ zh: a4bace64648d0a89
+/deepseek-ai-dsh-client-ui-context-menu:
+ en: f165f99dfbea7ed2
+ zh: 0f2835e7726dd0f5
+/deepseek-ai-dsh-client-ui-context-menu/summary:
+ en: 7d22e21395a76909
+ zh: e9f98068a4c5fde7
+/deepseek-ai-dsh-client-ui-context-menu/table-of-contents:
+ en: d152484eb41ac6b4
+ zh: 725b47765ee8f30b
+/deepseek-ai-dsh-client-ui-context-menu/use-this-package:
+ en: c84c65c7d24d51c3
+ zh: 98c8f40a326a8f0b
+/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions:
+ en: 11bea09005de2678
+ zh: d56e25d2d93022aa
+/deepseek-ai-dsh-client-ui-context-menu/understand-the-implementation:
+ en: 5b7717fad8457145
+ zh: 19a06fca8060cf62
+/deepseek-ai-dsh-client-ui-context-menu/further-exploration:
+ en: 6d538e70a88f070b
+ zh: f90577fe9c8d1799
+/deepseek-ai-dsh-client-ui-context-menu/model-experience:
+ en: bb3787b3201a6724
+ zh: e51cbb66f42a433d
+/deepseek-ai-dsh-client-ui-context-menu/model-experience/kv-cache-effect:
+ en: e5fe67ca0631f67a
+ zh: b26e6107f2638589
+/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work:
+ en: ac1e5554e7f3109f
+ zh: c2df38df3b9ea4f2
+/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work/dev-note:
+ en: e67f633dc6fe7773
+ zh: 6c5f9788c6536776
diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md
new file mode 100644
index 0000000000..dff6ea78cc
--- /dev/null
+++ b/packages/client/ui-context-menu/README.md
@@ -0,0 +1,100 @@
+---
+description: "Add actions to right-click menus without importing a React component"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-context-menu
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Add actions to right-click menus without importing a React component. Targets retain the clicked Session, Workspace, file, directory, URL, image, or pane even when selection changes. Contributions can discover metadata asynchronously and disappear safely when their plugin unloads.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## 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 `contextMenus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback.
+
+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.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 `ContextMenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family; `ContextMenuTarget` derives the `{ kind, value }` union from that map. Open a target with `ctx.contextMenus.open(target, { 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.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.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. 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.
+
+Actions carry `icon?: { key: string; src?: string }`. Register the matching glyph component with `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))`. The overlay dispatches `renderSlot` by that key for ordinary, nested, and footer actions; the component receives the captured `icon` metadata, including an optional image URL. Registrations follow the contributing plugin and menu Slot declaration lifetimes. Action views contain no JSX nodes.
+
+The optional keyed `contextMenu.content` Slot hosts component contributions. Existing Session and tab Slot names remain owned by their feature adapters. Keyboard context keys and Shift+F10 use the focused trigger’s lower edge. `afterContent` places a data action after those rows; ordinary actions render before them.
+
+-----
+
+
+## Understand the implementation
+
+
+Implementation internals — click to expand
+
+The registry separates resolution and callback lifetimes from presentation. The shared overlay uses the existing Menu primitive for keyboard movement, focus return, dismissal, submenus, and viewport positioning. [Registry](src/client/registry.ts) owns cancellation and ordering; [target and action types](src/client/types.ts) own the public fields.
+
+
+
+-----
+
+
+## Further Exploration
+
+- [Slots](../ui-slots/README.md) — component contributions.
+- [File actions](../ui-file-actions/README.md) — original content transfer.
+- [Open in app](../ui-open-in-app/README.md) — shipped resource actions.
+
+-----
+
+
+## Model Experience
+
+None, as popup presentation and registration do not construct model input.
+
+#### KV Cache effect
+
+None; contributed feature actions own any subsequent model interaction.
+
+## Known Limitations and Deferred Work
+
+
+
+- The shipped renderer is the shared Web Menu, including inside Desktop. Native menu rendering is not supplied.
+- Component-only legacy actions do not appear in the plain-data resolution API.
+- Menus are window-local and transient; they are not persisted in Session data.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+None.
+
+
diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md
new file mode 100644
index 0000000000..78f154feae
--- /dev/null
+++ b/packages/client/ui-context-menu/README.zh.md
@@ -0,0 +1,100 @@
+---
+description: "无需导入 React 组件即可向右键菜单添加操作"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-context-menu
+
+[English](README.md) | 中文
+
+## 概述
+
+无需导入 React 组件即可向右键菜单添加操作。即使选中项变化,目标仍保留被点击的会话、工作区、文件、文件夹、链接、图片或窗格。贡献项可以异步查询元数据,并在插件卸载时安全移除。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延后工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 使用本包
+
+Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `contextMenus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。
+
+插件通过 `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.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` 的声明合并扩展 `ContextMenuTargetMap`,即可增加目标类别;`ContextMenuTarget` 从该映射派生 `{ kind, value }` 联合类型。通过 `ctx.contextMenus.open(target, { x, y })` 打开目标。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。
+
+`ctx.contextMenus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。
+
+`ctx.contextMenus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。
+
+共享弹窗等待全部贡献项完成或超时后才显示。同一目标在四个 CSS 像素以内重复触发会关闭菜单;更远的触发以独立淡入淡出替换菜单。`ready` 向自定义渲染器提供同一个准备完成时点。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。
+
+操作携带 `icon?: { key: string; src?: string }`。通过 `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))` 注册匹配的图标组件。覆盖层对普通项、子菜单和底部操作都按该 key 调用 `renderSlot`;组件接收已捕获的 `icon` 元数据,包括可选图片 URL。注册随贡献插件和菜单 Slot 声明的生命周期清理。操作视图不包含 JSX 节点。
+
+可选的 keyed Slot `contextMenu.content` 承载组件贡献。现有会话和标签菜单 Slot 名称仍由各自功能适配器维护。键盘菜单键和 Shift+F10 使用焦点触发元素的下边缘定位。`afterContent` 将数据操作放在这些行之后;普通操作排在之前。
+
+-----
+
+
+## 理解实现
+
+
+实现细节 — 点击展开
+
+注册表将查询和回调生命周期与展示分离。共享覆盖层复用现有 Menu 原语,处理键盘移动、焦点返回、关闭、子菜单和视口定位。[注册表](src/client/registry.ts) 管理取消与排序;[目标和操作类型](src/client/types.ts) 定义公开字段。
+
+
+
+-----
+
+
+## 进一步探索
+
+- [Slots](../ui-slots/README.zh.md) — 组件贡献。
+- [文件操作](../ui-file-actions/README.zh.md) — 原始内容传输。
+- [本地打开](../ui-open-in-app/README.zh.md) — 内置资源操作。
+
+-----
+
+
+## 模型体验
+
+无,因为弹出菜单展示与注册不会构造模型输入。
+
+#### KV 缓存影响
+
+无;贡献操作所属功能负责后续可能产生的模型交互。
+
+## 已知限制与延后工作
+
+
+
+- 内置渲染器是共享 Web Menu,Desktop 也使用它;未提供原生菜单渲染器。
+- 仅有组件形式的旧操作不会出现在纯数据查询 API 中。
+- 菜单是窗口内的临时状态,不持久化到会话数据。
+
+
+### 开发备注
+
+
+维护者工作背景 — 点击展开
+
+无。
+
+
diff --git a/packages/client/ui-context-menu/locale/de.json b/packages/client/ui-context-menu/locale/de.json
new file mode 100644
index 0000000000..4b83ec446a
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/de.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Kontextmenüs"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/en.json b/packages/client/ui-context-menu/locale/en.json
new file mode 100644
index 0000000000..edcf35ac5e
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/en.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Context menus"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/es.json b/packages/client/ui-context-menu/locale/es.json
new file mode 100644
index 0000000000..dda4ab5e04
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/es.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Menús contextuales"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/fr.json b/packages/client/ui-context-menu/locale/fr.json
new file mode 100644
index 0000000000..6e41b74ead
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/fr.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Menus contextuels"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/ja.json b/packages/client/ui-context-menu/locale/ja.json
new file mode 100644
index 0000000000..7b98bda82b
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/ja.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "コンテキストメニュー"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/pt.json b/packages/client/ui-context-menu/locale/pt.json
new file mode 100644
index 0000000000..4a2c18368a
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/pt.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Menus de contexto"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/ru.json b/packages/client/ui-context-menu/locale/ru.json
new file mode 100644
index 0000000000..33b3ad67a4
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/ru.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "Контекстные меню"
+ }
+}
diff --git a/packages/client/ui-context-menu/locale/zh.json b/packages/client/ui-context-menu/locale/zh.json
new file mode 100644
index 0000000000..974fc94de9
--- /dev/null
+++ b/packages/client/ui-context-menu/locale/zh.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "右键菜单"
+ }
+}
diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json
new file mode 100644
index 0000000000..6aacc8717f
--- /dev/null
+++ b/packages/client/ui-context-menu/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@deepseek-ai/dsh-client-ui-context-menu",
+ "description": "Typed, plugin-extensible context menus with captured targets and shared Web presentation",
+ "version": "0.2.2-alpha.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/ui-context-menu"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./locale/*.json": "./locale/*.json"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-ui-layout",
+ "@deepseek-ai/dsh-client-shortcuts"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ },
+ "devDependencies": {
+ "@deepseek-ai/schemastery": "workspace:~",
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-client-locale": "workspace:*",
+ "@deepseek-ai/dsh-client-store": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
+ "@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:*",
+ "@deepseek-ai/dsh-client-shortcuts": "workspace:*"
+ },
+ "files": [
+ "locale/*.json",
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts"
+ ]
+}
diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx
new file mode 100644
index 0000000000..58a9808f3a
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/View.tsx
@@ -0,0 +1,60 @@
+/** Shared Web renderer for Client menu contributions. */
+import { Menu, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import type { Shortcuts, ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
+import type { createMenuStore } from './store.ts'
+import type { MenuActionView } from './types.ts'
+
+/**
+ * @param actions - plain presentation.
+ * @param catalog - effective user shortcuts.
+ * @param renderSlot - owner-authorized keyed icon renderer.
+ * @returns Existing Menu entries.
+ */
+export function menuEntries(
+ actions: readonly MenuActionView[], catalog: readonly ShortcutCatalogEntry[],
+ renderSlot: PropsRenderSlots<'contextMenu.icon'>['renderSlot'],
+): MenuEntry[] {
+ return actions.flatMap((action) => {
+ const shortcut = catalog.find(entry => entry.id === action.commandId)
+ const item: MenuEntry = { id: action.id, label: action.label,
+ ...(shortcut === undefined ? {} : { shortcut }),
+ ...(action.disabled === undefined ? {} : { disabled: action.disabled }),
+ ...(action.danger === undefined ? {} : { danger: action.danger }),
+ ...(action.icon === undefined ? {} : {
+ icon: renderSlot('contextMenu.icon', { icon: action.icon }, { entryKey: action.icon.key }),
+ }),
+ ...(action.children === undefined ? {} : { submenu: menuEntries(action.children, catalog, renderSlot) }),
+ }
+ return action.separatorBefore ? [{ type: 'separator', id: `${action.id}:separator` }, item] : [item]
+ })
+}
+
+/** Shared overlay callbacks. */
+export interface MenuOverlayInjected {
+ hooks: { catalog: Shortcuts['catalog'] }
+ close: () => void
+ returnFocus?: () => HTMLElement | null
+ select: (id: string) => void
+}
+type Props = PropsStore>
+ & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace
+
+/** @param props - registered state, target-specific legacy rows, and actions. @returns one menu and persistent feedback. */
+export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog }: Props) {
+ const catalog = useCatalog(value => value)
+ const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s)
+ return <>
+
+ {notice !== null && { actions.notify(null) }} />}
+ >
+}
diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts
new file mode 100644
index 0000000000..ac4fc288be
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/index.ts
@@ -0,0 +1,155 @@
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+/** Window-local menu capability and its Web overlay. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import z from '@deepseek-ai/schemastery'
+import { MENU_TRANSITION_MS } from '@deepseek-ai/dsh-client-ui-primitives'
+import { menuTargetKey } from './target-key.ts'
+import { MenuRegistry } from './registry.ts'
+import { createMenuStore } from './store.ts'
+import { dictionaries } from './locales.ts'
+import { MenuOverlay } from './View.tsx'
+import type { ContextMenus, MenuResolution, ContextMenuTarget, ContextMenuIcon, MenuPoint, UseContextMenu } from './types.ts'
+import { createContextMenuBindings } from './trigger.ts'
+
+export type { ContextMenus, ContextMenuTarget, ContextMenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts'
+export type { ContextMenuBindings, ContextMenuTriggerProps, UseContextMenu } from './types.ts'
+export type { ContextMenuIcon } from './types.ts'
+export { createMenuStore } from './store.ts'
+
+declare module '@deepseek-ai/cordis' {
+ 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: UseContextMenu
+ }
+ interface SlotMap {
+ /** Business-owned glyph or image selected by the action's icon key. */
+ 'contextMenu.icon': { kind: 'keyed'; scope: 'root'; inline: true; owner: { icon: ContextMenuIcon } }
+ /** Optional React rows for existing extension positions; data contributions need no renderer. */
+ 'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: ContextMenuTarget; dismiss: () => void } }
+ }
+ interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' }
+}
+
+/** Maximum time to prepare each contribution before omitting its pending actions. */
+export interface Config { resolutionTimeoutMs?: number }
+/** Validated Client menu preparation budget. */
+export const Config: z = z.object({ resolutionTimeoutMs: z.natural().min(1).default(1000) })
+
+/** Required overlay and localization services. */
+export const inject = ['slots', 'locale', 'shortcuts']
+/** @param ctx - plugin-owned Client context. @param config - validated preparation budget. */
+export function apply(ctx: Context, config: Config = Config({})): void {
+ ctx.effect(() => ctx.locale.register('contextMenu', dictionaries), 'context menu: locale')
+ const t = ctx.locale.bind('contextMenu')
+ const handle = createMenuStore()
+ const instance = handle.create()
+ const store: typeof handle = { ...handle, create: () => instance }
+ const targetSource = createSnapshotStore(null)
+ let sequence = 0
+ let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined
+ let closeTimer: ReturnType | undefined
+ let openingTimer: ReturnType | undefined
+ let finishOpening: (() => void) | undefined
+ let returnFocus: HTMLElement | null = null
+ let disposed = false
+ const notify: ContextMenus['notify'] = (text, success = false, action) => {
+ if (disposed) return
+ const id = ++sequence
+ instance.actions.notify({ id, text, success, ...action === undefined ? {} : { action: {
+ label: action.label,
+ onClick: () => {
+ if (disposed || instance.getSnapshot().notice?.id !== id) return
+ instance.actions.notify(null)
+ try { void Promise.resolve(action.run()).catch(() => { notify(action.failureLabel) }) }
+ catch (_error) { notify(action.failureLabel) }
+ },
+ } } })
+ }
+ const registry = new MenuRegistry((message) => { notify(message ?? t('failed')) }, config.resolutionTimeoutMs)
+ const cancelOpening = (): void => {
+ clearTimeout(openingTimer); finishOpening?.(); finishOpening = undefined
+ active?.unsubscribe(); active?.resolution.dispose(); active = undefined
+ targetSource.set(null)
+ }
+ const dismiss = (replacing: boolean): void => {
+ cancelOpening()
+ clearTimeout(closeTimer)
+ if (instance.getSnapshot().target === null) return
+ instance.actions.exit(replacing)
+ closeTimer = setTimeout(() => { instance.actions.close() }, replacing ? MENU_TRANSITION_MS.replace : MENU_TRANSITION_MS.dismiss)
+ }
+ const close = (): void => { dismiss(false) }
+ const contextMenus: ContextMenus = {
+ active: targetSource,
+ register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close,
+ open: (target, point) => {
+ const previous = active
+ if (previous !== undefined
+ && Math.hypot(previous.point.x - point.x, previous.point.y - point.y) <= 4
+ && menuTargetKey(previous.target) === menuTargetKey(target)) { close(); return }
+ const focused = document.activeElement
+ if (!(focused instanceof Element) || focused.closest('[data-context-menu]') === null) {
+ returnFocus = focused instanceof HTMLElement ? focused : null
+ }
+ dismiss(true)
+ const resolution = registry.resolve(target)
+ targetSource.set(target)
+ let displayed = false
+ const publish = (): void => { if (displayed) instance.actions.items(resolution.source.getSnapshot()) }
+ const occurrence = { resolution, unsubscribe: resolution.source.subscribe(publish), target, point }
+ active = occurrence
+ const delay = new Promise((resolve) => {
+ finishOpening = resolve; openingTimer = setTimeout(resolve, MENU_TRANSITION_MS.prepare)
+ })
+ void Promise.all([resolution.ready, delay]).then(() => {
+ if (active !== occurrence) return
+ clearTimeout(closeTimer)
+ instance.actions.open(target, point)
+ displayed = true
+ publish()
+ })
+ },
+ }
+ 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
+ close()
+ }
+ const context = (event: MouseEvent): void => { if (!event.defaultPrevented) close() }
+ const escape = (event: KeyboardEvent): void => {
+ if (active !== undefined && instance.getSnapshot().target === null && event.key === 'Escape') {
+ event.preventDefault(); event.stopImmediatePropagation(); close()
+ }
+ }
+ document.addEventListener('pointerdown', outside)
+ document.addEventListener('contextmenu', context)
+ 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', escape, true)
+ }
+ }, 'context menu: lifetime')
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+ name: 'shell.overlay', id: 'context-menu', store,
+ children: {
+ 'contextMenu.content': { kind: 'keyed', scope: 'root' },
+ 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true },
+ },
+ inject: () => ({ hooks: { catalog: ctx.shortcuts.catalog }, close, returnFocus: () => returnFocus, select: (id: string) => {
+ const selected = active?.resolution
+ active?.unsubscribe(); active = undefined
+ targetSource.set(null); dismiss(false)
+ selected?.select(id); selected?.dispose() } }),
+ }, MenuOverlay))
+}
diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts
new file mode 100644
index 0000000000..d8b33a422e
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/locales.ts
@@ -0,0 +1,11 @@
+/** Context-menu operation feedback, separate from plugin display metadata. */
+export const dictionaries = {
+ zh: { failed: '操作失败,请重试' },
+ en: { failed: 'Could not complete the action. Try again.' },
+ ja: { failed: '操作に失敗しました。再試行してください。' },
+ es: { failed: 'No se pudo completar la acción. Inténtelo de nuevo.' },
+ fr: { failed: 'Impossible de terminer l’action. Réessayez.' },
+ de: { failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' },
+ ru: { failed: 'Не удалось выполнить действие. Повторите попытку.' },
+ pt: { failed: 'Não foi possível concluir a ação. Tente novamente.' },
+} satisfies Record
diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts
new file mode 100644
index 0000000000..3725bb9fed
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/registry.ts
@@ -0,0 +1,160 @@
+/** 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, ContextMenuTarget, ContextMenuTargetMap } from './types.ts'
+
+interface Provider {
+ readonly id: string
+ readonly order: number
+ readonly kind: keyof ContextMenuTargetMap
+ readonly lifetime: AbortController
+ observe(target: ContextMenuTarget): readonly ObservableSnapshot[]
+ resolve(target: ContextMenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise
+}
+
+/** React-free registry; each occurrence captures the providers registered at opening. */
+export class MenuRegistry {
+ private readonly providers = new Map()
+ private readonly occurrences = new Set<() => void>()
+ private disposed = false
+ constructor(private readonly failed: (message?: string) => void, private readonly timeoutMs?: number) {}
+
+ /**
+ * Register actions for subsequent openings.
+ * @param input - provider definition.
+ * @returns removal of this contribution and its pending resolutions.
+ */
+ 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 ContextMenuTargetMap[K]) ?? [],
+ resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal),
+ }
+ this.providers.set(key, provider)
+ return () => {
+ if (provider.lifetime.signal.aborted) return
+ this.providers.delete(key)
+ provider.lifetime.abort()
+ }
+ }
+
+ /**
+ * Resolve an independently owned occurrence.
+ * @param target - captured clicked identity.
+ * @returns presentation, selection, and disposal.
+ */
+ resolve(target: ContextMenuTarget): MenuResolution {
+ if (this.disposed) throw new Error('Menu registry is disposed')
+ const lifetime = new AbortController()
+ const source = createSnapshotStore([])
+ const resolved = new Map()
+ const callbacks = new Map()
+ const providers = [...this.providers.values()].filter(p => p.kind === target.kind)
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
+ const refresh = (): void => {
+ source.set(providers.flatMap(provider =>
+ provider.lifetime.signal.aborted ? [] : resolved.get(provider) ?? []))
+ }
+ const cleanup: (() => void)[] = []
+ const dispose = (): void => {
+ if (lifetime.signal.aborted) return
+ lifetime.abort()
+ for (const detach of cleanup) detach()
+ callbacks.clear()
+ this.occurrences.delete(dispose)
+ }
+ this.occurrences.add(dispose)
+ const pending: Promise[] = []
+ for (const provider of providers) {
+ let preparation: AbortController | undefined
+ let actionIds: string[] = []
+ let preparing: Promise | undefined
+ const clear = (): void => {
+ for (const id of actionIds) callbacks.delete(id)
+ actionIds = []
+ resolved.delete(provider)
+ refresh()
+ }
+ const prepare = (): void => {
+ preparation?.abort()
+ const deadline = new AbortController()
+ preparation = deadline
+ const signal = AbortSignal.any([lifetime.signal, provider.lifetime.signal, deadline.signal])
+ const accept = (actions: readonly MenuAction[]): void => {
+ if (signal.aborted) return
+ const next = new Map()
+ const project = (action: MenuAction, parent: string): MenuActionView => {
+ const id = `${parent}/${encodeURIComponent(action.id)}`
+ if (next.has(id)) throw new Error(`Duplicate menu action: ${id}`)
+ next.set(id, action)
+ const { run: _run, failureLabel: _failureLabel, children, ...view } = action
+ return { ...view, id, ...(children === undefined ? {} : { children: children.map(child => project(child, id)) }) }
+ }
+ const views = actions.map(action => project(action, encodeURIComponent(provider.id)))
+ for (const id of actionIds) callbacks.delete(id)
+ for (const [id, action] of next) callbacks.set(id, { provider, action })
+ actionIds = [...next.keys()]
+ resolved.set(provider, views)
+ refresh()
+ }
+ preparing = undefined
+ try {
+ const result = provider.resolve(target, signal)
+ if (result instanceof Promise) clear()
+ preparing = result instanceof Promise ? new Promise((settle) => {
+ const timer = this.timeoutMs === undefined ? undefined : setTimeout(() => {
+ deadline.abort(); this.failed()
+ }, this.timeoutMs)
+ const finish = (): void => { clearTimeout(timer); signal.removeEventListener('abort', finish); settle() }
+ signal.addEventListener('abort', finish, { once: true })
+ void result.then(accept).catch(() => { if (!signal.aborted) this.failed() }).finally(finish)
+ }) : undefined
+ if (!(result instanceof Promise)) accept(result)
+ } catch (_error) { if (!signal.aborted) { clear(); this.failed() } }
+ }
+ const subscriptions: (() => void)[] = []
+ const withdraw = (): void => {
+ preparation?.abort()
+ for (const detach of subscriptions.splice(0)) detach()
+ clear()
+ }
+ provider.lifetime.signal.addEventListener('abort', withdraw, { once: true })
+ cleanup.push(() => {
+ provider.lifetime.signal.removeEventListener('abort', withdraw)
+ withdraw()
+ })
+ try {
+ for (const input of provider.observe(target)) subscriptions.push(input.subscribe(prepare))
+ prepare()
+ } catch (_error) { if (!lifetime.signal.aborted && !provider.lifetime.signal.aborted) this.failed() }
+ pending.push((async () => {
+ let current: Promise | undefined
+ do { current = preparing; await current } while (current !== preparing)
+ })())
+ }
+ return {
+ source, dispose, ready: Promise.all(pending).then(() => {}),
+ select: (id) => {
+ const selected = callbacks.get(id)
+ if (lifetime.signal.aborted || selected === undefined || selected.provider.lifetime.signal.aborted
+ || selected.action.disabled || selected.action.children !== undefined) return
+ const run = selected.action.run
+ dispose()
+ try { void Promise.resolve(run?.()).catch(() => { this.failed(selected.action.failureLabel) }) }
+ catch (_error) { this.failed(selected.action.failureLabel) }
+ },
+ }
+ }
+
+ /** Cancel every occurrence and reject further registrations or resolutions. */
+ dispose(): void {
+ if (this.disposed) return
+ this.disposed = true
+ for (const dispose of this.occurrences) dispose()
+ for (const provider of this.providers.values()) provider.lifetime.abort()
+ this.providers.clear()
+ }
+}
diff --git a/packages/client/ui-context-menu/src/client/store.ts b/packages/client/ui-context-menu/src/client/store.ts
new file mode 100644
index 0000000000..8791201c91
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/store.ts
@@ -0,0 +1,41 @@
+/** Single-popup viewing state and feedback retained outside trigger components. */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { MenuActionView, MenuPoint, ContextMenuTarget } from './types.ts'
+
+interface Notice { id: number; text: string; success: boolean; action?: { label: string; onClick: () => void } }
+interface MenuState {
+ occurrence: number
+ closing: boolean
+ replacing: boolean
+ target: ContextMenuTarget | null
+ point: MenuPoint
+ items: readonly MenuActionView[]
+ notice: Notice | null
+}
+type MenuActions = {
+ open(draft: MenuState, target: ContextMenuTarget, point: MenuPoint): void
+ items(draft: MenuState, items: readonly MenuActionView[]): void
+ exit(draft: MenuState, replacing: boolean): void
+ close(draft: MenuState): void
+ notify(draft: MenuState, notice: Notice | null): void
+}
+
+/**
+ * Create the root popup store.
+ * @returns the presentation and its mutation actions.
+ */
+export function createMenuStore(): EngineStoreHandle {
+ return defineStore({
+ init: () => ({ occurrence: 0, closing: false, replacing: false, target: null, point: { x: 0, y: 0 }, items: [], notice: null }),
+ actions: {
+ open(draft, target, point) {
+ draft.occurrence++; draft.closing = false; draft.replacing = false
+ draft.target = target; draft.point = point; draft.items = []
+ },
+ items(draft, items) { draft.items = items },
+ exit(draft, replacing) { draft.closing = true; draft.replacing = replacing },
+ close(draft) { draft.target = null; draft.items = [] },
+ notify(draft, notice) { draft.notice = notice },
+ },
+ })
+}
diff --git a/packages/client/ui-context-menu/src/client/target-key.ts b/packages/client/ui-context-menu/src/client/target-key.ts
new file mode 100644
index 0000000000..4304d2d617
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/target-key.ts
@@ -0,0 +1,14 @@
+/** Stable equality for the JSON-compatible target vocabulary, independent of object insertion order. */
+import type { ContextMenuTarget } from './types.ts'
+
+/**
+ * Compare captured JSON targets independently of object key insertion order.
+ * @param target - captured immutable target.
+ * @returns canonical JSON identity.
+ */
+export function menuTargetKey(target: ContextMenuTarget): string {
+ return JSON.stringify(target, (_key: string, value: unknown): unknown => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)))
+ })
+}
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..04cd530e1a
--- /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, ContextMenuTarget } 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
new file mode 100644
index 0000000000..d32a788fb4
--- /dev/null
+++ b/packages/client/ui-context-menu/src/client/types.ts
@@ -0,0 +1,143 @@
+/** Client menu contributions and explicit gesture targets. */
+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: ContextMenuTarget | (() => ContextMenuTarget) | 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 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 }
+ url: { readonly sessionId: SessionId; readonly url: string }
+ image: {
+ readonly sessionId?: SessionId
+ readonly url: string
+ readonly name?: string
+ readonly mediaType?: string
+ readonly path?: string
+ }
+ tab: { readonly sessionId: SessionId; readonly paneId: PaneId; readonly tab: TabRecord }
+}
+
+/** Captured target; changing selection never changes an open menu's target. */
+export type ContextMenuTarget = {
+ [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 }
+/** Keyed icon renderer and optional image URL captured with the action. */
+export interface ContextMenuIcon {
+ readonly key: string
+ readonly src?: string
+}
+/** Client presentation fields; callbacks stay in the registry. */
+export interface MenuActionView {
+ readonly id: string
+ readonly label: string
+ /** Command identity for live shortcut display only; selection still invokes this action’s run callback. */
+ readonly commandId?: ShortcutCommandId
+ readonly disabled?: boolean
+ readonly icon?: ContextMenuIcon
+ readonly danger?: boolean
+ readonly separatorBefore?: boolean
+ /** Render after legacy component contributions in the shared Web popup. */
+ readonly afterContent?: boolean
+ readonly children?: readonly MenuActionView[]
+}
+/** A contribution can decline, return actions immediately, or resolve metadata asynchronously. */
+export interface MenuAction extends Omit {
+ readonly children?: readonly MenuAction[]
+ /** Localized operation and recovery guidance when execution rejects. */
+ readonly failureLabel?: string
+ /** Execute against the captured target. Reject for a visible failure; cancellation resolves normally. */
+ readonly run?: () => void | Promise
+}
+/** A namespaced contribution, ordered within its target family. */
+export interface MenuContribution {
+ readonly id: string
+ readonly target: K
+ readonly order: number
+ /**
+ * Reactive inputs that re-resolve actions for the same captured target.
+ * @param target - clicked object.
+ * @returns sources owned by the contributing feature; unsubscribed on dismissal or unload.
+ */
+ 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: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise
+}
+/** Independent menu occurrence used by custom renderers and embedded menus. */
+export interface MenuResolution {
+ /** Read a new immutable presentation after each provider settles, updates, or is withdrawn. */
+ readonly source: ObservableSnapshot
+ /** Settles when all opening contributions have completed or been withdrawn. */
+ readonly ready: Promise
+ /**
+ * Select once; disposed or removed contributions cannot execute.
+ * @param id - resolved action id.
+ */
+ select(id: string): void
+ /** Cancel metadata queries and discard callbacks. */
+ dispose(): void
+}
+/** Client capability used equally by built-in and external plugins. */
+export interface ContextMenus {
+ /** Captured target of the shared popup; null after dismissal. */
+ readonly active: ObservableSnapshot
+ /**
+ * Register for subsequent openings; unloading withdraws actions and aborts their queries. Duplicate ids within a target fail.
+ * @param contribution - target provider.
+ * @returns unregister.
+ */
+ register(contribution: MenuContribution): () => void
+ /**
+ * Open the single shared popup.
+ * @param target - captured object.
+ * @param point - viewport position.
+ */
+ open(target: ContextMenuTarget, point: MenuPoint): void
+ /**
+ * Resolve an embedded menu without opening the shared popup.
+ * @param target - captured object.
+ * @returns owned occurrence.
+ */
+ resolve(target: ContextMenuTarget): MenuResolution
+ /** Close the shared popup and cancel its metadata queries. */
+ close(): void
+ /**
+ * Show localized feedback outside the originating surface.
+ * @param text - localized message.
+ * @param success - successful action.
+ * @param action - optional text action retained by this notice; rejects with localized failure feedback.
+ */
+ notify(text: string, success?: boolean, action?: {
+ readonly label: string
+ readonly run: () => void | Promise
+ readonly failureLabel: string
+ }): void
+}
diff --git a/packages/client/ui-context-menu/src/index.ts b/packages/client/ui-context-menu/src/index.ts
new file mode 100644
index 0000000000..77258040c7
--- /dev/null
+++ b/packages/client/ui-context-menu/src/index.ts
@@ -0,0 +1,4 @@
+/** Host Loader entry; menus belong to the Client. */
+
+/** Register the browser plugin without Host behavior. */
+export function apply(): void {}
diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx
new file mode 100644
index 0000000000..54194a811b
--- /dev/null
+++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx
@@ -0,0 +1,166 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
+import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
+import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createMenuStore } from '../src/client/store.ts'
+import { MenuOverlay } from '../src/client/View.tsx'
+import { apply, inject } from '../src/client/index.ts'
+import type { ContextMenuTarget } from '../src/client/types.ts'
+
+afterEach(() => { cleanup(); vi.useRealTimers() })
+
+it('renders legacy Session actions before data contributions and restores More focus after dismissal', async () => {
+ const store = createMenuStore().create(), run = vi.fn()
+ const close = () => { store.actions.close() }
+ const useStore = bindSnapshotSelector(store)
+ const view = render(<>
+
+ select([])} useStore={useStore} actions={store.actions} close={close} select={run}
+ renderSlot={(_key, owner: object) => {
+ const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void }
+ return {
+ if (target.kind === 'session') run(target.value.sessionId)
+ dismiss()
+ }}>Export
+ }} />
+ >)
+ const trigger = view.getByRole('button', { name: 'More' })
+ trigger.focus(); fireEvent.click(trigger)
+ await waitFor(() =>{ expect(view.getByRole('menuitem', { name: 'Export' })).toBe(document.activeElement) })
+ fireEvent.click(view.getByRole('menuitem', { name: 'Export' }))
+ await waitFor(() =>{ expect(document.activeElement).toBe(trigger) })
+ expect(run).toHaveBeenCalledExactlyOnceWith('clicked')
+ expect(view.queryByRole('menu')).toBeNull()
+ fireEvent.click(trigger)
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(view.queryByRole('menu')).toBeNull()
+ fireEvent.click(trigger)
+ fireEvent.pointerDown(document.body)
+ expect(view.queryByRole('menu')).toBeNull()
+})
+
+it('renders disabled, grouped and nested data actions and keeps feedback after menu dismissal', () => {
+ vi.useFakeTimers()
+ const store = createMenuStore().create(), select = vi.fn()
+ store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 })
+ store.actions.items([
+ { id: 'plain', label: 'Open' },
+ { id: 'blocked', label: 'Unavailable', disabled: true, danger: true, separatorBefore: true },
+ { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] },
+ { id: 'footer', label: 'Folder', afterContent: true },
+ ])
+ const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions}
+ close={() => { store.actions.close() }} select={select} renderSlot={() => null} />)
+ expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true)
+ fireEvent.click(view.getByRole('menuitem', { name: 'Open' }))
+ expect(select).toHaveBeenCalledWith('plain')
+ fireEvent.click(view.getByRole('menuitem', { name: 'Export' }))
+ expect(view.getAllByRole('separator')).toHaveLength(2)
+ fireEvent.click(view.getByRole('menuitem', { name: 'Original' }))
+ expect(select).toHaveBeenCalledWith('child')
+ act(() => { store.actions.exit(true) })
+ expect(document.querySelector('[data-menu-phase="replace"]')).not.toBeNull()
+ act(() => { store.actions.exit(false) })
+ expect(document.querySelector('[data-menu-phase="dismiss"]')).not.toBeNull()
+ const reveal = vi.fn()
+ act(() => { store.actions.close(); store.actions.notify({ id: 1, text: 'Saved', success: true,
+ action: { label: 'Show in Finder', onClick: reveal } }) })
+ expect(view.queryByRole('menu')).toBeNull()
+ expect(view.getByRole('alert').textContent).toBe('SavedShow in Finder')
+ fireEvent.click(view.getByRole('button', { name: 'Show in Finder' }))
+ expect(reveal).toHaveBeenCalledOnce()
+ act(() => { store.actions.notify({ id: 2, text: 'Copy failed', success: false }) })
+ expect(view.getByRole('alert').textContent).toBe('Copy failed')
+ expect(view.queryByRole('button', { name: 'Show in Finder' })).toBeNull()
+ act(() => { vi.advanceTimersByTime(4000) })
+ expect(view.queryByRole('alert')).toBeNull()
+})
+
+function PluginIcon({ icon }: PropsRuntime<'contextMenu.icon'>) {
+ return
+}
+
+it('dispatches captured icon keys in rows, submenus and footers and retires renderer registrations 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)
+ await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) =>
+ <>{renderSlot('shell.overlay', {})}>)
+ const menuPlugin = await runtime.mount({ inject, apply })
+ const run = vi.fn()
+ const contribution = await runtime.mount({ inject: ['contextMenus'], apply(ctx) {
+ ctx.effect(() => ctx.contextMenus.register({ id: 'icons', target: 'session', order: 0, resolve: () => [
+ { id: 'row', label: 'Open', icon: { key: 'example.icon', src: '/captured.png' }, run },
+ { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original', icon: { key: 'example.icon' } }] },
+ { id: 'footer', label: 'Footer', afterContent: true, icon: { key: 'example.icon' } },
+ ] }))
+ } })
+ const icons = await runtime.mount({ inject: ['slots'], apply(ctx) {
+ ctx.slots.inject('contextMenu.icon', function* () {
+ yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.icon' }, PluginIcon)
+ yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.unused' }, () => Unused icon)
+ })
+ } })
+ const view = runtime.renderRoot()
+ const open = () => { runtime.ctx.contextMenus.open({ kind: 'session', value: {
+ sessionId: SessionId('icons'), displayTitle: 'Icons',
+ } }, { x: 20, y: 30 }) }
+ act(open)
+ await waitFor(() => { expect(view.getAllByTestId('plugin-icon')).toHaveLength(2) })
+ expect(view.getByRole('menuitem', { name: 'Open' }).querySelector('img')?.getAttribute('src')).toBe('/captured.png')
+ expect(view.queryByText('Unused icon')).toBeNull()
+ fireEvent.click(view.getByRole('menuitem', { name: 'Export' }))
+ expect(view.getByRole('menuitem', { name: 'Original' }).querySelector('img')).not.toBeNull()
+ await icons.dispose()
+ expect(view.queryAllByTestId('plugin-icon')).toHaveLength(0)
+ fireEvent.click(view.getByRole('menuitem', { name: 'Open' }))
+ expect(run).toHaveBeenCalledOnce()
+ await contribution.dispose()
+ await menuPlugin.dispose()
+ expect(runtime.slots.spec('contextMenu.icon')).toBeUndefined()
+})
+
+it('tracks configured shortcuts for nested and footer actions without invoking command dispatch', () => {
+ const shortcuts = new ShortcutRegistry('desktop', 'macos')
+ const commandId = 'session.rename' as ShortcutCommandId
+ const resolveCommand = vi.fn(() => ({ status: 'pass' as const }))
+ const remove = shortcuts.register({ id: commandId, label: () => 'Rename', aliases: [],
+ defaults: { 'desktop:macos': { code: 'KeyG', modifiers: ['primary', 'alt'] } },
+ regions: ['page'], modals: [], resolve: resolveCommand })
+ const store = createMenuStore().create()
+ store.actions.open({ kind: 'session', value: { sessionId: SessionId('captured'), displayTitle: 'Captured' } }, { x: 30, y: 40 })
+ store.actions.items([
+ { id: 'rename', label: 'Rename', commandId },
+ { id: 'missing', label: 'Unknown command', commandId: 'absent' as ShortcutCommandId },
+ { id: 'parent', label: 'Submenu', children: [{ id: 'nested', label: 'Nested', commandId }] },
+ { id: 'footer', label: 'Footer', commandId, afterContent: true },
+ ])
+ const select = vi.fn()
+ const view = render( null} />)
+ const item = view.getByRole('menuitem', { name: 'Rename' })
+ expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G')
+ expect(view.getByRole('menuitem', { name: 'Unknown command' }).getAttribute('aria-keyshortcuts')).toBeNull()
+ act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'custom' as never,
+ document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': { code: 'KeyR', modifiers: ['primary', 'shift'] } } } } }) })
+ expect(item.getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R')
+ expect(view.getByRole('menuitem', { name: 'Footer' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R')
+ fireEvent.click(view.getByRole('menuitem', { name: 'Submenu' }))
+ expect(view.getByRole('menuitem', { name: 'Nested' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R')
+ act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'unbound' as never,
+ document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': null } } } }) })
+ expect(item.getAttribute('aria-keyshortcuts')).toBeNull()
+ expect(item.querySelector('kbd')).toBeNull()
+ act(remove)
+ expect(item.getAttribute('aria-keyshortcuts')).toBeNull()
+ fireEvent.click(item)
+ expect(select).toHaveBeenCalledWith('rename')
+ expect(resolveCommand).not.toHaveBeenCalled()
+})
diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts
new file mode 100644
index 0000000000..afd3d97350
--- /dev/null
+++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts
@@ -0,0 +1,197 @@
+// @vitest-environment jsdom
+import type { MenuAction } from '../src/client/types.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { MenuOverlayInjected } from '../src/client/View.tsx'
+import { apply, inject } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import type { createMenuStore } from '../src/client/store.ts'
+
+afterEach(() => { vi.useRealTimers() })
+
+declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
+ interface ContextMenuTargetMap { pluginDocument: { readonly id: string } }
+}
+
+it('lets an external contribution open another menu and withdraws its actions when its fiber unloads', async () => {
+ const ctx = new Context()
+ ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
+ let overlay!: MenuOverlayInjected
+ let store!: ReturnType['create']>
+ 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 () => {}
+ },
+ } as never)
+ ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
+ const menuFiber = ctx.plugin({ inject, apply })
+ await menuFiber.await()
+ expect(hostApply).not.toThrow()
+ const run = vi.fn()
+ 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.contextMenus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 })
+ } }],
+ }))
+ } })
+ await contribution.await()
+ try {
+ 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.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.contextMenus.notify('Saved', true)
+ expect(store.getSnapshot().notice).toMatchObject({ text: 'Saved', success: true })
+ ctx.contextMenus.notify('Unavailable')
+ expect(store.getSnapshot().notice).toMatchObject({ text: 'Unavailable', success: false })
+ const reveal = vi.fn(async () => {})
+ 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.contextMenus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
+ const replaced = store.getSnapshot().notice!.action!
+ ctx.contextMenus.notify('New notice')
+ replaced.onClick()
+ expect(reveal).toHaveBeenCalledOnce()
+ for (const fail of [() => { throw new Error('denied') }, async () => { throw new Error('offline') }]) {
+ 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.contextMenus.register({ target: 'pluginDocument', id: 'failure', order: 0,
+ resolve: () => { throw new Error('Metadata unavailable') } })
+ ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 })
+ expect(store.getSnapshot().notice?.text).toBe('failed')
+ removeFailure()
+ 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.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()
+ } finally { await contribution.dispose(); await menuFiber.dispose() }
+})
+
+it('waits for metadata, toggles nearby repeated gestures, replaces occurrences, and cancels pending openings', async () => {
+ vi.useFakeTimers()
+ const ctx = new Context()
+ ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
+ let store!: ReturnType['create']>
+ ctx.provide('slots', { provideRoot: () => () => {},
+ inject: (_name: string, register: Parameters[1]) => ctx.effect(register),
+ register: (entry: { store: ReturnType }) => { store = entry.store.create(); return () => {} },
+ } as never)
+ ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
+ const fiber = ctx.plugin({ inject, apply })
+ await fiber.await()
+ let finish!: (actions: readonly MenuAction[]) => void
+ 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.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.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.contextMenus.open(target, { x: 80, y: 30 })
+ finish([{ id: 'second', label: 'Second' }])
+ await vi.advanceTimersByTimeAsync(40)
+ expect(store.getSnapshot().occurrence).toBe(first + 1)
+ 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.contextMenus.open(target, { x: 20, y: 30 })
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ finish([{ id: 'escape', label: 'Escape' }])
+ await vi.advanceTimersByTimeAsync(100)
+ expect(store.getSnapshot().target).toBeNull()
+ } finally { await fiber.dispose() }
+})
+
+it('keeps trigger focus through replacement and ignores non-dismissal events and late feedback', async () => {
+ vi.useFakeTimers()
+ const ctx = new Context()
+ ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
+ let overlay!: MenuOverlayInjected
+ 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.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.contextMenus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 })
+ await vi.advanceTimersByTimeAsync(40)
+ expect(overlay.returnFocus?.()).toBe(trigger)
+ menu.focus()
+ 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.contextMenus.active.getSnapshot()).not.toBeNull()
+ document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true }))
+ 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.contextMenus.active.getSnapshot()).toBeNull()
+ const active = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
+ 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('leaves unbound keyboard gestures native', async () => {
+ const ctx = new Context()
+ ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } 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()
+ try {
+ 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
new file mode 100644
index 0000000000..c533f3b49d
--- /dev/null
+++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts
@@ -0,0 +1,218 @@
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { describe, expect, it, vi } from 'vitest'
+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 ContextMenuTargetMap { extension: { readonly id: string } }
+}
+const target = { kind: 'extension' as const, value: { id: 'clicked' } }
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (error: Error) => void
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no })
+ return { promise, resolve, reject }
+}
+
+describe('menu contribution lifecycle', () => {
+ it('orders independently resolved contributions and executes the captured target once', async () => {
+ const failure = vi.fn(), run = vi.fn()
+ const registry = new MenuRegistry(failure)
+ const waiting = deferred()
+ registry.register({ id: 'later', target: 'extension', order: 20, resolve: () => waiting.promise })
+ registry.register({ id: 'first', target: 'extension', order: 10, resolve: ({ id }) => [{ id: 'open', label: id, run: () => { run(id) } }] })
+ const menu = registry.resolve(target)
+ expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open'])
+ waiting.resolve([{ id: 'open', label: 'Later' }]); await waiting.promise
+ expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open', 'later/open'])
+ expect(JSON.parse(JSON.stringify(menu.source.getSnapshot()))).toEqual(menu.source.getSnapshot())
+ menu.select('first/open'); menu.select('first/open')
+ expect(run).toHaveBeenCalledExactlyOnceWith('clicked')
+ expect(failure).not.toHaveBeenCalled()
+ registry.dispose()
+ })
+
+ it.each(['menu', 'provider', 'registry'] as const)('aborts pending metadata when its %s retires', async (retire) => {
+ const failure = vi.fn(), run = vi.fn()
+ const registry = new MenuRegistry(failure)
+ const waiting = deferred()
+ let signal!: AbortSignal
+ const remove = registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, lifetime) => { signal = lifetime; return waiting.promise } })
+ const menu = registry.resolve(target)
+ if (retire === 'menu') menu.dispose()
+ if (retire === 'provider') remove()
+ if (retire === 'registry') registry.dispose()
+ expect(signal.aborted).toBe(true)
+ waiting.resolve([{ id: 'late', label: 'Late', run }]); await waiting.promise
+ expect(menu.source.getSnapshot()).toEqual([])
+ menu.select('slow/late')
+ expect(run).not.toHaveBeenCalled()
+ expect(failure).not.toHaveBeenCalled()
+ menu.dispose(); remove(); registry.dispose()
+ })
+
+ it('withdraws resolved actions on unload and takes new contributions on the next opening', () => {
+ const registry = new MenuRegistry(vi.fn()), run = vi.fn()
+ const remove = registry.register({ id: 'old', target: 'extension', order: 0, resolve: () => [{ id: 'a', label: 'A', run }] })
+ const first = registry.resolve(target)
+ remove(); remove()
+ expect(first.source.getSnapshot()).toEqual([])
+ first.select('old/a'); expect(run).not.toHaveBeenCalled()
+ registry.register({ id: 'new', target: 'extension', order: 0, resolve: () => [{ id: 'b', label: 'B' }] })
+ expect(first.source.getSnapshot()).toEqual([])
+ const next = registry.resolve(target)
+ expect(next.source.getSnapshot()[0]?.id).toBe('new/b')
+ first.dispose(); next.dispose(); registry.dispose()
+ expect(() => registry.resolve(target)).toThrow('disposed')
+ })
+
+ it('contains provider failure and rejects duplicate contributions without corrupting other actions', async () => {
+ const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed)
+ const definition = { id: 'good', target: 'extension' as const, order: 0, resolve: () => [{ id: 'a', label: 'A', run }] }
+ registry.register(definition)
+ expect(() => registry.register(definition)).toThrow('Duplicate menu contribution')
+ registry.register({ id: 'bad', target: 'extension', order: 1, resolve: () => [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }] })
+ registry.register({ id: 'rejected', target: 'extension', order: 2, resolve: () => Promise.reject(new Error('unavailable')) })
+ const menu = registry.resolve(target)
+ await Promise.resolve(); await Promise.resolve()
+ expect(failed).toHaveBeenCalledTimes(2)
+ expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['good/a'])
+ menu.select('bad/a'); expect(run).not.toHaveBeenCalled()
+ menu.select('good/a'); expect(run).toHaveBeenCalledOnce()
+ registry.dispose()
+ })
+
+ it('ignores disabled, submenu and unknown selections and reports an action rejection', async () => {
+ const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed)
+ registry.register({ id: 'actions', target: 'extension', order: 0, resolve: () => [
+ { id: 'disabled', label: 'Disabled', disabled: true, run },
+ { id: 'parent', label: 'Parent', children: [{ id: 'child', label: 'Child', run: () => Promise.reject(new Error('failed')) }], run },
+ ] })
+ const menu = registry.resolve(target)
+ menu.select('missing'); menu.select('actions/disabled'); menu.select('actions/parent')
+ expect(run).not.toHaveBeenCalled()
+ menu.select('actions/parent/child'); await Promise.resolve(); await Promise.resolve()
+ expect(failed).toHaveBeenCalledOnce()
+ registry.dispose()
+ })
+
+ it('orders equal-priority providers by identity and contains synchronous action failures', () => {
+ const failed = vi.fn(), registry = new MenuRegistry(failed)
+ registry.register({ id: 'z', target: 'extension', order: 1, resolve: () => [{ id: 'none', label: 'No action' }] })
+ registry.register({ id: 'a', target: 'extension', order: 1, resolve: () => [{ id: 'throw', label: 'Fail',
+ failureLabel: 'Could not copy. Try again.', run: () => { throw new Error('clipboard denied') } }] })
+ const first = registry.resolve(target)
+ expect(first.source.getSnapshot().map(item => item.id)).toEqual(['a/throw', 'z/none'])
+ first.select('a/throw')
+ expect(failed).toHaveBeenCalledWith('Could not copy. Try again.')
+ const second = registry.resolve(target)
+ second.select('z/none')
+ registry.dispose()
+ expect(() => registry.register({ id: 'late', target: 'extension', order: 0, resolve: () => [] })).toThrow('disposed')
+ })
+
+ it('does not report cancelled metadata failures, including unload during resolution', async () => {
+ const failed = vi.fn(), registry = new MenuRegistry(failed)
+ let remove = () => {}
+ remove = registry.register({ id: 'self-removing', target: 'extension', order: 0, resolve: () => {
+ remove(); throw new Error('owner unloaded')
+ } })
+ const waiting = deferred()
+ registry.register({ id: 'late-rejection', target: 'extension', order: 1, resolve: () => waiting.promise })
+ const menu = registry.resolve(target)
+ menu.dispose()
+ waiting.reject(new Error('cancelled'))
+ await Promise.resolve(); await Promise.resolve()
+ expect(failed).not.toHaveBeenCalled()
+ registry.dispose()
+ })
+})
+
+it('omits a provider that misses its deadline and ignores its late result', async () => {
+ vi.useFakeTimers()
+ const failed = vi.fn(), registry = new MenuRegistry(failed, 50)
+ const waiting = deferred()
+ let signal!: AbortSignal
+ registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, value) => { signal = value; return waiting.promise } })
+ registry.register({ id: 'ready', target: 'extension', order: 1, resolve: () => [{ id: 'copy', label: 'Copy' }] })
+ const menu = registry.resolve(target)
+ try {
+ await vi.advanceTimersByTimeAsync(50)
+ await menu.ready
+ expect(signal.aborted).toBe(true)
+ expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy'])
+ expect(failed).toHaveBeenCalledOnce()
+ waiting.resolve([{ id: 'late', label: 'Late' }])
+ await waiting.promise
+ expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy'])
+ } finally { registry.dispose(); vi.useRealTimers() }
+})
+
+it('replaces observed actions and callbacks, cancels older preparation, and releases every subscription', async () => {
+ const input = createSnapshotStore(0)
+ const original = input.subscribe.bind(input)
+ const subscribe = vi.spyOn(input, 'subscribe')
+ const detach = vi.fn()
+ subscribe.mockImplementation((listener) => {
+ const off = original(listener)
+ return () => { detach(); off() }
+ })
+ const failed = vi.fn(), registry = new MenuRegistry(failed), run = vi.fn()
+ const old = deferred(), current = deferred()
+ const signals: AbortSignal[] = []
+ const resolve = vi.fn((_target: { id: string }, signal: AbortSignal) => {
+ signals.push(signal)
+ return input.getSnapshot() === 0 ? old.promise : current.promise
+ })
+ const remove = registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve })
+ const menu = registry.resolve(target)
+ try {
+ input.set(1)
+ expect(signals[0]?.aborted).toBe(true)
+ old.resolve([{ id: 'old', label: 'Old', run }])
+ await old.promise
+ expect(menu.source.getSnapshot()).toEqual([])
+ current.resolve([{ id: 'new', label: 'New', run }])
+ await menu.ready
+ expect(menu.source.getSnapshot().map(item => item.id)).toEqual(['live/new'])
+ menu.select('live/old')
+ expect(run).not.toHaveBeenCalled()
+ remove()
+ expect(detach).toHaveBeenCalledOnce()
+ expect(menu.source.getSnapshot()).toEqual([])
+ input.set(2)
+ expect(resolve).toHaveBeenCalledTimes(2)
+ expect(signals.every(signal => signal.aborted)).toBe(true)
+ expect(failed).not.toHaveBeenCalled()
+ } finally { menu.dispose(); registry.dispose() }
+ expect(detach).toHaveBeenCalledOnce()
+})
+
+it('withdraws invalid reactive updates and contains observation failure without retaining stale callbacks', () => {
+ const input = createSnapshotStore(false), failed = vi.fn(), run = vi.fn()
+ const registry = new MenuRegistry(failed)
+ registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve: () => {
+ if (input.getSnapshot()) throw new Error('No access')
+ return [{ id: 'run', label: 'Run', run }]
+ } })
+ registry.register({ id: 'broken', target: 'extension', order: 1, observe: () => { throw new Error('Unavailable') }, resolve: () => [] })
+ const menu = registry.resolve(target)
+ input.set(true)
+ expect(menu.source.getSnapshot()).toEqual([])
+ menu.select('live/run')
+ expect(run).not.toHaveBeenCalled()
+ expect(failed).toHaveBeenCalledTimes(2)
+ menu.dispose(); registry.dispose()
+})
+
+it('does not report an observation failure after its provider unloads', () => {
+ const failed = vi.fn(), registry = new MenuRegistry(failed)
+ let remove = () => {}
+ remove = registry.register({ id: 'removed', target: 'extension', order: 0,
+ observe: () => { remove(); throw new Error('Unloaded') }, resolve: () => [] })
+ const menu = registry.resolve(target)
+ expect(menu.source.getSnapshot()).toEqual([])
+ expect(failed).not.toHaveBeenCalled()
+ menu.dispose(); registry.dispose()
+})
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
new file mode 100644
index 0000000000..0f6830f205
--- /dev/null
+++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts
@@ -0,0 +1,14 @@
+import { expect, it } from 'vitest'
+import { menuTargetKey } from '../src/client/target-key.ts'
+import type { ContextMenuTarget } from '../src/client/types.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
+ 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', () => {
+ const first: ContextMenuTarget = { kind: 'nested', value: { id: 'a', data: { a: 1, b: ['x', null] } } }
+ const second: ContextMenuTarget = { value: { data: { b: ['x', null], a: 1 }, id: 'a' }, kind: 'nested' }
+ expect(menuTargetKey(first)).toBe(menuTargetKey(second))
+ expect(menuTargetKey(first)).not.toBe(menuTargetKey({ kind: 'nested', value: { id: 'a', data: { a: 1, b: [null, 'x'] } } }))
+})
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..d83a906a3b
--- /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, ContextMenuTarget } from '../src/client/types.ts'
+
+afterEach(cleanup)
+const target = (id: string): ContextMenuTarget => ({ 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-context-menu/tsconfig.json b/packages/client/ui-context-menu/tsconfig.json
new file mode 100644
index 0000000000..e12f61a4c8
--- /dev/null
+++ b/packages/client/ui-context-menu/tsconfig.json
@@ -0,0 +1,45 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../util/brand"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../locale/tsconfig.client.json"
+ },
+ {
+ "path": "../store"
+ },
+ {
+ "path": "../ui-slots"
+ },
+ {
+ "path": "../ui-primitives"
+ },
+ {
+ "path": "../ui-dockkit"
+ },
+ {
+ "path": "../ui-layout"
+ },
+ {
+ "path": "../ui-renderer"
+ },
+ {
+ "path": "../shortcuts/tsconfig.client.json"
+ }
+ ]
+}
diff --git a/packages/client/ui-context-menu/tsdown.config.ts b/packages/client/ui-context-menu/tsdown.config.ts
new file mode 100644
index 0000000000..7aa03cebc1
--- /dev/null
+++ b/packages/client/ui-context-menu/tsdown.config.ts
@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 353750e5e9..0f945d3708 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 9b596b668979544a
zh: 159f24dfc8715c92
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
- en: bb1244310f5c0382
- zh: 824b84077432b559
+ en: 59cd63057dfb6eeb
+ zh: b2e2c86e810b7bb8
/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 0633998db6..2002f10c1b 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -184,6 +184,8 @@ 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 receives `getTriggerProps` from its Factory’s injected `useContextMenu` hook.
+
## Model Experience
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 84993c9f9e..b1d1c5fbd3 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -184,6 +184,8 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。
+会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库从其 Factory 注入的 `useContextMenu` hook 获取 `getTriggerProps`。
+
## 模型体验
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index d2924b00e3..f168e7f6c8 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-settings",
- "@deepseek-ai/dsh-client-ui-workspace"
+ "@deepseek-ai/dsh-client-ui-workspace",
+ "@deepseek-ai/dsh-client-ui-context-menu"
],
"platform": "web"
}
@@ -97,7 +98,8 @@
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
- "@deepseek-ai/dsh-message-quotes": "workspace:*"
+ "@deepseek-ai/dsh-message-quotes": "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 b3c0d3bcd0..3328e1e5da 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -1,3 +1,4 @@
+import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index caec4849b4..369f562a7b 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -475,6 +475,7 @@ export interface ConversationViewActionsOwnerProps {
/** Business callbacks injected into the strict Session header. */
export interface ConversationSessionHeaderInjected {
+ /** Open the clicked title's actions. */
/** 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/index.ts b/packages/client/ui-conversation/src/client/index.ts
index d4f06accc5..7a39200c30 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/packages/client/ui-conversation/src/client/index.ts
@@ -1,3 +1,4 @@
+import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
/** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */
export type {} from './user-text/session-reference-rule.ts'
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
index deaddcf769..14602cbca0 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
@@ -107,8 +107,9 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
*/
export function ConversationSessionHeader({
sessionId, hideChrome, useSessions, useConversationViews, useStore,
- renderSlot, open, 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)
@@ -176,7 +177,7 @@ export function ConversationSessionHeader({
...last ? {} : { openTitle: () => { open(summary.id) } },
}
return (
-
+
{index > 0 && /}
{lineage
? summary.subagent
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index 523fcffaae..d968bdc4d8 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { render, cleanup } from '@testing-library/react'
import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical'
import { createUserTextFixture } from './user-text-component-fixture.client.tsx'
@@ -103,6 +104,8 @@ async function bench(renderQuote = false) {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.root.declare({
'main': { kind: 'keyed', scope: 'root' },
'quote-probe': { kind: 'single', scope: 'session' },
diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
index a7b0bfdbdf..154d31ec9f 100644
--- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { describe, expect, it, vi } from 'vitest'
import {
SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages,
@@ -25,6 +26,8 @@ async function bench(options: { declareConversation?: boolean } = {}) {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
if (options.declareConversation !== false) {
await runtime.root.declare({
'main': { kind: 'keyed', scope: 'root' },
diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
index e4c2a2c037..c8e96efdbc 100644
--- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
/** Conversation assembly acceptance independent of Tool presentation. */
import './control-row-dom.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -86,6 +87,8 @@ async function bench(opts?: { blank?: boolean }) {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
@@ -109,6 +112,8 @@ describe('resident composer', () => {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
@@ -136,6 +141,8 @@ describe('resident composer', () => {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
@@ -203,6 +210,8 @@ describe('prompt rejection through the assembled composer', () => {
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
const prompt = vi.fn(async () => ({
ok: false,
error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }),
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..3106eeb495 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'
@@ -47,6 +48,7 @@ function mount() {
useResource,
useWorkspaces: emptyWorkspaces(),
...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..da4c7dc61b 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
@@ -195,6 +196,7 @@ function bench(over?: BenchOptions) {
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})),
...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..517848498a 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 /
@@ -67,6 +68,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
baselinesReady: true, recentWorkspaceId: undefined,
})),
...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..2129c605d2 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
@@ -169,6 +170,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
baselinesReady: true, recentWorkspaceId: undefined,
})),
...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..44878bbc18 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.
@@ -118,6 +119,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial
),
useWorkspaces: (() => { throw new Error('unused') }) as never,
...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 2f7a1fd979..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'
@@ -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/tests/user-text-component-fixture.client.tsx b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx
index f4970b398d..c5383f3091 100644
--- a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx
+++ b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx
@@ -2,7 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import { createElement, type ComponentProps, type ReactNode } from 'react'
import { onTestFinished } from 'vitest'
-import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { UserTextInput, UserTextNodeOwnerProps } from '../src/client/contract/slots.ts'
import type { UserTextNode } from '../src/client/contract/user-text.ts'
@@ -62,7 +62,7 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in
case 'secret': return createElement(SecretText, { ...owner, t } as ComponentProps)
case 'text': return createElement(PlainUserText, owner as ComponentProps)
case 'session': return createElement(SessionReference, owner as ComponentProps)
- case 'file': return createElement(FileReference, { ...owner, openFile, t } as ComponentProps)
+ case 'file': return createElement(FileReference, { ...owner, openFile, t, useContextMenu: contextMenuHook(), resolveFilePath: (path: string) => path } as ComponentProps)
case 'folder': return createElement(FolderReference, owner as ComponentProps)
case 'skill': return createElement(SkillReference, { ...owner, openSkill } as ComponentProps)
case 'command': return createElement(GoalCommandReference, owner as ComponentProps)
diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json
index 0339eebbe6..0187cf5d72 100644
--- a/packages/client/ui-conversation/tsconfig.client.json
+++ b/packages/client/ui-conversation/tsconfig.client.json
@@ -104,6 +104,9 @@
},
{
"path": "../product-analytics/tsconfig.client.json"
+ },
+ {
+ "path": "../ui-context-menu"
}
],
"exclude": [
diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml
index ff6839eee5..675bcff105 100644
--- a/packages/client/ui-deliverables/README.i18n.yaml
+++ b/packages/client/ui-deliverables/README.i18n.yaml
@@ -33,8 +33,8 @@
en: 7ee5ee783902662c
zh: fdf2c244fb22b8ab
/deepseek-ai-dsh-client-ui-deliverables/further-exploration:
- en: 476b59e8671db982
- zh: b3d0495a03e8940a
+ en: 98eda99fafcb04b9
+ zh: e6a21254c0383f48
/deepseek-ai-dsh-client-ui-deliverables/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md
index 6727c12776..0632574d83 100644
--- a/packages/client/ui-deliverables/README.md
+++ b/packages/client/ui-deliverables/README.md
@@ -86,6 +86,8 @@ Read these pages when the deliverables surface is not enough. They move from the
-----
+Delivery cards and known inline file mentions expose a [context menu](../ui-context-menu/README.md) for their declared Session file, preserving the original path and optional line.
+
## Model Experience
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md
index 45484627dd..2951888e72 100644
--- a/packages/client/ui-deliverables/README.zh.md
+++ b/packages/client/ui-deliverables/README.zh.md
@@ -86,6 +86,8 @@ Node 半部提供经过身份验证的原生文件打开;共用指导由[模
-----
+交付卡片和已知行内文件引用为其声明的会话文件提供[右键菜单](../ui-context-menu/README.zh.md),保留原始路径和可选行号。
+
## 模型体验
diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json
index e64e93ae83..4172b1f4c2 100644
--- a/packages/client/ui-deliverables/package.json
+++ b/packages/client/ui-deliverables/package.json
@@ -37,7 +37,8 @@
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-ui-tool",
- "@deepseek-ai/dsh-client-ui-settings"
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-ui-context-menu"
],
"platform": "web"
}
@@ -80,7 +81,8 @@
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
- "@deepseek-ai/dsh-client-ui-settings": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-settings": "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 544e7a82b2..89cfa24afa 100644
--- a/packages/client/ui-deliverables/src/client/Deliverables.tsx
+++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx
@@ -66,11 +66,12 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
* @returns the closing turn's file rows.
*/
export function Deliverables({
- matched, openFile, 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)
@@ -109,6 +110,7 @@ export function Deliverables({
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
host={host === 'error' ? null : host} t={t}
onPreview={() => { openFile(file.path) }}
+ 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 b40bfa40a0..6e71d25d15 100644
--- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
+++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
@@ -1,3 +1,4 @@
+import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
/** 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'
@@ -19,12 +20,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, 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
+ contextMenuProps: ContextMenuTriggerProps
actions: ReactNode
} & PropsLocale) {
const reveal = host?.fileManager ?? 'directory'
@@ -36,7 +38,8 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions,
: reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`)
return
+ aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview}
+ {...contextMenuProps} />
diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts
index 6edc7dee77..e07e6090ef 100644
--- a/packages/client/ui-deliverables/src/client/index.ts
+++ b/packages/client/ui-deliverables/src/client/index.ts
@@ -1,3 +1,4 @@
+import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
/**
* Deliverables plugin, browser half: registers the changed-files card and
* delivery cards into the chat view's turn-tail list, the `changes-review`
@@ -103,13 +104,14 @@ export function apply(ctx: ClientContext): void {
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.
const mentions: ChatFileMentions = {
- forClosing(owner) {
+ forClosing(owner, _sessionId, getFileTriggerProps) {
const paths = selectProducedFiles(owner)
const presented = presentedForClosing(owner)
if (paths === null && presented.length === 0) return undefined
return producedFileMentions([...new Set([...paths ?? [], ...presented.map(file => file.path)])], owner.openFile,
path => t('presented.previewButton', { name: path }),
- owner.turn.data.get('deliverables')?.produced)
+ owner.turn.data.get('deliverables')?.produced,
+ getFileTriggerProps)
},
}
ctx.provide('chatFileMentions', mentions)
diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
index 07aba395cf..d51b54edc4 100644
--- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts
+++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
@@ -274,6 +274,7 @@ export { basename } from '../presented.ts'
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @param operations - successful mutations whose literal arguments may name the same canonical targets.
+ * @param getFileTriggerProps - optional gesture bindings for the resolved original path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
@@ -282,6 +283,7 @@ export function producedFileMentions(
openFile: (path: string) => void,
label: (path: string) => string,
operations: readonly Pick
[] = [],
+ getFileTriggerProps?: (path: string) => NonNullable>['contextMenuProps'],
): MarkdownFileMentions {
return {
resolve(value) {
@@ -291,7 +293,9 @@ export function producedFileMentions(
}
const path = matches.size === 1 ? matches.values().next().value : undefined
if (path === undefined) return undefined
- return { open: () => { openFile(path) }, label: label(path), title: path }
+ const contextMenuProps = getFileTriggerProps?.(path)
+ return { open: () => { openFile(path) }, label: label(path), title: path,
+ ...(contextMenuProps === undefined ? {} : { contextMenuProps }) }
},
}
}
diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
index cb0e129c6b..9f8ed1d7b1 100644
--- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* ui-deliverables browser half: the derivation contract of produced paths,
* recorded changes, and deliveries over engine-published Turn data, the
@@ -43,7 +44,10 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const diffs = new ChangesDiffStore()
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }
+ const openMenu = vi.fn()
return {
+ openMenu,
+ useContextMenu: contextMenuHook(openMenu),
SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}>,
renderSlot: renderFileActions,
useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()),
@@ -838,6 +842,7 @@ describe('producedFileMentions resolver', () => {
describe('plugin registration', () => {
it('registers the tail entry and fiber disposal removes it', async () => {
const ctx = new Context()
+ ctx.provide('contextMenus', { register: () => () => {}, open: vi.fn() } as never)
await ctx.plugin(SlotRegistry).await()
new UiConversation(ctx, { binding: () => undefined } as never)
// The owning view's child declaration, stood up by a bench root entry.
@@ -906,10 +911,16 @@ describe('plugin registration', () => {
const preview = vi.fn<(path: string) => void>()
for (const produced of [[], [{ path: 'out/report.docx' }]]) {
const delivered = tailOwner({ produced, presented: [{ path: 'out/report.docx', seq: 2, index: 0 }] }, 3, preview)
- const mentions = service?.forClosing(delivered, SessionId('child-session'))
+ const { getTriggerProps } = contextMenuHook((target, point) => { ctx.contextMenus.open(target, point) })()
+ const mentions = service?.forClosing(delivered, SessionId('child-session'), path =>
+ getTriggerProps({ kind: 'file', value: { sessionId: SessionId('child-session'), path } }))
for (const text of ['report.docx', 'out/report.docx']) {
const mention = mentions?.resolve(text)
expect(mention?.label).toBe('Open out/report.docx in sidebar')
+ const trigger = render()
+ fireEvent.contextMenu(trigger.getByRole('button'), { clientX: 41, clientY: 52 })
+ trigger.unmount()
+ expect(vi.spyOn(ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'out/report.docx' } }, { x: 41, y: 52 })
mention?.open()
}
}
@@ -1014,6 +1025,8 @@ describe('presented files', () => {
const props = openProps()
props.openPresented.mockResolvedValue({ kind: 'opened' })
const view = render()
+ fireEvent.contextMenu(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }), { clientX: 18, clientY: 29 })
+ expect(props.openMenu).toHaveBeenCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report-0.docx' } }, { x: 18, y: 29 })
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
const expand = view.getByRole('button', { name: 'Show all 8 delivered files' })
expect(expand.getAttribute('aria-expanded')).toBe('false')
diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
index 192fb447de..0b009d5915 100644
--- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
+++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
@@ -2,20 +2,25 @@
/** Explicit file actions preserve their destination, availability, and independent failure state. */
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
-const props = () => ({
- cwd: undefined,
- file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 },
- host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
- phase: undefined,
- onPreview: vi.fn(),
- actions: ,
- t: makeTranslate(en),
-})
+const props = () => {
+ const onMenu = vi.fn()
+ return {
+ cwd: undefined,
+ file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 },
+ host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
+ phase: undefined,
+ onPreview: vi.fn(),
+ onMenu, contextMenuProps: contextMenuHook((_target, point) => { onMenu(point) })()
+ .getTriggerProps({ kind: 'image', value: { url: 'unused' } }),
+ actions: ,
+ t: makeTranslate(en),
+ }
+}
it('localizes reveal failures and restores the description when progress clears', () => {
const p = props()
const view = render()
@@ -66,6 +71,8 @@ it('renders the supplied action independently from the card preview', () => {
fireEvent.click(view.getByRole('button', { name: 'Native file action' }))
expect(native).toHaveBeenCalledOnce()
expect(p.onPreview).not.toHaveBeenCalled()
+ fireEvent.contextMenu(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }), { clientX: 26, clientY: 37 })
+ expect(p.onMenu).toHaveBeenCalledWith({ x: 26, y: 37 })
fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
expect(p.onPreview).toHaveBeenCalledOnce()
})
diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json
index 4fe248edd6..bd78605122 100644
--- a/packages/client/ui-deliverables/tsconfig.client.json
+++ b/packages/client/ui-deliverables/tsconfig.client.json
@@ -68,6 +68,9 @@
},
{
"path": "../../util/workspace-path"
+ },
+ {
+ "path": "../ui-context-menu"
}
]
}
diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx
index 846a4b75e8..36ec07ca0b 100644
--- a/packages/client/ui-dockkit/src/components/DockSurface.tsx
+++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx
@@ -9,9 +9,9 @@
* drop target changes nothing.
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
-import type { ReactNode } from 'react'
+import type { ReactNode, HTMLAttributes } from 'react'
import type { DockIntents, DockLabels, TabMenuExtras, TabRenderer } from '../contract/adapter.ts'
-import type { LayoutState, PaneId, SplitId, TabId } from '../contract/types.ts'
+import type { LayoutState, PaneId, SplitId, TabId, TabRecord } from '../contract/types.ts'
import { clampSizes, FLOAT_DEFAULT_SIZE, MIN_PANE_FRACTION } from '../engine/constraints.ts'
import { getSplit, topRightPaneId } from '../engine/tree.ts'
import type { DropTarget, HalvesFit } from '../engine/geometry.ts'
@@ -63,7 +63,11 @@ export interface DockSurfaceProps {
* slot, and nothing outside that embedder is expected to supply it.
*/
readonly renderTabTitle?: TabRenderer
- /** Extra items for a tab's context menu; omit for the kit's own item only. */
+ /** Whether the embedder popup is preparing or visible; suppresses tab tooltips. */
+ readonly tabMenuOpen?: boolean | undefined
+ /** Override the kit popup; the embedder owns rendering and dismissal. */
+ readonly getTabTriggerProps?: ((tab: TabRecord, paneId: PaneId, handlers?: Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) | undefined
+ /** Extra rows for the kit popup; ignored when getTabTriggerProps is supplied. */
readonly renderTabMenuItems?: TabMenuExtras
/**
* Surface-wide controls, drawn at the far end of the top-right pane's tab
@@ -169,7 +173,8 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
/** The split tree and the gestures over it. */
function Surface({
- state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
+ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems,
+ getTabTriggerProps, tabMenuOpen, chrome, onRoom,
draw,
dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
}: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode {
@@ -291,6 +296,7 @@ function Surface({
renderTab,
renderTabTitle,
renderTabMenuItems,
+ getTabTriggerProps, tabMenuOpen,
chromePaneId: topRightPaneId(state),
chrome,
}
diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx
index df0e39bdb4..a7d2e721ea 100644
--- a/packages/client/ui-dockkit/src/components/TabPanel.tsx
+++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx
@@ -22,6 +22,7 @@
* `canAddTab` allows), the pane's split control, and the embedder's chrome keep
* their width and their place at the strip's end.
*/
+import type { KeyboardEvent } from 'react'
import { Fragment, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import clsx from 'clsx'
@@ -303,6 +304,13 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re
// A pane's lone unclosable chip is a label, not a choice: there is
// no other tab to select against and nothing to do to it.
const quiet = !closable && pane.tabs.length === 1
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.target !== event.currentTarget) return
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return
+ const next = chipToFocus(event.key, pane.tabs, tabId)
+ if (next !== undefined) { event.preventDefault(); focusChip(next); return }
+ if (selects(event.key)) { event.preventDefault(); activate(tabId) }
+ }
return (
{(index > 0 || stripIndex === index) && (
@@ -350,30 +358,18 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re
event.stopPropagation()
activate(tabId)
}}
- onKeyDown={(event) => {
- // Keys on the chip's nested close control are that control's.
- if (event.target !== event.currentTarget) return
- if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return
- const next = chipToFocus(event.key, pane.tabs, tabId)
- if (next !== undefined) {
- event.preventDefault()
- focusChip(next)
- return
- }
- if (selects(event.key)) {
- event.preventDefault()
- activate(tabId)
- }
- }}
- onContextMenu={(event) => {
- event.preventDefault()
- const anchor = event.currentTarget
- setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
+ {...callbacks.getTabTriggerProps?.(tab, pane.id, { onKeyDown, tabIndex: selected ? 0 : -1 }) ?? {
+ onKeyDown,
+ onContextMenu: (event) => {
+ event.preventDefault(); event.stopPropagation()
+ const anchor = event.currentTarget
+ setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
+ },
}}
>
{callbacks.renderTabTitle?.(tab) ?? tab.title}
{closable && (
-
+