Merge remote-tracking branch 'origin/master' into fix/web-prompt-retry

This commit is contained in:
liukx0205
2026-11-04 15:34:17 +08:00
116 changed files with 2939 additions and 1063 deletions
+7
View File
@@ -9,6 +9,11 @@ on:
description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包'
type: string
required: true
staging_percentage:
description: '更新灰度比例:0–100 整数,默认 100;留空沿用包内值,作用于原包所属渠道'
type: string
required: false
default: '100'
upload_website:
description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布'
type: boolean
@@ -66,6 +71,7 @@ jobs:
website-destination: ${{ steps.check.outputs.website-destination }}
label: ${{ steps.check.outputs.label }}
env:
DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }}
DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }}
DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }}
DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }}
@@ -111,6 +117,7 @@ jobs:
cancel-in-progress: false
timeout-minutes: 90
env:
DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }}
DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }}
DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }}
DSH_TELEMETRY_DISABLED: '1'
+2 -2
View File
@@ -24,8 +24,8 @@
en: 0f43f2e193e95043
zh: 1c1efb062c06f7b8
/desktop-ci-publication/package-and-publish:
en: 3f58698708671202
zh: be0ba215a67168d6
en: e62e9d93a50e6489
zh: 2491c37e40f4c496
/desktop-ci-publication/recovery-and-records:
en: d6eb9cbac5b819a0
zh: 0de1ec24055d1007
+2
View File
@@ -68,6 +68,8 @@ Environment approval gates this workflow, not arbitrary code running under the s
## Package and publish
The publication form accepts an optional `staging_percentage` integer from 0 to 100. It applies to the retained package’s channel (nightly or stable), appears in the approval summary, and is included in the published YAML and audit record without modifying retained artifacts. Leave it blank for the packaged default (normally full rollout). Completed publications cannot change this percentage through a retry; same-version feed conflicts remain errors.
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
+2
View File
@@ -68,6 +68,8 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 打包与发布
发布表单支持可选的 `staging_percentage`,取值为 0 到 100 的整数。该值作用于保留包所属的 nightly 或 stable 渠道,显示在审批摘要中,并写入发布的 YAML 和审计记录,不修改保留产物。留空沿用包内默认值(通常为全量)。已完成的发布不能通过重试修改比例;同版本清单内容冲突仍会报错。
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
@@ -1,4 +1,6 @@
/** Render the verified publication plan for GitHub's native deployment reviewers. */
import { load } from 'js-yaml'
import { z } from 'zod'
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs'
@@ -48,6 +50,10 @@ export async function desktopPublicationSummary(
row('发布环境', code(plan.environment)),
row('版本号', code(plan.version)),
row('升级通道', code(source.channel)),
...feeds.map((feed) => {
const metadata = z.object({ stagingPercentage: z.number().optional() }).parse(load(feed.contents!))
return row('更新灰度比例', metadata.stagingPercentage === undefined ? '全量(未设置)' : `${metadata.stagingPercentage}%`)
}),
row('平台', code(plan.target)),
...(plan.environment === 'test' ? [row('测试 release ID', `${code(source.testReleaseId!)}<br>测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []),
row('下载地址预览', `${installers.map(file => link(url(file.key))).join('<br>')}<br>发布后的目标地址,审批前不保证可下载。`),
@@ -40,7 +40,7 @@ export interface DesktopPublicationRecord {
*/
export async function createDesktopPublicationRecord(
root: string,
identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string },
identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string; stagingPercentage?: number | undefined },
): Promise<DesktopPublicationRecord> {
await mkdir(root, { recursive: true, mode: 0o700 })
const staging = await mkdtemp(join(root, '.initializing-'))
@@ -78,6 +78,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: {
runId: string
attempt: string
destination: string
stagingPercentage?: number | undefined
}): Promise<boolean> {
let entries
try { entries = await readdir(root, { withFileTypes: true }) } catch (error) {
@@ -93,7 +94,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: {
throw error
}
const parsed = z.object({ operation: z.string(), repository: z.string(),
runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation)
runId: z.string(), attempt: z.string(), destination: z.string(), stagingPercentage: z.number().optional() }).parse(operation)
if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId
|| parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue
let events
@@ -103,7 +104,12 @@ export async function wasDesktopAttemptPublished(root: string, expected: {
}
// Only newline-terminated entries were completely appended before an interruption.
for (const line of events.split('\n').slice(0, -1)) {
if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true
if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') {
if (parsed.stagingPercentage !== expected.stagingPercentage) {
throw new Error('desktop publish: staging percentage differs from the completed publication')
}
return true
}
}
}
return false
@@ -61,6 +61,8 @@ export interface DesktopUploadPlan {
export interface DesktopUploadPlanOptions {
/** Publish only the installer at its fixed download URL, replacing the previous object. */
readonly latest?: boolean
/** Percentage of installations eligible for this publication; absent preserves packaged metadata. */
readonly stagingPercentage?: number | undefined
readonly environment?: NodeJS.ProcessEnv
readonly repositoryRoot?: string
readonly appRoot?: string
@@ -272,6 +274,7 @@ export async function createDesktopUploadPlan(
const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}`
const published = {
...metadata,
...(options.stagingPercentage === undefined ? {} : { stagingPercentage: options.stagingPercentage }),
files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }],
...(metadata.path === undefined ? {} : { path: payloadUrl }),
}
+10 -4
View File
@@ -28,6 +28,11 @@ const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z
* @returns Validated local publication context; no network operation is performed.
*/
export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) {
const percentage = env.DESKTOP_STAGING_PERCENTAGE?.trim() || ''
if (percentage !== '' && (!/^\d+$/u.test(percentage) || Number(percentage) > 100)) {
throw new Error('desktop publish: staging percentage must be an integer from 0 to 100')
}
const stagingPercentage = percentage === '' ? undefined : Number(percentage)
const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}'))
if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine')
const root = desktopReleasesRoot(env)
@@ -48,12 +53,13 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi
DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket }
: { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket }
const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory,
stagingPercentage,
packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } })
if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes')
await verifySignatures(directory, manifest, config)
const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex')
if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval')
return { route, manifest, config, plan, root, approval }
return { route, manifest, config, plan, root, approval, stagingPercentage }
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
@@ -62,7 +68,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
const mode = process.argv[2]
if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish')
const env = process.env
const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env)
const { route, manifest, config, plan, root, approval, stagingPercentage } = await prepareDesktopPublication(env)
if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required')
if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production'
&& !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) {
@@ -84,11 +90,11 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
phase = 'channel lock and publication journal acquisition'
await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => {
const published = await wasDesktopAttemptPublished(recordsRoot,
{ repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination })
{ repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination, stagingPercentage })
const journal = await createDesktopPublicationRecord(recordsRoot, {
repository: manifest.source.repository, runId: route.runId, attempt: route.attempt,
actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown',
destination, operation: 'publish', reason: 'Actions publication',
destination, stagingPercentage, operation: 'publish', reason: 'Actions publication',
})
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`)
let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed'
@@ -1,3 +1,4 @@
import { load } from 'js-yaml'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -61,10 +62,25 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu
expect(summary).not.toContain('example.com//')
expect(summary).not.toContain(root)
expect(summary).not.toContain('absent-credentials')
for (const percentage of ['0', '10', '100']) {
const staged = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature)
const stagedFeed = staged.plan.artifacts.find(file => file.channelMetadata)!
expect(load(stagedFeed.contents!)).toMatchObject({ stagingPercentage: Number(percentage) })
expect(staged.approval).not.toBe(prepared.approval)
expect(await desktopPublicationSummary(staged.manifest.source, staged.plan, staged.approval, false)).toContain(`${percentage}%`)
await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage,
DESKTOP_APPROVED_PACKAGE: prepared.approval }, signature)).rejects.toThrow(/changed after approval/u)
}
for (const percentage of ['-1', '101', '1.5', 'NaN', 'Infinity', '1e1']) {
await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature))
.rejects.toThrow(/integer from 0 to 100/u)
}
const blank = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: ' ' }, signature)
expect(blank.approval).toBe(prepared.approval)
await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u)
await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u)
await writeFile(join(retained, `${base}.${extension}`), 'changed')
await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u)
expect(signatureChecks).toBe(2)
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -52,10 +52,10 @@ it('persists intent, before/after bytes and terminal state outside the checkout'
} finally { await rm(root, { recursive: true, force: true }) }
})
it('recognizes feed completion even when the later tag failed', async () => {
it.each([undefined, 10])('recognizes feed completion with percentage %s even when the later tag failed', async (stagingPercentage) => {
const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-'))
try {
const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' }
const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac', stagingPercentage }
const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' })
expect(await wasDesktopAttemptPublished(root, identity)).toBe(false)
await journal.record({ type: 'feeds-complete' })
@@ -63,6 +63,8 @@ it('recognizes feed completion even when the later tag failed', async () => {
await journal.finish('published-tag-pending')
expect(await wasDesktopAttemptPublished(root, identity)).toBe(true)
expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false)
await expect(wasDesktopAttemptPublished(root, { ...identity, stagingPercentage: 20 }))
.rejects.toThrow(/differs from the completed publication/u)
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -178,11 +178,14 @@ describe('desktop upload plan', () => {
})
it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => {
const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly'))
const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable'))
const nightly = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'nightly'), stagingPercentage: 10 })
const stable = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'stable'), stagingPercentage: 10 })
expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml'])
expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml'])
expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true)
for (const plan of [nightly, stable]) {
expect(plan.artifacts.find(file => file.channelMetadata)!.contents).toContain('stagingPercentage: 10')
}
const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable')
await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u)
})
@@ -8,6 +8,7 @@
| 发布环境 | <code>test</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>nightly</code> |
| 更新灰度比例 | 全量(未设置) |
| 平台 | <code>mac-arm64</code> |
| 测试 release ID | <code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code><br>测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 |
| 下载地址预览 | <a href="https&#58;//example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/mac-arm64/deepseek-harness-1.0.0-mac-arm64.dmg">https&#58;//example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/mac-arm64/deepseek-harness-1.0.0-mac-arm64.dmg</a><br>发布后的目标地址,审批前不保证可下载。 |
@@ -8,6 +8,7 @@
| 发布环境 | <code>production</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>stable</code> |
| 更新灰度比例 | 全量(未设置) |
| 平台 | <code>win-x64</code> |
| 下载地址预览 | <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
| 升级 YAML | <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
@@ -8,6 +8,7 @@
| 发布环境 | <code>production</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>stable</code> |
| 更新灰度比例 | 全量(未设置) |
| 平台 | <code>win-x64</code> |
| 下载地址预览 | <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
| 升级 YAML | <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
@@ -8,6 +8,7 @@
| 发布环境 | <code>test</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>nightly</code> |
| 更新灰度比例 | 全量(未设置) |
| 平台 | <code>win-x64</code> |
| 测试 release ID | <code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code><br>测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 |
| 下载地址预览 | <a href="https&#58;//example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/win-x64/deepseek-harness-1.0.0-win-x64.exe">https&#58;//example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/win-x64/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
+7 -2
View File
@@ -1,6 +1,7 @@
/** Context gestures against built plugins, seeded Sessions, and original files. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Locator, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
@@ -38,7 +39,10 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
let scaffold: WebScaffold, browser: Browser, page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) })
scaffold = await launchWebScaffold({
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
extraOverlayPath: fileURLToPath(new URL('./context-menus.overlay.yml', import.meta.url)),
})
await mkdir(join(scaffold.workspaceCwd, 'folder'))
await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace')
await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n')
@@ -70,7 +74,8 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
const names = await menuLabels(menu)
expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder'])
expect(names.slice(0, 4)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID'])
expect(names).not.toContain('Open in')
expect(names.at(-1)).toBe('Archive session')
expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true)
expect(await menu.getByRole('separator').count()).toBe(2)
+4
View File
@@ -0,0 +1,4 @@
# Context gestures do not query the runner's native file associations.
- id: session-controller
config:
nativeOpen: false
@@ -2,5 +2,4 @@ Pin session
Rename
Fork session
Copy Session ID
Show in folder
Archive session
@@ -0,0 +1,5 @@
- menu:
- menuitem "Copy Session as Markdown"
- menuitem "Download session log"
- separator
- menuitem "Feedback"
@@ -0,0 +1,5 @@
- dialog "Exporting Session":
- heading "Exporting Session" [level=2]
- button "Close"
- paragraph: "Session: open-app-menu-foreground Preparing a ZIP containing the selected Session, its sub-Sessions, and attachments."
- button "Close"
@@ -7,7 +7,9 @@
{
"stage": "more-hover",
"details": true,
"tooltips": []
"tooltips": [
"Session actions for Use the read tool twice"
]
},
{
"stage": "archive-before-dwell",
@@ -4,6 +4,6 @@
- menuitem "Fork session"
- separator
- menuitem "Copy Session ID"
- menuitem "Show in Finder"
- menuitem "Open in"
- separator
- menuitem "Archive session"
+324
View File
@@ -0,0 +1,324 @@
/** Session menu opening over built plugins; only native application discovery and launch are simulated. */
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, webkit, type Browser, type BrowserContext, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
const FIRST = 'open-app-menu-foreground'
const OTHER = 'open-app-menu-background'
const ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" rx="3" fill="gray"/></svg>'
const devices = [
{ name: 'macOS desktop', platform: 'MacIntel', width: 1280, height: 800, touch: false, mobile: false },
{ name: 'Windows desktop', platform: 'Win32', width: 1280, height: 800, touch: false, mobile: false },
{ name: 'Linux desktop', platform: 'Linux x86_64', width: 1280, height: 800, touch: false, mobile: false },
{ name: 'narrow desktop', platform: 'MacIntel', width: 390, height: 844, touch: false, mobile: false },
{ name: 'iPhone', platform: 'iPhone', width: 390, height: 844, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' },
{ name: 'Android', platform: 'Linux armv8l', width: 412, height: 915, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' },
{ name: 'iPad portrait', platform: 'MacIntel', width: 820, height: 1180, touch: true, mobile: true },
{ name: 'iPad landscape', platform: 'MacIntel', width: 1180, height: 820, touch: true, mobile: true },
] as const
/** Choose the recorded Session through the real sidebar before testing viewport changes. */
async function openSeed(page: Page, url: string): Promise<void> {
await page.goto(url)
const group = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' })
await group.click()
await page.locator(`[data-row-key="session:${FIRST}"]`).click()
await page.getByText('DONE', { exact: true }).waitFor()
}
/** Every visible parent and child menu stays inside the viewport, including long lists. */
async function expectMenusInside(page: Page): Promise<void> {
const bounds = await page.getByRole('menu').evaluateAll(nodes => nodes.map((node) => {
const rect = node.getBoundingClientRect()
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: innerWidth, height: innerHeight }
}))
expect(bounds.length).toBeGreaterThan(0)
for (const rect of bounds) {
expect(rect.left).toBeGreaterThanOrEqual(0)
expect(rect.top).toBeGreaterThanOrEqual(0)
expect(rect.right).toBeLessThanOrEqual(rect.width)
expect(rect.bottom).toBeLessThanOrEqual(rect.height)
}
}
/** Install an instance-local native-operation simulation without changing the menu implementation. */
async function nativeRoutes(
context: BrowserContext,
catalog: readonly string[],
launch: (body: { app: string; path: string }) => Promise<number>,
): Promise<void> {
await context.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: catalog } }))
await context.route('**/open-in-app/icon/*', route => route.fulfill({ body: ICON, contentType: 'image/svg+xml' }))
await context.route('**/open-in-app/open', async (route) => {
const status = await launch(route.request().postDataJSON() as { app: string; path: string })
await route.fulfill({ status, json: { ok: status === 200 } })
})
}
describe('Open in Session menus', () => {
let scaffold: WebScaffold
let browser: Browser
let otherDirectory: string
beforeAll(async () => {
// The real Host receives SSH facts to avoid probing or starting this runner's native applications.
scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([
{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } },
]) })
const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
otherDirectory = join(scaffold.workspaceCwd, 'background-directory')
await mkdir(otherDirectory)
await seedSession(scaffold, recorded, FIRST)
await seedSession({ ...scaffold, workspaceCwd: otherDirectory }, recorded, OTHER)
browser = await chromium.launch()
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it.each([true, false])('owns Header More without export, with Open in enabled=%s', async (enabled) => {
const isolated = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./open-in-app-no-export.overlay.yml', import.meta.url)),
...(enabled ? { openInAppEnvironment: createLaunchEnvironmentSnapshot([
{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } },
]) } : {}),
})
onTestFinished(() => isolated.close())
const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
await seedSession(isolated, recorded, FIRST)
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
onTestFinished(() => context.close())
const launches: { app: string; path: string }[] = []
await nativeRoutes(context, ['finder'], async (body) => { launches.push(body); return 200 })
const page = await context.newPage()
await openSeed(page, isolated.authenticatedUrl)
const more = page.getByRole('button', { name: 'More actions', exact: true })
if (!enabled) {
expect(await more.count()).toBe(0)
return
}
await more.click()
await page.getByRole('menuitem', { name: 'Open in', exact: true }).waitFor()
expect(await page.getByRole('menuitem', { name: 'Download session log', exact: true }).count()).toBe(0)
expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(0)
await more.click()
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
await more.click()
await page.getByRole('menuitem', { name: 'Open in', exact: true }).click()
await page.getByRole('menuitem', { name: 'Finder', exact: true }).click()
await expect.poll(() => launches.length).toBe(1)
expect(launches[0]).toEqual({ app: 'finder', path: isolated.workspaceCwd })
})
it('keeps export feedback across Session navigation and dismissal does not cancel the download', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
const gate = Promise.withResolvers<undefined>()
const settled = Promise.withResolvers<undefined>()
let requested = false
onTestFinished(async () => {
gate.resolve(undefined)
if (requested) await settled.promise
await context.close()
})
await nativeRoutes(context, ['finder'], async () => 200)
await context.route('**/api/session.export?*', async (route) => {
if (route.request().method() === 'HEAD') {
requested = true
try {
await gate.promise
await route.fulfill({ status: 200 })
} finally { settled.resolve(undefined) }
} else await route.fulfill({ status: 200, contentType: 'application/zip', body: 'controlled download response' })
})
const page = await context.newPage()
await openSeed(page, scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'More actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Download session log', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Exporting Session', exact: true })
await dialog.waitFor()
expect(await dialog.textContent()).toContain(`Session: ${FIRST} `)
const other = page.locator(`[data-row-key="session:${OTHER}"]`)
// Exercise navigation's real handler while the modal owns pointer hit-testing.
await other.dispatchEvent('click')
await expect.poll(() => other.getAttribute('aria-selected')).toBe('true')
expect(await dialog.textContent()).toContain(`Session: ${FIRST} `)
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/open-in-app-menus/global-export.expected.md', import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
await dialog.getByText('Close', { exact: true }).click()
const downloaded = page.waitForEvent('download')
gate.resolve(undefined)
const download = await downloaded
expect(download.suggestedFilename()).toContain(FIRST)
expect(await page.getByRole('dialog').count()).toBe(0)
})
it.each(devices)('opens through Header More on $name with device-specific feedback', async (device) => {
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }, locale: 'en-US', hasTouch: device.touch,
...('userAgent' in device ? { userAgent: device.userAgent } : {}),
})
onTestFinished(() => context.close())
await context.addInitScript(({ platform, touch }) => {
Object.defineProperty(navigator, 'platform', { value: platform })
Object.defineProperty(navigator, 'maxTouchPoints', { value: touch ? 5 : 0 })
}, device)
const launches: { app: string; path: string }[] = []
await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return 200 })
const page = await context.newPage()
await openSeed(page, scaffold.authenticatedUrl)
await page.setViewportSize({ width: device.width, height: device.height })
const more = page.getByRole('button', { name: 'More actions', exact: true })
expect(await page.getByRole('banner').locator('[data-open-target="directory"]').count()).toBe(0)
if (device.touch) await more.tap(); else await more.click()
const parent = page.getByRole('menuitem', { name: 'Open in', exact: true })
await parent.waitFor()
if (device.touch) await more.tap(); else await more.click()
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
expect(await more.getAttribute('aria-expanded')).toBe('false')
if (device.touch) await more.tap(); else await more.click()
await parent.waitFor()
expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(1)
expect(await page.getByRole('menuitem', { name: 'Pin session', exact: true }).count()).toBe(0)
if (device.touch) await parent.tap(); else await parent.click()
await page.getByRole('menuitem', { name: 'Cursor', exact: true }).waitFor()
await expectMenusInside(page)
if (device.width === 390) {
expect(await page.getByRole('menuitem', { name: 'Back', exact: true }).count()).toBe(1)
}
const choice = page.getByRole('menuitem', { name: 'Cursor', exact: true })
if (device.touch) await choice.tap(); else await choice.click()
await expect.poll(() => launches).toEqual([{ app: 'cursor', path: scaffold.workspaceCwd }])
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
await expect.poll(() => page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"cursor"')
if (device.mobile) await page.getByText('Opened on another device', { exact: true }).waitFor()
else expect(await page.getByText('Opened on another device', { exact: true }).count()).toBe(0)
})
it('captures a background Session, remembers only success, and prevents a second launch while busy', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
onTestFinished(() => context.close())
const response = Promise.withResolvers<number>()
onTestFinished(() => { response.resolve(502) })
const launches: { app: string; path: string }[] = []
await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return response.promise })
const page = await context.newPage()
await openSeed(page, scaffold.authenticatedUrl)
const background = page.locator(`[data-row-key="session:${OTHER}"]`)
await background.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open in', exact: true }).click()
await page.getByRole('menuitem', { name: 'Cursor', exact: true }).click()
await expect.poll(() => launches).toEqual([{ app: 'cursor', path: otherDirectory }])
expect(await background.getAttribute('aria-selected')).toBe('false')
await page.getByRole('button', { name: 'More actions', exact: true }).click()
const disabled = page.getByRole('menuitem', { name: 'Open in', exact: true })
await expect.poll(() => disabled.isDisabled()).toBe(true)
expect(launches).toHaveLength(1)
await page.keyboard.press('Escape')
response.resolve(502)
await page.getByText('Could not open. Try again.', { exact: true }).waitFor()
expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBeNull()
await background.hover()
await background.getByRole('button', { name: /actions/i }).click()
await expect.poll(() => page.getByRole('menuitem', { name: 'Open in', exact: true }).isEnabled()).toBe(true)
expect(await page.getByRole('menuitem', { name: 'Show in Finder', exact: true }).count()).toBe(0)
})
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'tracks real geometry and ignores unrelated portals in $name', async ({ engine }) => {
const isolated = await engine.launch()
onTestFinished(() => isolated.close())
const context = await isolated.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' })
await nativeRoutes(context, ['vscode', 'cursor', 'finder', 'windowsterminal'], async () => 200)
const page = await context.newPage()
await openSeed(page, scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'More actions', exact: true }).click()
const parent = page.getByRole('menuitem', { name: 'Open in', exact: true })
await page.mouse.move(20, 750)
await parent.focus()
await page.keyboard.press('ArrowRight')
const root = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }) })
const child = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }) })
await child.waitFor()
const rootElement = await root.elementHandle()
const childElement = await child.elementHandle()
const parentElement = await parent.elementHandle()
if (rootElement === null || childElement === null || parentElement === null) throw new Error('Both menu panels and the parent row must be mounted')
const alignment = async () => {
const [outer, nested, row] = await Promise.all([rootElement.boundingBox(), childElement.boundingBox(), parentElement.boundingBox()])
return outer === null || nested === null || row === null ? Infinity : Math.max(
Math.abs(outer.x - nested.x - nested.width - 4), Math.abs(nested.y - row.y),
)
}
// offsetWidth is integer-valued while browser rectangles retain fractional pixels.
await expect.poll(alignment).toBeLessThanOrEqual(1)
// Browser layout changes are real; no observer callback or rectangle is mocked.
await parent.evaluate((node) => { node.style.marginTop = '24px' })
await expect.poll(alignment).toBeLessThanOrEqual(1)
const before = await child.boundingBox()
await page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }).getByText('Windows Terminal', { exact: true })
.evaluate((node) => { node.style.fontSize = '30px' })
await expect.poll(async () => (await child.boundingBox())?.width ?? 0).toBeGreaterThan(before?.width ?? 0)
await expect.poll(alignment).toBeLessThanOrEqual(1)
await rootElement.evaluate((node) => {
node.style.maxHeight = '130px'
const viewport = node.querySelector<HTMLElement>(':scope > [role="presentation"]')
if (viewport === null || viewport.scrollHeight <= viewport.clientHeight) throw new Error('The parent must scroll')
viewport.scrollTop = viewport.scrollHeight
})
await expect.poll(alignment).toBeLessThanOrEqual(1)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('menuitem', { name: 'Back', exact: true }).waitFor()
await expectMenusInside(page)
expect(await childElement.evaluate(node => node.isConnected)).toBe(true)
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
}))
const writes = await childElement.evaluate(async (node) => {
let count = 0
const observer = new MutationObserver((records) => { count += records.length })
observer.observe(node, { attributes: true, attributeFilter: ['style'] })
const unrelated = document.createElement('div')
unrelated.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none'
try {
document.body.append(unrelated)
await new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
})
return count
} finally { observer.disconnect(); unrelated.remove() }
})
expect(writes).toBe(0)
await page.getByRole('menuitem', { name: 'Back', exact: true }).click()
await parent.waitFor()
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
},
)
it.each([{ catalog: [] }, { catalog: ['unnameable'] }, { catalog: ['finder'] }])('keeps empty and single-application catalogs consistent: $catalog', async ({ catalog }) => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
onTestFinished(() => context.close())
await nativeRoutes(context, catalog, async () => 200)
const page = await context.newPage()
await openSeed(page, scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'More actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor()
const open = page.getByRole('menuitem', { name: 'Open in', exact: true })
if (catalog[0] !== 'finder') expect(await open.count()).toBe(0)
else {
await open.click()
await page.getByRole('menuitem', { name: 'Finder', exact: true }).waitFor()
expect(await page.getByRole('menuitem', { name: /^Finder/ }).count()).toBe(1)
await expectMenusInside(page)
}
})
})
@@ -0,0 +1,3 @@
# Header ownership must not depend on the optional export contributor.
- id: session-log-download
disabled: true
+6
View File
@@ -65,6 +65,12 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE)
await page.getByRole('button', { name: 'More actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor()
expect(await page.getByRole('menuitem', { name: 'Open in', exact: true }).count()).toBe(0)
await compareOrRefreshGolden(join(import.meta.dirname, 'expected/context-menus/ssh-header.txt'),
await captureStableAria(page, '[data-context-menu]', scaffold.workspaceCwd), MODE)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md'])
@@ -587,6 +587,8 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
await observe('details')
await row.getByRole('button', { name: /^Session actions for / }).hover()
await hoverPage.clock.runFor(1000)
// The shared menu button's tooltip appears once its bubble has been measured.
await tooltip.filter({ hasText: /^Session actions for / }).waitFor()
expect(await card.count()).toBe(1)
await observe('more-hover')
+1
View File
@@ -187,6 +187,7 @@
"tests/file-upload-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/open-in-app-ssh.e2e.ts",
"tests/open-in-app-menus.e2e.ts",
"tests/fork-mid-turn.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/markdown-images.e2e.ts",
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
/:
en: 54b02da822ff0fbb
zh: 1d43d9775ebb92a8
/session-header-menus-and-export-dialogs:
en: d7c751cc3dbc57b7
zh: 3d08043e08ccf406
/session-header-menus-and-export-dialogs/change:
en: cf5c9760f279f709
zh: cb640560cc139fa9
/session-header-menus-and-export-dialogs/migration:
en: 4a12455c8862476f
zh: 49f775d6fcc10751
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "Custom Web compositions must provide context menus for Session actions, and export dialogs now remain visible across Session switches."
---
# Session Header menus and export dialogs
English | [中文](guide.zh.md)
## Change
The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to contribute copy, download, and feedback actions. Custom Web compositions without a context-menu provider must add one or these client actions cannot activate. The default Web composition already includes it. The Header More button belongs to `ui-conversation`, so removing the export plugin does not remove other registered Header actions such as Open in. The button hides when no Header-menu providers are registered.
Download dialogs are mounted in the root `shell.overlay`, rather than a Session Header. They identify the exported Session and remain visible when the user switches Sessions. Downloads may run concurrently, but dialogs appear one at a time in request order; closing one shows the next and does not cancel its download. Host export routes and the `/export` command are unchanged.
## Migration
1. Add `@deepseek-ai/dsh-client-ui-context-menu` to custom Web compositions that lack it, with the menu package's required services. See [context-menu composition](../../../../packages/client/ui-context-menu/README.md#use-this-package). Keep `ui-conversation` for the Session Header and the shell's root overlay for download feedback. Default Web profiles need no configuration change.
2. Custom client code using `SessionLogDownloadDialogProps` must use root-overlay runtime props instead of Session-header props and remove the `request` injection. `SessionLogDownloadEntry` includes an `order` value; the dialog reads Session identities and ordering from `bySession`.
3. Restart the profile. Confirm that Header More contains the registered actions, Open in remains after disabling the export plugin, and starting downloads for two Sessions presents their dialogs in request order even after switching Sessions.
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "自定义 Web 组合必须为会话操作提供右键菜单服务,导出弹窗在切换 Session 后仍保持显示。"
---
# 会话头部菜单与导出弹窗
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 提供复制、下载和反馈操作。没有右键菜单提供者的自定义 Web 组合必须补充,否则这些客户端操作无法激活。默认 Web 组合已包含提供者。头部更多按钮由 `ui-conversation` 持有,因此移除导出插件不会移除其他已注册的头部操作,例如在应用中打开。没有注册任何头部菜单提供者时,按钮隐藏。
下载弹窗挂载于根级 `shell.overlay`,不再位于 Session Header。弹窗标明正在导出的 Session,切换 Session 后仍然显示。多个下载可以并行进行,但弹窗按发起顺序一次展示一个;关闭当前弹窗会展示下一个,不取消其下载。Host 导出路由及 `/export` 命令不变。
## 迁移
1. 自定义 Web 组合缺少提供者时,添加 `@deepseek-ai/dsh-client-ui-context-menu` 并提供菜单包要求的服务。参见[右键菜单组合](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。保留 `ui-conversation` 以提供会话头部,以及 shell 的根级 overlay 以展示下载反馈。默认 Web profile 无需修改配置。
2. 使用 `SessionLogDownloadDialogProps` 的自定义客户端代码需改用根级 overlay runtime props,不再使用 Session Header props,并移除 `request` 注入。`SessionLogDownloadEntry` 包含 `order` 值;弹窗从 `bySession` 读取 Session 身份与顺序。
3. 重启 profile,确认头部更多菜单包含已注册的操作,禁用导出插件后仍可在应用中打开,并确认两个 Session 的下载弹窗按发起顺序显示,切换 Session 后仍然保留。
@@ -502,7 +502,7 @@ export class SessionCommandController {
case 'remove': {
agent.inbox.remove(request.itemId)
const source = message.source
if ((source.kind === 'user' || source.kind === 'retry') && 'rpcId' in source) {
if (source.kind === 'user' && 'rpcId' in source) {
this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
}
break
+3 -3
View File
@@ -67,9 +67,9 @@
- id: session-log-download
name: '@deepseek-ai/dsh-session-log-export'
# Session-header "Open In..." split button over the host application
# resolution (macOS/Windows/Linux; a host with no resolved application
# renders no button). Two halves: the host routes and the browser surface.
# Session-menu application choices over Host resolution on macOS, Windows,
# and Linux. An empty catalog hides the action. The Host routes and browser
# controls also serve the file-tree directory opener.
- id: open-in-app
name: '@deepseek-ai/dsh-host-open-in-app'
config:
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package:
en: c84c65c7d24d51c3
zh: 98c8f40a326a8f0b
en: 122f4cbfda90c063
zh: f88536b7641e445b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions:
en: 11bea09005de2678
zh: d56e25d2d93022aa
@@ -29,6 +29,8 @@ The Web application bundle mounts this Client plugin. External Client plugins de
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.
Use `renderFactorySlot('contextMenu.button', { target, label, className?, size? })` for an ellipsis trigger. `label` is localized by the caller; `size: 'sm'` selects the compact toolbar button, while omission retains caller-styled row geometry. The factory owns focus, pointer and click isolation, the bottom-edge anchor, and expanded state. Each rendered button toggles only the popup it opened: activating it again closes that popup even after the button moves or its target's display fields change, while another button for an equal target opens its own popup. Repeated right-click and keyboard gestures keep the 4-pixel rule. Target families need no identity rule here. It hides only when the target kind has no registered contribution, without resolving actions early. The stable `ctx.contextMenus.registeredKinds` observable publishes registration and withdrawal immediately, including providers activated after the button mounts. A registered provider returning no actions still makes its button available.
### 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.
@@ -29,6 +29,8 @@ Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.clien
插件通过 `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()`。
省略号触发器使用 `renderFactorySlot('contextMenu.button', { target, label, className?, size? })`。`label` 由调用方本地化;`size: 'sm'` 选择紧凑工具栏按钮,省略时保留调用方设置的行按钮尺寸。工厂统一管理焦点、指针和点击隔离、下边缘锚点与展开状态。每个已渲染的按钮只切换自己打开的弹出菜单:按钮移动或目标展示字段变化后,再次激活仍会关闭该菜单;另一个目标相同的按钮则打开自己的菜单。重复的右键和键盘手势仍沿用 4 像素规则。目标类别无需在此声明身份规则。仅当目标类别没有注册贡献时隐藏按钮,不提前解析操作。稳定的 `ctx.contextMenus.registeredKinds` observable 即时发布注册和注销,包括按钮挂载后才激活的提供方。已注册提供方即使返回空操作,按钮仍可用。
### 注册操作
在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。
@@ -0,0 +1,54 @@
/** Shared ellipsis control for explicit context-menu targets. */
import { Button, IconEllipsisOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots'
import { useId } from 'react'
import type { MouseEvent, PointerEvent } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { ContextMenus, ContextMenuTarget, MenuPoint } from './types.ts'
/** Menu facts and the per-button toggle, bound by the factory registration. */
export interface ContextMenuButtonInjected {
hooks: {
/** Instance id of the shared button that opened the current popup; null for gestures and after dismissal. */
opener: ObservableSnapshot<string | null>
registeredKinds: ContextMenus['registeredKinds']
}
/**
* Close the popup this button opened, or open a new one for its target.
* @param button - instance id of the activated button.
* @param target - captured object.
* @param point - viewport anchor below the button.
*/
toggle: (button: string, target: ContextMenuTarget, point: MenuPoint) => void
}
/**
* Render the menu trigger while its target kind has a registered contribution.
* @param props - Localized owner data and framework-bound menu facts.
* @returns An available target's menu trigger.
*/
export function ContextMenuButton({ target, label, className, size, useOpener, useRegisteredKinds, toggle }: FactoryComponentPropsOf<'contextMenu.button'>) {
const id = useId()
const expanded = useOpener(opener => opener === id)
const available = useRegisteredKinds(kinds => kinds.includes(target.kind))
if (!available) return null
const attributes = {
className,
'data-context-menu-trigger': '',
'aria-label': label,
'aria-haspopup': 'menu' as const,
'aria-expanded': expanded,
onPointerDown: (event: PointerEvent<HTMLButtonElement>) => { event.stopPropagation() },
onClick: (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
event.currentTarget.focus({ preventScroll: true })
const rect = event.currentTarget.getBoundingClientRect()
toggle(id, target, { x: rect.left, y: rect.bottom })
},
}
return <Tooltip label={label} side="bottom" align="end" portal>
{size === 'sm'
? <Button {...attributes} size="sm" icon={<IconEllipsisOutlineRegular />} />
: <button {...attributes} type="button"><IconEllipsisOutlineRegular /></button>}
</Tooltip>
}
@@ -1,10 +1,11 @@
/** 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 { InjectFace, PropsStore, PropsRenderSlots, PropsLocale } 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'
import { isContextMenuTrigger } from './external-trigger.ts'
/**
* @param actions - plain presentation.
@@ -39,14 +40,15 @@ export interface MenuOverlayInjected {
select: (id: string) => void
}
type Props = PropsStore<ReturnType<typeof createMenuStore>>
& PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace<MenuOverlayInjected>
& PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace<MenuOverlayInjected> & PropsLocale<'contextMenu'>
/** @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) {
export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog, t }: Props) {
const catalog = useCatalog(value => value)
const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s)
return <>
<Menu key={occurrence} open={target !== null} anchor={null} portal autoFocus contextual returnFocus={returnFocus}
submenuBackLabel={t('back')} isExternalTrigger={isContextMenuTrigger}
exiting={closing ? replacing ? 'replace' : 'dismiss' : undefined}
anchorPoint={point}
items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)}
@@ -0,0 +1,11 @@
/** Context-menu trigger recognition shared by the document listener and popup. */
/**
* Recognize an explicit menu button without depending on the popup implementation.
* @param node - Pointer event target.
* @returns Whether the node belongs to an explicit menu button.
*/
export function isContextMenuTrigger(node: Node): boolean {
const element = node instanceof Element ? node : node.parentElement
return element?.closest('[data-context-menu-trigger]') != null
}
@@ -11,12 +11,16 @@ import { MenuRegistry } from './registry.ts'
import { createMenuStore } from './store.ts'
import { dictionaries } from './locales.ts'
import { MenuOverlay } from './View.tsx'
import { ContextMenuButton } from './Button.tsx'
import type { ContextMenuButtonInjected } from './Button.tsx'
import type { ContextMenuButtonProps } from './types.ts'
import { isContextMenuTrigger } from './external-trigger.ts'
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 type { ContextMenuIcon, ContextMenuButtonProps } from './types.ts'
export { createMenuStore } from './store.ts'
declare module '@deepseek-ai/cordis' {
@@ -27,13 +31,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Menu bindings for explicit objects, available in every Slot scope. */
useContextMenu: UseContextMenu
}
interface SlotFactoryMap {
/** Ellipsis button hidden only while its target kind has no registered contributions. */
'contextMenu.button': { scope: 'root'; props: ContextMenuButtonProps; inject: ContextMenuButtonInjected }
}
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' }
interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' | 'back' }
}
/** Maximum time to prepare each contribution before omitting its pending actions. */
@@ -51,6 +59,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const instance = handle.create()
const store: typeof handle = { ...handle, create: () => instance }
const targetSource = createSnapshotStore<ContextMenuTarget | null>(null)
// Instance id of the shared button whose activation opened the current popup.
const opener = createSnapshotStore<string | null>(null)
let sequence = 0
let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined
let closeTimer: ReturnType<typeof setTimeout> | undefined
@@ -75,7 +85,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const cancelOpening = (): void => {
clearTimeout(openingTimer); finishOpening?.(); finishOpening = undefined
active?.unsubscribe(); active?.resolution.dispose(); active = undefined
targetSource.set(null)
targetSource.set(null); opener.set(null)
}
const dismiss = (replacing: boolean): void => {
cancelOpening()
@@ -86,42 +96,59 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
const close = (): void => { dismiss(false) }
const contextMenus: ContextMenus = {
active: targetSource,
active: targetSource, registeredKinds: registry.registeredKinds,
register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close,
open: (target, point) => {
open: (target, point, options) => {
const previous = active
if (previous !== undefined
// A repeated gesture closes; shared buttons toggle by their own instance instead.
if (options === undefined && 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<void>((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()
})
show(target, point)
if (options !== undefined) opener.set(options.button)
},
}
function show(target: ContextMenuTarget, point: MenuPoint): void {
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<void>((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)
ctx.slots.registerFactory({
name: 'contextMenu.button', scope: 'root',
inject: () => ({
hooks: { opener, registeredKinds: registry.registeredKinds },
toggle: (button: string, target: ContextMenuTarget, point: MenuPoint) => {
if (disposed) return
if (opener.getSnapshot() === button) { close(); return }
contextMenus.open(target, point, { button })
},
}),
}, ContextMenuButton)
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
if (event.button === 2 || (event.target instanceof Node && isContextMenuTrigger(event.target))
|| (event.target instanceof Element && event.target.closest('[role="menu"]'))) return
close()
}
const context = (event: MouseEvent): void => { if (!event.defaultPrevented) close() }
@@ -141,7 +168,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
}, 'context menu: lifetime')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'context-menu', store,
name: 'shell.overlay', id: 'context-menu', store, locale: 'contextMenu',
children: {
'contextMenu.content': { kind: 'keyed', scope: 'root' },
'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true },
@@ -149,7 +176,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
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)
targetSource.set(null); opener.set(null); dismiss(false)
selected?.select(id); selected?.dispose() } }),
}, MenuOverlay))
}
@@ -1,11 +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<string, { failed: string }>
zh: { back: '返回', failed: '操作失败,请重试' },
en: { back: 'Back', failed: 'Could not complete the action. Try again.' },
ja: { back: '戻る', failed: '操作に失敗しました。再試行してください。' },
es: { back: 'Atrás', failed: 'No se pudo completar la acción. Inténtelo de nuevo.' },
fr: { back: 'Retour', failed: 'Impossible de terminer l’action. Réessayez.' },
de: { back: 'Zurück', failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' },
ru: { back: 'Назад', failed: 'Не удалось выполнить действие. Повторите попытку.' },
pt: { back: 'Voltar', failed: 'Não foi possível concluir a ação. Tente novamente.' },
} satisfies Record<string, { back: string; failed: string }>
@@ -14,6 +14,9 @@ interface Provider {
/** React-free registry; each occurrence captures the providers registered at opening. */
export class MenuRegistry {
private readonly kindsSource = createSnapshotStore<readonly (keyof ContextMenuTargetMap)[]>([])
/** Registered target kinds, independent of asynchronous action availability. */
readonly registeredKinds: ObservableSnapshot<readonly (keyof ContextMenuTargetMap)[]> = this.kindsSource
private readonly providers = new Map<string, Provider>()
private readonly occurrences = new Set<() => void>()
private disposed = false
@@ -34,9 +37,11 @@ export class MenuRegistry {
resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal),
}
this.providers.set(key, provider)
this.publishKinds()
return () => {
if (provider.lifetime.signal.aborted) return
this.providers.delete(key)
this.publishKinds()
provider.lifetime.abort()
}
}
@@ -156,5 +161,13 @@ export class MenuRegistry {
for (const dispose of this.occurrences) dispose()
for (const provider of this.providers.values()) provider.lifetime.abort()
this.providers.clear()
this.publishKinds()
}
private publishKinds(): void {
const kinds = [...new Set([...this.providers.values()].map(provider => provider.kind))].sort()
const previous = this.kindsSource.getSnapshot()
if (kinds.length === previous.length && kinds.every((kind, index) => kind === previous[index])) return
this.kindsSource.set(kinds)
}
}
@@ -1,4 +1,4 @@
/** Stable equality for the JSON-compatible target vocabulary, independent of object insertion order. */
/** Target equality for repeated pointer and keyboard gestures. */
import type { ContextMenuTarget } from './types.ts'
/**
@@ -44,6 +44,15 @@ export interface ContextMenuTargetMap {
export type ContextMenuTarget = {
[K in keyof ContextMenuTargetMap]: { readonly kind: K; readonly value: ContextMenuTargetMap[K] }
}[keyof ContextMenuTargetMap]
/** Owner data for the shared ellipsis button; labels are already localized. */
export interface ContextMenuButtonProps {
readonly target: ContextMenuTarget
readonly label: string
readonly className?: string | undefined
/** Use the compact toolbar control; omission preserves caller-styled row button geometry. */
readonly size?: 'sm'
}
/** 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. */
@@ -109,6 +118,8 @@ export interface MenuResolution {
export interface ContextMenus {
/** Captured target of the shared popup; null after dismissal. */
readonly active: ObservableSnapshot<ContextMenuTarget | null>
/** Target kinds with registered contributions; updates synchronously on registration and withdrawal, without resolving actions. */
readonly registeredKinds: ObservableSnapshot<readonly (keyof ContextMenuTargetMap)[]>
/**
* Register for subsequent openings; unloading withdraws actions and aborts their queries. Duplicate ids within a target fail.
* @param contribution - target provider.
@@ -116,11 +127,12 @@ export interface ContextMenus {
*/
register<K extends keyof ContextMenuTargetMap>(contribution: MenuContribution<K>): () => void
/**
* Open the single shared popup.
* Open the single shared popup; a repeated gesture on the same target within 4 CSS pixels closes it instead.
* @param target - captured object.
* @param point - viewport position.
* @param options - set by the shared `contextMenu.button`: its instance id replaces the repeated-gesture check.
*/
open(target: ContextMenuTarget, point: MenuPoint): void
open(target: ContextMenuTarget, point: MenuPoint, options?: { readonly button: string }): void
/**
* Resolve an embedded menu without opening the shared popup.
* @param target - captured object.
@@ -0,0 +1,177 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent } from '@testing-library/react'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { Context } from '@deepseek-ai/cordis'
import { useState } from 'react'
import { apply, inject } from '../src/client/index.ts'
import type { ContextMenuTarget, MenuAction } from '../src/client/types.ts'
import { isContextMenuTrigger } from '../src/client/external-trigger.ts'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface ContextMenuTargetMap {
header: { readonly sessionId: SessionId }
entity: { readonly id: string; readonly title: string }
}
}
const target = (id: string): ContextMenuTarget => ({ kind: 'header', value: { sessionId: SessionId(id) } })
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
it('shows late registered kinds without preparing actions and withdraws them with the contributing fiber', 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({}, ({ renderFactorySlot }) => <>
{renderFactorySlot('contextMenu.button', { target: target('header'), label: 'Header menu', size: 'sm' })}
{renderFactorySlot('contextMenu.button', { target: { kind: 'session', value: {
sessionId: SessionId('row'), displayTitle: 'Row',
} }, label: 'Row menu', className: 'row-control' })}
</>)
const view = runtime.renderRoot()
expect(view.queryByRole('button')).toBeNull()
const plugin = await runtime.mount({ inject, apply })
expect(view.queryByRole('button')).toBeNull()
const resolve = vi.fn(() => new Promise<readonly MenuAction[]>(() => {}))
const row = await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({
id: 'row', target: 'session', order: 0, resolve: () => [],
})) } })
expect(view.getByRole('button', { name: 'Row menu' })).toBeTruthy()
expect(view.queryByRole('button', { name: 'Header menu' })).toBeNull()
const contribution = await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({
id: 'header', target: 'header', order: 0, resolve,
})) } })
expect(view.getByRole('button', { name: 'Header menu' }).getAttribute('aria-haspopup')).toBe('menu')
expect(resolve).not.toHaveBeenCalled()
const source = runtime.ctx.contextMenus.registeredKinds
await contribution.dispose()
expect(view.queryByRole('button', { name: 'Header menu' })).toBeNull()
expect(view.getByRole('button', { name: 'Row menu' })).toBeTruthy()
await row.dispose()
expect(view.queryByRole('button')).toBeNull()
await plugin.dispose()
expect(source.getSnapshot()).toEqual([])
})
it('toggles moved buttons by target identity, replaces other targets, and preserves right-click distance rules', async () => {
vi.useFakeTimers()
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
const clicked = vi.fn(), pressed = vi.fn()
await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderFactorySlot, renderSlot, useContextMenu }) => {
const { getTriggerProps } = useContextMenu()
return <div onClick={clicked} onPointerDown={pressed}>
{renderFactorySlot('contextMenu.button', { target: target('one'), label: 'First menu', size: 'sm' })}
{renderFactorySlot('contextMenu.button', { target: target('one'), label: 'Same target menu' })}
{renderFactorySlot('contextMenu.button', { target: target('two'), label: 'Second menu' })}
<div {...getTriggerProps(target('one'))}>Right-click target</div>
{renderSlot('shell.overlay', {})}
</div>
})
await runtime.mount({ inject, apply })
await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({
id: 'header', target: 'header', order: 0, resolve: () => [{ id: 'open', label: 'Open' }],
})) } })
const view = runtime.renderRoot()
const first = view.getByRole('button', { name: 'First menu' })
const same = view.getByRole('button', { name: 'Same target menu' })
const second = view.getByRole('button', { name: 'Second menu' })
let rect = new DOMRect(20, 30, 28, 28)
first.getBoundingClientRect = () => rect
same.getBoundingClientRect = () => new DOMRect(300, 400, 20, 20)
const focus = vi.spyOn(first, 'focus')
const open = vi.spyOn(runtime.ctx.contextMenus, 'open')
fireEvent.pointerDown(first)
fireEvent.click(first)
expect(clicked).not.toHaveBeenCalled()
expect(pressed).not.toHaveBeenCalled()
expect(focus).toHaveBeenCalledWith({ preventScroll: true })
expect(open.mock.lastCall?.slice(0, 2)).toEqual([target('one'), { x: 20, y: 58 }])
expect(typeof open.mock.lastCall?.[2]?.button).toBe('string')
expect(first.getAttribute('aria-expanded')).toBe('true')
expect(same.getAttribute('aria-expanded')).toBe('false')
expect(second.getAttribute('aria-expanded')).toBe('false')
await act(() => vi.advanceTimersByTimeAsync(40))
expect(view.getByRole('menuitem', { name: 'Open' })).toBeTruthy()
rect = new DOMRect(200, 300, 28, 28)
fireEvent.pointerDown(first.querySelector('svg')!)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('one'))
fireEvent.click(first)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
expect(first.getAttribute('aria-expanded')).toBe('false')
expect(open).toHaveBeenCalledTimes(1)
fireEvent.click(first)
fireEvent.click(same)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('one'))
expect(first.getAttribute('aria-expanded')).toBe('false')
expect(same.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(same)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
fireEvent.click(first)
fireEvent.pointerDown(second)
fireEvent.click(second)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('two'))
expect(first.getAttribute('aria-expanded')).toBe('false')
expect(second.getAttribute('aria-expanded')).toBe('true')
await act(() => vi.advanceTimersByTimeAsync(40))
fireEvent.keyDown(document, { key: 'Escape' })
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
const region = view.getByText('Right-click target')
fireEvent.contextMenu(region, { clientX: 10, clientY: 10 })
fireEvent.contextMenu(region, { clientX: 14, clientY: 10 })
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
fireEvent.contextMenu(region, { clientX: 10, clientY: 10 })
fireEvent.contextMenu(region, { clientX: 15, clientY: 10 })
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('one'))
fireEvent.pointerDown(document.body)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
})
it('keeps an open button expanded after its owner updates display metadata in the target', 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)
const entity = (title: string): ContextMenuTarget => ({ kind: 'entity', value: { id: 'same', title } })
let update!: (target: ContextMenuTarget) => void
await runtime.root.declare({}, ({ renderFactorySlot }) => {
const [current, setCurrent] = useState(entity('before'))
update = setCurrent
return renderFactorySlot('contextMenu.button', { target: current, label: 'Entity menu' })
})
await runtime.mount({ inject, apply })
await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({
id: 'entity', target: 'entity', order: 0, resolve: () => [],
})) } })
const view = runtime.renderRoot(), button = view.getByRole('button', { name: 'Entity menu' })
fireEvent.click(button)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(entity('before'))
act(() => { update(entity('after')) })
expect(view.getByRole('button', { name: 'Entity menu' })).toBe(button)
expect(button.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(button)
expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull()
expect(button.getAttribute('aria-expanded')).toBe('false')
})
it('recognizes marked elements, SVG descendants and text without treating unrelated or detached nodes as triggers', () => {
const button = document.createElement('button')
button.setAttribute('data-context-menu-trigger', '')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const text = document.createTextNode('More')
button.append(svg, text)
expect(isContextMenuTrigger(button)).toBe(true)
expect(isContextMenuTrigger(svg)).toBe(true)
expect(isContextMenuTrigger(text)).toBe(true)
expect(isContextMenuTrigger(document.body)).toBe(false)
expect(isContextMenuTrigger(document)).toBe(false)
expect(isContextMenuTrigger(document.createTextNode('Detached'))).toBe(false)
})
@@ -3,6 +3,7 @@ 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 { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
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'
@@ -21,7 +22,7 @@ it('renders legacy Session actions before data contributions and restores More f
const useStore = bindSnapshotSelector(store)
const view = render(<>
<button onClick={() => { store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 }) }}>More</button>
<MenuOverlay useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run}
<MenuOverlay t={key => key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run}
renderSlot={(_key, owner: object) => {
const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void }
return <MenuItemButton onSelect={() => {
@@ -55,7 +56,7 @@ it('renders disabled, grouped and nested data actions and keeps feedback after m
{ id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] },
{ id: 'footer', label: 'Folder', afterContent: true },
])
const view = render(<MenuOverlay useCatalog={select => select([])} useStore={bindSnapshotSelector(store)} actions={store.actions}
const view = render(<MenuOverlay t={key => key === 'back' ? 'Back' : 'Failed'} useCatalog={select => 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' }))
@@ -90,7 +91,9 @@ it('dispatches captured icon keys in rows, submenus and footers and retires rend
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)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) =>
<>{renderSlot('shell.overlay', {})}</>)
const menuPlugin = await runtime.mount({ inject, apply })
@@ -143,7 +146,7 @@ it('tracks configured shortcuts for nested and footer actions without invoking c
{ id: 'footer', label: 'Footer', commandId, afterContent: true },
])
const select = vi.fn()
const view = render(<MenuOverlay useStore={bindSnapshotSelector(store)} actions={store.actions}
const view = render(<MenuOverlay t={key => key === 'back' ? 'Back' : 'Failed'} useStore={bindSnapshotSelector(store)} actions={store.actions}
useCatalog={bindSnapshotSelector(shortcuts.catalog)} close={vi.fn()} select={select} renderSlot={() => null} />)
const item = view.getByRole('menuitem', { name: 'Rename' })
expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G')
@@ -5,6 +5,7 @@ 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 type { ContextMenuButtonInjected } from '../src/client/Button.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'
@@ -20,7 +21,7 @@ it('lets an external contribution open another menu and withdraws its actions wh
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
let store!: ReturnType<ReturnType<typeof createMenuStore>['create']>
ctx.provide('slots', { provideRoot: () => () => {},
ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType<typeof createMenuStore> }) => {
overlay = entry.inject(); store = entry.store.create(); return () => {}
@@ -95,7 +96,7 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences,
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let store!: ReturnType<ReturnType<typeof createMenuStore>['create']>
ctx.provide('slots', { provideRoot: () => () => {},
ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { store: ReturnType<typeof createMenuStore> }) => { store = entry.store.create(); return () => {} },
} as never)
@@ -142,7 +143,7 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
ctx.provide('slots', { provideRoot: () => () => {},
ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} },
} as never)
@@ -166,6 +167,8 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and
expect(overlay.returnFocus?.()).toBe(trigger)
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 }))
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
trigger.setAttribute('data-context-menu-trigger', '')
trigger.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 }))
@@ -185,7 +188,11 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and
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)
let button!: ContextMenuButtonInjected
ctx.provide('slots', { provideRoot: () => () => {},
registerFactory: (entry: { inject: () => ContextMenuButtonInjected }) => { button = entry.inject(); return () => {} },
inject: () => () => {},
} as never)
ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
@@ -194,4 +201,6 @@ it('leaves unbound keyboard gestures native', async () => {
document.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
} finally { await fiber.dispose() }
button.toggle('retired-button', { kind: 'pluginDocument', value: { id: 'retired' } }, { x: 1, y: 2 })
expect(button.hooks.opener.getSnapshot()).toBeNull()
})
@@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts'
import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface ContextMenuTargetMap { extension: { readonly id: string } }
interface ContextMenuTargetMap { extension: { readonly id: string }; registryHeader: { readonly id: string } }
}
const target = { kind: 'extension' as const, value: { id: 'clicked' } }
@@ -16,6 +16,35 @@ function deferred<T>() {
}
describe('menu contribution lifecycle', () => {
it('publishes registered kinds immediately through one stable source without resolving actions', () => {
const registry = new MenuRegistry(vi.fn()), resolve = vi.fn(() => [])
const source = registry.registeredKinds
const changed = vi.fn()
const detach = source.subscribe(changed)
expect(source.getSnapshot()).toEqual([])
const first = registry.register({ id: 'first', target: 'registryHeader', order: 0, resolve })
expect(source.getSnapshot()).toEqual(['registryHeader'])
const snapshot = source.getSnapshot()
const second = registry.register({ id: 'second', target: 'registryHeader', order: 0, resolve })
expect(source.getSnapshot()).toBe(snapshot)
first(); first()
expect(source.getSnapshot()).toBe(snapshot)
const other = registry.register({ id: 'other', target: 'session', order: 0, resolve })
expect(source.getSnapshot()).toEqual(['registryHeader', 'session'])
second()
expect(source.getSnapshot()).toEqual(['session'])
other()
expect(source.getSnapshot()).toEqual([])
registry.register({ id: 'replacement', target: 'registryHeader', order: 0, resolve })
expect(registry.registeredKinds).toBe(source)
expect(source.getSnapshot()).toEqual(['registryHeader'])
registry.dispose()
expect(source.getSnapshot()).toEqual([])
expect(changed).toHaveBeenCalledTimes(6)
expect(resolve).not.toHaveBeenCalled()
detach()
})
it('orders independently resolved contributions and executes the captured target once', async () => {
const failure = vi.fn(), run = vi.fn()
const registry = new MenuRegistry(failure)
@@ -67,6 +96,21 @@ describe('menu contribution lifecycle', () => {
expect(() => registry.resolve(target)).toThrow('disposed')
})
it('replaces synchronous observed actions and retires their previous callbacks', () => {
const source = createSnapshotStore('first'), run = vi.fn()
const registry = new MenuRegistry(vi.fn())
registry.register({ id: 'changing', target: 'extension', order: 0, observe: () => [source],
resolve: () => [{ id: source.getSnapshot(), label: source.getSnapshot(), run }] })
const menu = registry.resolve(target)
source.set('second')
expect(menu.source.getSnapshot().map(action => action.id)).toEqual(['changing/second'])
menu.select('changing/first')
expect(run).not.toHaveBeenCalled()
menu.select('changing/second')
expect(run).toHaveBeenCalledOnce()
registry.dispose()
})
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 }] }
@@ -21,8 +21,8 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 8994069b62a88825
zh: 262ace7ad9bd35b7
en: bd2a85b9012fa3c3
zh: e88d7e682e316a0e
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: 59cd63057dfb6eeb
zh: b2e2c86e810b7bb8
@@ -110,6 +110,8 @@ The package occupies the root-scoped `main` key `conversation`. Its `main.conver
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps the blank-Session 40px row on every platform, so the Hero input card sits at the same height before and after a Workspace is bound; on macOS desktop that row is also the window drag row. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip.
The Session Header owns its trailing More button and renders the shared `contextMenu.button` factory after utility contributions. This package declares the `sessionHeader` context-menu target `{ sessionId }`, independent of Session-row context actions; Header-menu providers import its client types. The button remains available without the export plugin while another Header-menu provider is registered; it hides when none are registered. Providers determine their own items and availability when the menu opens. [Context menus](../ui-context-menu/README.md) own button toggling and accessibility; [Session log export](../../session-query/session-log-export/README.md) owns export actions and global download dialogs.
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
@@ -110,6 +110,8 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在所有平台都保留与 blank Session 相同的 40px 行,因此绑定 Workspace 前后 Hero 输入卡片的高度位置不变;在 macOS 桌面上,这一行同时是窗口拖拽行。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。
Session Header 持有末尾的更多按钮,在 utility 贡献之后渲染共享 `contextMenu.button` factory。本包声明 `sessionHeader` 右键菜单目标 `{ sessionId }`,独立于会话行右键操作;Header 菜单提供者导入本包的 client 类型。只要仍注册有其他 Header 菜单提供者,未挂载导出插件时按钮也可用;没有任何提供者时隐藏。各提供者在菜单打开时决定自己的条目及可用性。[右键菜单](../ui-context-menu/README.zh.md)负责按钮开关与可访问性;[会话日志导出](../../session-query/session-log-export/README.zh.md)负责导出操作与全局下载弹窗。
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
@@ -25,6 +25,13 @@ import type { ConversationSnapshot } from './snapshot.ts'
import type { ViewTab } from './views.ts'
import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode } from './user-text.ts'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface ContextMenuTargetMap {
/** Session Header More menu, independent of Session-row and title context actions. */
sessionHeader: { readonly sessionId: SessionId }
}
}
/** Browser-owned draft attachment that has not crossed the durable Host boundary. */
export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment
@@ -687,6 +694,7 @@ export type ConversationHeaderProps =
/** Full props of the strict Session header. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderFactories
& PropsRenderSlots<
'conversation.session.header.lineage'
| 'conversation.session.header.actions'
@@ -79,6 +79,7 @@ export const zh = {
'hero.headline': '探索未至之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'header.more': '更多操作',
'session.hierarchy': '会话层级',
'session.moreViews': '更多会话视图',
'todo.title': '任务',
@@ -474,6 +475,7 @@ export const en = {
'hero.headline': 'Into the Unknown',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'header.more': 'More actions',
'session.hierarchy': 'Session hierarchy',
'session.moreViews': 'More conversation views',
'todo.title': 'To-dos',
@@ -868,6 +870,7 @@ export const ja = {
'hero.headline': '未知への挑戦',
'hero.preview': 'プレビュー',
'hero.chooseWorkspace': 'ワークスペースを選択',
'header.more': 'その他の操作',
'session.hierarchy': 'セッション階層',
'session.moreViews': 'その他の会話ビュー',
'todo.title': 'ToDo',
@@ -1260,6 +1263,7 @@ export const es = {
'hero.headline': 'Hacia lo desconocido',
'hero.preview': 'Vista previa',
'hero.chooseWorkspace': 'Elige espacio de trabajo',
'header.more': 'Más acciones',
'session.hierarchy': 'Jerarquía de sesiones',
'session.moreViews': 'Más vistas de conversación',
'todo.title': 'Tareas pendientes',
@@ -1654,6 +1658,7 @@ export const fr = {
'hero.headline': "Dans l'inconnu",
'hero.preview': 'Aperçu',
'hero.chooseWorkspace': 'Choisir un espace de travail',
'header.more': 'Autres actions',
'session.hierarchy': 'Hiérarchie des sessions',
'session.moreViews': 'Plus de vues de conversations',
'todo.title': 'À faire',
@@ -2048,6 +2053,7 @@ export const de = {
'hero.headline': 'Ins Unbekannte',
'hero.preview': 'Vorschau',
'hero.chooseWorkspace': 'Wählen Sie einen Arbeitsbereich',
'header.more': 'Weitere Aktionen',
'session.hierarchy': 'Sitzungshierarchie',
'session.moreViews': 'Weitere Konversationsansichten',
'todo.title': 'Aufgaben',
@@ -2442,6 +2448,7 @@ export const ru = {
'hero.headline': 'В неизвестность',
'hero.preview': 'Предварительный просмотр',
'hero.chooseWorkspace': 'Выберите рабочее пространство',
'header.more': 'Другие действия',
'session.hierarchy': 'Иерархия сеансов',
'session.moreViews': 'Больше просмотров беседы',
'todo.title': 'Дела',
@@ -2836,6 +2843,7 @@ export const pt = {
'hero.headline': 'Para o desconhecido',
'hero.preview': 'Visualização',
'hero.chooseWorkspace': 'Escolha o espaço de trabalho',
'header.more': 'Mais ações',
'session.hierarchy': 'Hierarquia de sessão',
'session.moreViews': 'Mais visualizações de conversas',
'todo.title': 'Tarefas',
@@ -193,6 +193,18 @@
display: none;
}
.headerMenuButton {
flex: none;
width: 28px;
padding: 0;
color: var(--dsw-alias-label-secondary);
}
.headerMenuButton svg {
width: 15px;
height: 15px;
}
/* The far-right corner seat reaches 16px into the header's 28px right padding,
so its control sits past the utilities' edge; it is laid out only while its
occupant renders something. Its left margin equals the utilities' 8px gap so
@@ -107,7 +107,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
*/
export function ConversationSessionHeader({
sessionId, hideChrome, useSessions, useConversationViews, useStore,
renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
renderSlot, renderFactorySlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
}: ConversationSessionHeaderProps) {
const { getTriggerProps } = useContextMenu()
const tabs = useConversationViews(value => value)
@@ -208,6 +208,10 @@ export function ConversationSessionHeader({
</div>
<div className={css.headerUtilities}>
{renderSlot('conversation.session.header.utilities', {})}
{renderFactorySlot('contextMenu.button', {
target: { kind: 'sessionHeader', value: { sessionId } },
label: t('header.more'), className: css.headerMenuButton, size: 'sm',
})}
</div>
</>
)}
@@ -191,6 +191,7 @@ function mount(
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
const headerFactory = vi.fn<ConversationSessionHeaderSlotProps['renderFactorySlot']>(() => null)
const lineageOwners: ConversationHeaderLineageOwnerProps[] = []
const viewTabs = options.viewTabs ?? [
{ id: 'chat', label: 'Chat' },
@@ -252,6 +253,7 @@ function mount(
useStore={bindSnapshotSelector(store)}
actions={store.actions}
renderSlot={renderSlot as never}
renderFactorySlot={headerFactory}
open={open}
selectView={(view) => { store.actions.setView(view) }}
t={t}
@@ -405,7 +407,7 @@ function mount(
const props: ConversationSlotProps = { ...runtimeProps, renderSlot, renderFactorySlot }
const view = render(<ConversationMainPanel {...props} />)
return {
view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, headerFactory, lineageOwners, seatOwners, open,
pickerOwner: () => pickerOwner,
frameOwner: () => frameOwners.at(-1),
setPinned,
@@ -559,6 +561,9 @@ describe('ConversationRoot resident composer', () => {
expect(b.slotCalls).toContain('conversation.session.header.actions')
expect(b.slotCalls).toContain('conversation.session.header.utilities')
expect(b.slotCalls).toContain('conversation.session.header.corner')
expect(b.headerFactory).toHaveBeenCalledWith('contextMenu.button', expect.objectContaining({
target: { kind: 'sessionHeader', value: { sessionId: SID } }, label: zh['header.more'], size: 'sm',
}))
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
@@ -586,6 +591,7 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy()
expect(b.view.queryByRole('tablist')).toBeNull()
expect(b.slotCalls).not.toContain('conversation.session.header.utilities')
expect(b.headerFactory).not.toHaveBeenCalled()
expect(b.slotCalls).not.toContain('conversation.session.header.actions')
expect(b.view.getByText('探索未至之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
+14 -14
View File
@@ -3,32 +3,32 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md
/:
en: be11314755d8f4f9
zh: dd75b03074eea543
en: 8caca7252f2cf5c7
zh: 47a157352d33265e
/deepseek-ai-dsh-client-ui-open-in-app:
en: 004b805ac638e884
zh: 6ac98df58affefcf
/deepseek-ai-dsh-client-ui-open-in-app/summary:
en: e69e4c6df4ddffa1
zh: d1ef0a722f48e9bd
en: 1e68d4eb8f246ed2
zh: d8bf9abe0c1cccee
/deepseek-ai-dsh-client-ui-open-in-app/table-of-contents:
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package:
en: 056e2bd9e3c78dd8
zh: be35d2ba53be0442
en: b149da9963eb9837
zh: 2426ea37fbd0f8b7
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: 51e6019f011f394e
zh: 818391262086ae10
en: e07d3a607d54cb47
zh: 9a7342f05b64db38
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
en: ec7a7c56aaeafce6
zh: 4cee0cd2cb7531df
en: 22da59203ca974e6
zh: f288bd6a193bccef
/deepseek-ai-dsh-client-ui-open-in-app/further-exploration:
en: 562c58d589fb7afa
zh: 9ba88b381160cc96
en: 720527877e452a97
zh: b2241c7a336a3eeb
/deepseek-ai-dsh-client-ui-open-in-app/model-experience:
en: 5f9e6e0fb0438628
zh: fc9a81185ea6cb14
en: aad141aa922e23f7
zh: 18432b4f70971fa7
/deepseek-ai-dsh-client-ui-open-in-app/model-experience/kv-cache-effect:
en: ca75c51c89c2c9b0
zh: c2241315dc0c89c9
+12 -10
View File
@@ -1,5 +1,5 @@
---
description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file."
description: "Open Session directories from application submenus and open previewed files through their native applications."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls.
Open a Session’s directory through **Open in** in its Header More menu or its row/title context menu. The submenu lists installed, localized applications in Host order with icons and names, without a default marker. File-tree, document-preview, and delivery controls also open files or directories through native applications. Availability, icons, and launches come from [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount both packages together. A Session without a known directory or eligible application has no Open in item.
## Table of Contents
@@ -25,23 +25,25 @@ This package provides the browser surface of the open-in-app feature. A Session-
<a id="use-this-package"></a>
## Use this package
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once.
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions.
The Header entry uses the More button owned by [`ui-conversation`](../ui-conversation/README.md), with shared button behavior from [`ui-context-menu`](../ui-context-menu/README.md). Open in remains available there without the export plugin; this package supplies menu items rather than its own Header button.
### What to expect
The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application.
The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows.
On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode.
The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
The **Open locally** shortcut captures the main Session’s directory and remembered application. The effective binding remains in the file-tree opener tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and that Session has a directory and installed application to open. An in-flight launch blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller’s launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action.
Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control.
Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
Workspace context menus reveal the captured folder through the shared directory application catalog. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
@@ -55,7 +57,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
<details>
<summary>Implementation internals — click to expand</summary>
Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing.
[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across the shared context-menu package’s `session` and `sessionHeader` targets; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
@@ -67,7 +69,7 @@ The directory and file adapters supply application metadata and operations to [`
## Further Exploration
- [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them.
- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action.
- [ui-conversation](../ui-conversation/README.md) — the Header owning the shared More button.
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy.
- [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes.
- [Web client architecture](../../../docs/subsystems/web-client.md) — how browser plugin rows load and register slots.
@@ -77,7 +79,7 @@ The directory and file adapters supply application metadata and operations to [`
<a id="model-experience"></a>
## Model Experience
None, as the split button is browser chrome; nothing here reaches a model request.
None, as these opening controls and menus are browser chrome; nothing here reaches a model request.
#### KV Cache effect
+12 -10
View File
@@ -1,5 +1,5 @@
---
description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。"
description: "通过应用子菜单打开会话目录,并通过原生应用打开预览文件。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。
通过会话头部更多菜单或会话行、标题右键菜单中的**在应用中打开**打开会话目录。子菜单按 Host 顺序列出已安装且已本地化的应用,仅显示图标和名称,不标注默认应用。文件树、文档预览和交付控件也可通过原生应用打开文件或目录。可用性、图标和启动由 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 提供;两个包应一起挂载。会话目录未知或没有符合条件的应用时,不显示在应用中打开。
## 目录
@@ -25,23 +25,25 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。
头部入口使用 [`ui-conversation`](../ui-conversation/README.zh.md) 持有的更多按钮,由 [`ui-context-menu`](../ui-context-menu/README.zh.md) 提供共享按钮行为。未挂载导出插件时,在应用中打开仍可通过该入口使用;本包提供菜单项,而不创建自己的头部按钮。
### 预期行为
会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。
会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。
在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。
**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。
**在本地打开**快捷键捕获主会话目录及已记住的应用。有效绑定继续显示在文件树打开按钮的 tooltip 和 `aria-keyshortcuts` 中;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面且该会话具有可打开目录与已安装应用时才执行命令。启动进行中会阻止命令;结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享控制器的启动状态,因此重复操作不会启动两次,也不会在启动期间更改已记住的选择。
文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。
使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。
工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。
工作区右键菜单通过共享的目录应用列表定位被点击的文件夹。文件树目录提供原生定位与复制文件夹路径,不提供文件预览或保存。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
@@ -55,7 +57,7 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。
[`session-open-with.ts`](src/client/session-open-with.ts) 为共享右键菜单包声明的 `session` 和 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
@@ -67,7 +69,7 @@ kind: "package-reference"
## 进一步探索
- [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。
- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。
- [ui-conversation](../ui-conversation/README.zh.md)——持有共享更多按钮的会话头部。
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。
- [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。
- [Web client 架构](../../../docs/subsystems/web-client.zh.md)——浏览器插件行如何加载并注册 slot。
@@ -77,7 +79,7 @@ kind: "package-reference"
<a id="model-experience"></a>
## 模型体验
无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。
无。这些打开控件与菜单是浏览器界面;不会进入模型请求。
#### KV Cache 影响
@@ -1,6 +1,6 @@
{
"meta": {
"title": "外部应用打开界面",
"description": "在会话和文档预览中提供「打开方式」入口。"
"description": "在应用中打开会话目录或预览文件。"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
"description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file",
"description": "Session application submenus and native opening controls for directories and previewed files",
"version": "0.2.2-alpha.3",
"publishConfig": {
"access": "public"
@@ -7,7 +7,7 @@ import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/cli
import { NS } from './locales.ts'
import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx'
/** Browser operations and state shared by the Session header and file tree contributions. */
/** Directory operations and state shared with Session application submenus. */
export interface OpenInAppActionInjected extends OpenTargetReporter {
hooks: {
openInAppApps: ObservableSnapshot<readonly string[] | null>
@@ -1,4 +1,5 @@
/** Native path and URL menu contributions, independent of their trigger components. */
import { useState } from 'react'
import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { Context } from '@deepseek-ai/cordis'
@@ -141,5 +142,8 @@ export function registerContextMenus(
/** @param props - icon metadata captured with the resolved action. @returns Application image or the native-open glyph. */
export function ContextMenuApplicationIcon({ icon }: Pick<PropsRuntime<'contextMenu.icon'>, 'icon'>) {
return icon.src ? <img src={icon.src} width={14} height={14} alt="" /> : <IconRightUpOutlineRegular />
const [failedSource, setFailedSource] = useState<string>()
return icon.src && icon.src !== failedSource
? <img src={icon.src} width={14} height={14} alt="" draggable={false} onError={() => { setFailedSource(icon.src) }} />
: <IconRightUpOutlineRegular />
}
@@ -1,4 +1,4 @@
/** Browser availability/choice state and the launch carrier for the split button. */
/** Shared directory application catalog, preference, and launch carrier for controls and menus. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
@@ -20,7 +20,7 @@ type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
/**
* Owns the once-per-page availability read, the persisted last choice, and
* the launch POST. Availability and choice publish through uSES-safe sources
* so every Session header shares one truth.
* so Session menus and directory controls share the same catalog and choice.
*/
export class OpenInAppController {
/** Installed app ids in host menu order; null until the host answered. */
@@ -107,9 +107,8 @@ export class OpenInAppController {
const payload = await response.json() as OpenInAppAppsPayload
if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string')
}
} catch {
// Swallows network failures: an unreachable host reads as no apps, and
// the header simply shows no button rather than a broken one.
} catch (_error) {
// An unreachable Host supplies no directory openers in menus or controls.
}
this.apps.set(apps)
}
@@ -7,8 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
* file defaults and application lists come from the serving Host desktop.
*/
import { createElement } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -23,8 +21,8 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { OpenInAppController } from './controller.ts'
import { registerSessionReveal } from './session-reveal.tsx'
import { OpenInAppAction, type OpenInAppActionInjected, type OpenInAppActionProps } from './OpenInAppAction.tsx'
import { registerSessionOpenWith } from './session-open-with.ts'
import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
import { OpenInAppPathController } from './open-path.ts'
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
import { FileRouteAction } from './FileRouteAction.tsx'
@@ -35,7 +33,7 @@ import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Session-header "open workspace in application" copy. */
/** Native application menu and control copy. */
'open-in-app': OpenInAppKey
}
}
@@ -70,7 +68,7 @@ export function apply(ctx: ClientContext): void {
yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon)
})
registerContextMenus(ctx, paths, t, controller, announcement.report)
registerSessionReveal(ctx, controller, t, announcement.report)
registerSessionOpenWith(ctx, controller, t, announcement.report)
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'open-in-app.toast', locale: NS,
inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }),
@@ -113,17 +111,6 @@ export function apply(ctx: ClientContext): void {
choose: (appId) => { controller.choose(appId) },
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`,
})
function SessionOpenInAppAction(
props: PropsRuntime<'conversation.session.header.utilities'> & Omit<OpenInAppActionProps, 'absolutePath'>,
) {
const { sessionId, useSessions } = props
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
return cwd ? createElement(OpenInAppAction, { ...props, absolutePath: cwd }) : null
}
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'open-in-app', order: -10, locale: NS, inject: directoryInjected,
}, SessionOpenInAppAction))
ctx.slots.inject('sidebar.right.tab.files.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.files.actions',
id: 'open-in-app', locale: NS, inject: directoryInjected,
@@ -39,6 +39,7 @@ const PRODUCT_NAMES = {
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'menu.openIn': '在应用中打开',
'menu.openInSidebar': '在侧边栏打开',
'menu.openInFloat': '在浮窗中打开',
'menu.finder': '在 Finder 中显示',
@@ -73,6 +74,7 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<OpenInAppKey, string> = {
'menu.openIn': 'Open in',
'menu.openInSidebar': 'Open in sidebar',
'menu.openInFloat': 'Open in floating panel',
'menu.finder': 'Show in Finder',
@@ -110,6 +112,7 @@ export type OpenInAppKey = keyof typeof zh
/** Japanese copy, checked against the English keys. */
export const ja = {
'menu.openIn': 'アプリケーションで開く',
'menu.openInSidebar': 'サイドバーで開く',
'menu.openInFloat': 'フローティングパネルで開く',
'menu.finder': 'Finder に表示',
@@ -144,6 +147,7 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
'menu.openIn': 'Abrir con',
'menu.openInSidebar': 'Abrir en la barra lateral',
'menu.openInFloat': 'Abrir en un panel flotante',
'menu.finder': 'Mostrar en Finder',
@@ -177,6 +181,7 @@ export const es = {
/** French dictionary, checked against the English keys. */
export const fr = {
'menu.openIn': 'Ouvrir avec',
'menu.openInSidebar': 'Ouvrir dans la barre latérale',
'menu.openInFloat': 'Ouvrir dans un panneau flottant',
'menu.finder': 'Afficher dans le Finder',
@@ -210,6 +215,7 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
'menu.openIn': 'Öffnen mit',
'menu.openInSidebar': 'In der Seitenleiste öffnen',
'menu.openInFloat': 'In schwebendem Panel öffnen',
'menu.finder': 'Im Finder anzeigen',
@@ -243,6 +249,7 @@ export const de = {
/** Russian dictionary, checked against the English keys. */
export const ru = {
'menu.openIn': 'Открыть с помощью',
'menu.openInSidebar': 'Открыть на боковой панели',
'menu.openInFloat': 'Открыть на плавающей панели',
'menu.finder': 'Показать в Finder',
@@ -276,6 +283,7 @@ export const ru = {
/** Portuguese dictionary, checked against the English keys. */
export const pt = {
'menu.openIn': 'Abrir com',
'menu.openInSidebar': 'Abrir na barra lateral',
'menu.openInFloat': 'Abrir em painel flutuante',
'menu.finder': 'Mostrar no Finder',
@@ -0,0 +1,51 @@
/** Application submenus for captured Sessions, shared by header and row menus. */
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { APP_LABEL_KEY } from './applications.ts'
import type { OpenInAppController } from './controller.ts'
import type { OpenInAppToastSource } from './open-toast.tsx'
/**
* Share directory availability, successful choices, and launch feedback with the file-tree control.
* @param ctx - owning plugin context.
* @param controller - directory application catalog and launcher.
* @param t - localized action labels.
* @param report - app-wide native operation feedback; launch failures resolve without registry feedback.
*/
export function registerSessionOpenWith(
ctx: Context,
controller: OpenInAppController,
t: PropsLocale<'open-in-app'>['t'],
report: OpenInAppToastSource['report'],
): void {
const resolve = ({ sessionId }: ContextMenuTargetMap['sessionHeader']): readonly MenuAction[] => {
const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const applications = (controller.apps.getSnapshot() ?? []).flatMap((id) => {
const key = APP_LABEL_KEY[id]
return key === undefined ? [] : [{ id, name: t(key) }]
})
if (!directory || applications.length === 0) return []
const disabled = controller.operation.getSnapshot().phase === 'busy'
return [{
id: 'openWith', label: t('menu.openIn'), icon: { key: 'open-in-app.application' }, disabled,
children: applications.map(({ id, name }) => ({
id: `app:${id}`, label: name, disabled,
icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` },
run: async () => {
const outcome = await controller.launch(id, directory)
if (outcome.kind === 'opened') controller.choose(id)
report(outcome)
},
})),
}]
}
for (const target of ['session', 'sessionHeader'] as const) {
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.openWith', target, order: 500,
observe: () => [controller.apps, controller.operation, ctx.sessions.list, ctx.locale],
resolve: value => resolve(value).map(action => target === 'sessionHeader' ? { ...action, separatorBefore: true } : action),
}), `open-in-app: ${target} directory menu`)
}
}
@@ -1,37 +0,0 @@
/** Native directory reveal contributed to the captured Session's structured menu. */
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { OpenInAppController } from './controller.ts'
import type { OpenInAppToastSource } from './open-toast.tsx'
/**
* Share directory availability and launch feedback with the existing application controls.
* @param ctx - owning plugin context.
* @param controller - directory application catalog and launcher.
* @param t - localized action and failure labels.
* @param report - native operation feedback shared with the application controls.
*/
export function registerSessionReveal(
ctx: Context,
controller: OpenInAppController,
t: PropsLocale<'open-in-app'>['t'],
report: OpenInAppToastSource['report'],
): void {
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.reveal', target: 'session', order: 500,
observe: () => [controller.apps, ctx.sessions.list, ctx.locale],
resolve: ({ sessionId }) => {
const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const applications = controller.apps.getSnapshot()
const manager = applications?.find(id => ['finder', 'explorer', 'filemanager'].includes(id))
return [{
id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: { key: 'open-in-app.reveal' },
disabled: !directory || manager === undefined, failureLabel: t('path.revealError'),
...(directory && manager !== undefined ? { run: async () => {
const outcome = await controller.launch(manager, directory)
report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome)
} } : {}),
}]
},
}), 'open-in-app: Session directory menu')
}
@@ -10,7 +10,6 @@ import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
* proving removal (HMR safety) and the injected controller faces.
*/
import type { PropsRuntime, SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -118,6 +117,11 @@ describe('open-in-app browser half', () => {
it('renders captured application images and the default glyph without an image URL', () => {
const view = render(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/captured.png' } }))
expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/captured.png')
fireEvent.error(view.container.querySelector('img')!)
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/new.png' } }))
expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/new.png')
view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application' } }))
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
@@ -141,14 +145,14 @@ describe('open-in-app browser half', () => {
'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application',
])
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) })
menu.select('open-in-app.reveal/reveal')
await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) })
menu.select(menu.source.getSnapshot()[0]!.children![0]!.id)
menu.dispose()
const face: Partial<OpenInAppActionInjected> = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!()
const face: Partial<OpenInAppActionInjected> = ctx.slots.entries('sidebar.right.tab.files.actions')[0]!.inject!()
await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') })
expect(toast.hooks.toast.getSnapshot()).toEqual(success
? mobile ? { kind: 'opened', seq: 1 } : null
: { kind: 'failed', failure: 'revealError', seq: 1 })
: { kind: 'failed', failure: 'openError', seq: 1 })
expect(notify).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({
body: JSON.stringify({ app: 'finder', path: '/captured' }),
@@ -196,7 +200,7 @@ describe('open-in-app browser half', () => {
const select = (cwd: string) => { list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } },
} }) }
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const face = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) })
select('/first')
@@ -281,7 +285,7 @@ describe('open-in-app browser half', () => {
list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Main', cwd: '/workspace', running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } },
} })
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const face = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder']) })
const command = commands.get('workspace.openLocal')!
@@ -343,30 +347,13 @@ describe('open-in-app browser half', () => {
expect(ctx.slots.entries('shell.overlay')).toHaveLength(0)
})
it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => {
it('keeps the file-tree control without an independent Session header opener and removes it on teardown', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
const { ctx, fiber, list } = await bench()
try {
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const files = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
expect(files?.component).toBe(OpenInAppAction)
expect(files?.inject).toBe(entry?.inject)
expect(entry?.options).toMatchObject({ id: 'open-in-app' })
const header = entry?.component as SlotComponent<Pick<
PropsRuntime<'conversation.session.header.utilities'>, 'sessionId' | 'useSessions'
>>
const id = 'main' as SessionId
const props = { sessionId: id, useSessions: <T>(select: (state: SessionListState) => T) => select(list.getSnapshot()) }
expect(header(props)).toBeNull()
for (const cwd of ['', '/first', '/second']) {
list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: {} },
} })
if (cwd === '') expect(header(props)).toBeNull()
else expect(header(props)).toMatchObject({ type: OpenInAppAction, props: { absolutePath: cwd } })
}
} finally { await fiber.dispose() }
expect(headerEntryIds(ctx)).not.toContain('open-in-app')
const { ctx, fiber } = await bench()
expect(headerEntryIds(ctx)).toEqual([])
expect(ctx.slots.entries('sidebar.right.tab.files.actions')[0]?.component).toBe(OpenInAppAction)
await fiber.dispose()
expect(headerEntryIds(ctx)).toEqual([])
expect(ctx.slots.entries('sidebar.right.tab.files.actions')).toHaveLength(0)
})
@@ -381,7 +368,7 @@ describe('open-in-app browser half', () => {
})
vi.stubGlobal('fetch', fetcher)
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const injected: Partial<OpenInAppActionInjected> | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.iconUrl === undefined
|| injected.choose === undefined || injected.launch === undefined || injected.report === undefined) {
@@ -417,7 +404,7 @@ describe('open-in-app browser half', () => {
return new Response('', { status: 502 })
}))
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const injected: Partial<OpenInAppActionInjected> | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action')
const { hooks } = injected
@@ -0,0 +1,124 @@
import { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { OpenInAppController } from '../src/client/controller.ts'
import { registerSessionOpenWith } from '../src/client/session-open-with.ts'
import { en, zh, NS } from '../src/client/locales.ts'
function bench(apps: readonly string[] | null = ['finder', 'cursor'], cwd: string | undefined = '/captured') {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
locale.register(NS, { en, zh }); locale.setLocale('en')
const failed = vi.fn(), report = vi.fn()
const registry = new MenuRegistry(failed)
onTestFinished(() => { registry.dispose() })
ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never)
const sessionId = SessionId('captured'), mainId = SessionId('main')
const row = { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} }
const list = createSnapshotStore<SessionListState>({ ids: [sessionId, mainId], byId: {
[sessionId]: row, [mainId]: { ...row, id: mainId, cwd: '/main', retainedBy: { mainView: 1 } },
}, phase: 'ready', projectionsBySession: {} })
ctx.provide('sessions', { list } as never)
const gate = Promise.withResolvers<Response>()
const fetcher = vi.fn(() => gate.promise)
const controller = new OpenInAppController(fetcher)
controller.choice.set(''); controller.apps.set(apps)
const choose = vi.spyOn(controller, 'choose')
const fiber = ctx.plugin((scope) => { registerSessionOpenWith(scope, controller, locale.bind(NS), report) })
const resolve = (kind: 'session' | 'sessionHeader' = 'session') => registry.resolve(kind === 'session'
? { kind, value: { sessionId, displayTitle: 'Captured' } }
: { kind, value: { sessionId } })
return { ctx, registry, locale, controller, list, row, sessionId, fetcher, gate, choose, report, failed, fiber, resolve }
}
it.each([null, [], ['unlabelled']])('omits unavailable catalog %j from both menu targets', async (apps) => {
const b = bench(apps)
await b.fiber.await()
for (const kind of ['session', 'sessionHeader'] as const) expect(b.resolve(kind).source.getSnapshot()).toEqual([])
})
it('omits unknown directories and follows metadata, locale and applications without labeling preferences', async () => {
const b = bench()
await b.fiber.await()
b.list.set({ ...b.list.getSnapshot(), byId: {} })
const menu = b.resolve(), header = b.resolve('sessionHeader')
expect(menu.source.getSnapshot()).toEqual([])
b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: b.row } })
expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false })
expect(menu.source.getSnapshot()[0]?.separatorBefore).toBeUndefined()
expect(header.source.getSnapshot()[0]?.separatorBefore).toBe(true)
expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor'])
b.controller.choose('cursor')
expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor'])
b.locale.setLocale('zh')
expect(menu.source.getSnapshot()[0]?.label).toBe('在应用中打开')
expect(header.source.getSnapshot()[0]?.children?.[1]?.label).toBe('Cursor')
b.controller.apps.set(['cursor', 'unknown', 'finder'])
expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Cursor', '访达'])
b.controller.apps.set(['finder'])
expect(menu.source.getSnapshot()[0]?.children).toHaveLength(1)
expect(menu.source.getSnapshot()[0]?.children?.[0]).toMatchObject({
label: '访达', icon: { key: 'open-in-app.application', src: 'open-in-app/icon/finder' },
})
b.controller.apps.set([])
expect(menu.source.getSnapshot()).toEqual([])
expect(header.source.getSnapshot()).toEqual([])
})
it.each(['session', 'sessionHeader'] as const)('launches the captured %s directory, disables all openers, and remembers only after success', async (kind) => {
const b = bench()
await b.fiber.await()
const menu = b.resolve(kind), observing = b.resolve(kind === 'session' ? 'sessionHeader' : 'session')
const action = menu.source.getSnapshot()[0]!.children![1]!
menu.select(action.id)
expect(b.fetcher).toHaveBeenCalledExactlyOnceWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'cursor', path: '/captured' }) }))
expect(b.choose).not.toHaveBeenCalled()
expect(observing.source.getSnapshot()[0]).toMatchObject({ disabled: true })
expect(observing.source.getSnapshot()[0]?.children?.every(row => row.disabled)).toBe(true)
observing.select(observing.source.getSnapshot()[0]!.children![0]!.id)
expect(b.fetcher).toHaveBeenCalledOnce()
b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: { ...b.row, cwd: '/changed' } } })
menu.dispose()
b.gate.resolve(new Response())
await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith({ kind: 'opened' }) })
expect(b.choose).toHaveBeenCalledExactlyOnceWith('cursor')
expect(b.controller.choice.getSnapshot()).toBe('cursor')
expect(observing.source.getSnapshot()[0]?.disabled).toBe(false)
expect(b.failed).not.toHaveBeenCalled()
})
it.each([{ kind: 'failed', failure: 'openError' }, { kind: 'skipped' }] as const)('does not remember a %j launch or duplicate its feedback', async (outcome) => {
const b = bench()
await b.fiber.await()
vi.spyOn(b.controller, 'launch').mockResolvedValue(outcome)
const menu = b.resolve()
menu.select(menu.source.getSnapshot()[0]!.children![1]!.id)
await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith(outcome) })
expect(b.choose).not.toHaveBeenCalled()
expect(b.controller.choice.getSnapshot()).toBe('')
expect(b.failed).not.toHaveBeenCalled()
})
it('withdraws both registrations and their observers on unload and reinstalls without duplicates', async () => {
const b = bench()
await b.fiber.await()
const menu = b.resolve(), header = b.resolve('sessionHeader')
const id = menu.source.getSnapshot()[0]!.children![0]!.id
await b.fiber.dispose()
expect(menu.source.getSnapshot()).toEqual([])
expect(header.source.getSnapshot()).toEqual([])
menu.select(id)
expect(b.fetcher).not.toHaveBeenCalled()
b.controller.apps.set(['cursor'])
expect(b.resolve().source.getSnapshot()).toEqual([])
const replacement = b.ctx.plugin((scope) => { registerSessionOpenWith(scope, b.controller, b.locale.bind(NS), b.report) })
await replacement.await()
expect(b.resolve().source.getSnapshot()).toHaveLength(1)
expect(b.resolve('sessionHeader').source.getSnapshot()).toHaveLength(1)
})
@@ -1,48 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { OpenInAppController } from '../src/client/controller.ts'
import { registerSessionReveal } from '../src/client/session-reveal.tsx'
import { en, zh, NS } from '../src/client/locales.ts'
it.each([
[['finder'], '/workspace', 'Show in Finder', false],
[['explorer'], '/workspace', 'Show in folder', false],
[['filemanager'], '/workspace', 'Show in folder', false],
[['vscode'], '/workspace', 'Show in folder', true],
[null, '/workspace', 'Show in folder', true],
[['finder'], undefined, 'Show in Finder', true],
] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
locale.register(NS, { en, zh }); locale.setLocale('en')
const registry = new MenuRegistry(vi.fn())
onTestFinished(() => { registry.dispose() })
ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never)
const sessionId = SessionId('captured')
const list = createSnapshotStore<SessionListState>({ ids: [sessionId], byId: {
[sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} },
}, phase: 'ready', projectionsBySession: {} })
ctx.provide('sessions', { list } as never)
const controller = new OpenInAppController()
controller.apps.set(apps)
const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' })
const report = vi.fn()
registerSessionReveal(ctx, controller, locale.bind(NS), report)
const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } })
expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled })
menu.select('open-in-app.reveal/reveal')
if (disabled) {
expect(launch).not.toHaveBeenCalled()
expect(report).not.toHaveBeenCalled()
} else {
expect(launch).toHaveBeenCalledWith(apps?.[0], cwd)
await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) })
}
})
@@ -18,11 +18,11 @@
en: ead6be55c7c9d7cf
zh: 125ad36db7328551
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 9d998ea39a7415b2
zh: 79b6eeec7a6262bb
en: 77383e8771784a60
zh: 25c4c59b1cbc7f8b
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: a5580871b06143b6
zh: 152465da61068c5d
en: 2fc65b908307b52e
zh: e5c1181ecd2da6e5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: e2649937f077d4e0
zh: 6ad604cb5adfc16b
@@ -30,8 +30,8 @@
en: 3fd7072b662eed0a
zh: 88c6c299c0a6f8cd
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
en: a53935420b1a6c0c
zh: 5248f2088059e551
en: 88448dce667337fe
zh: 5d9b6035191a0b08
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: a39e67d79e171221
zh: 593e260c073336f9
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -136,7 +136,8 @@
right: 0;
}
/* Tall lists scroll within the layout viewport, independently of pinch zoom. */
/* Tall lists scroll within the layout viewport, independently of pinch zoom.
* Child cards use their own body portal, so a scrolling parent never clips them. */
.scrollable {
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
max-height: calc(100dvh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
@@ -362,24 +363,76 @@
display: block;
}
/* The submenu aligns with the parent's bottom edge. Its hit bridge covers
* the menu inset and the 6px gap so pointer movement keeps it open. */
.submenu {
/* The parent card's anchor for an open child card: an unscrolled box matching the
* card. WebKit does not re-resolve anchors on the anchor-positioned card itself
* after it settles, and resolves rows inside the scrolling list without their
* scroll offset; a descendant box avoids both. */
.submenuAnchor {
position: absolute;
top: auto;
bottom: -4px;
left: calc(100% + 10px);
z-index: 101;
min-width: 163px;
inset: 0;
pointer-events: none;
}
.submenu::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -10px;
width: 10px;
/* Child cards stay mounted while switching between side and single-panel layouts.
* Both layouts use the parent card's anchor box as the default anchor, which
* browsers track through movement and zoom. JavaScript selects the layout and
* supplies the active row's offset within the parent card and the child card's
* natural height. */
.submenu {
--dsh-menu-window-top: max(12px, var(--dsh-frame-overlay-top, 12px));
position: fixed;
position-anchor: var(--dsh-submenu-parent);
position-visibility: always;
margin: 0;
z-index: 1101;
width: max-content;
min-width: min(163px, calc(100vw - 24px));
max-width: min(360px, calc(100vw - 24px));
max-height: calc(100dvh - 12px - var(--dsh-menu-window-top));
}
.submenu[data-contextual] { --dsh-menu-window-top: 12px; }
/* Side card: beside the parent card with a 4px gap, top edge on the active row,
* moved up when its height does not fit below the row. The only placement
* fallback is the left side. */
.submenu[data-submenu-layout='side'] {
top: max(var(--dsh-menu-window-top), min(
calc(anchor(top) + var(--dsh-submenu-row-offset, 0px)),
calc(100dvh - 12px - var(--dsh-submenu-height, 0px))
));
left: calc(anchor(right) + 4px);
right: 12px;
justify-self: start;
height: max-content;
position-try-fallbacks: --dsh-submenu-left;
}
@position-try --dsh-submenu-left {
left: 12px;
right: calc(anchor(left) + 4px);
justify-self: end;
}
/* Single-panel page: the child takes the parent card's place and at least its width. */
.submenu[data-submenu-layout='page'] {
top: max(var(--dsh-menu-window-top), anchor(top));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
align-self: start;
justify-self: start;
height: fit-content;
min-width: min(anchor-size(width), calc(100vw - 24px));
max-width: calc(100vw - 24px);
}
/* Retain the parent's geometry and rows for placement and focus restoration. */
.parentPage {
visibility: hidden;
pointer-events: none;
}
.submenu > .item {
flex: none;
}
.list[data-menu-phase='dismiss'],
+76 -160
View File
@@ -1,15 +1,15 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode, RefObject, SyntheticEvent } from 'react'
import type { ReactNode, RefObject } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCheckOutlineRegular } from './icons/index.tsx'
import { useMenuSubmenu, menuHoverAllowed } from './useMenuSubmenu.ts'
import { MenuRow, menuIconColumns, renderMenuDecoration } from './MenuRow.tsx'
import { SubmenuPanel } from './SubmenuPanel.tsx'
import { usePointerGrace } from './pointer-grace.ts'
import { isBehindModal } from './useModalLayer.ts'
import { observeComposition } from './keyboard-composition.ts'
import { focusWithoutRing } from './focus.ts'
import { ShortcutKeys } from './ShortcutKeys.tsx'
import { MenuSurface } from './MenuSurface.tsx'
import { Tooltip } from './Tooltip.tsx'
import css from './Menu.module.css'
/** Selectable row (optionally with a nested submenu). */
@@ -23,7 +23,7 @@ export interface MenuItem {
icon?: ReactNode
/** Destructive row: error-colored text/icon and danger hover fill. */
danger?: boolean
/** Nested card opened to the right on hover/focus. */
/** Child choices opened by hover, focus, click, or ArrowRight; placement follows available space. */
submenu?: readonly MenuEntry[]
}
@@ -91,64 +91,8 @@ export interface MenuItemButtonProps {
* @param props.onSelect - row activation callback.
* @returns one menu-item row.
*/
export function MenuItemButton({
children, shortcut, icon, current, disabled = false, disabledReason, danger = false, separatorBefore = false, onSelect,
}: MenuItemButtonProps) {
const reasonId = useId()
// Native disabled buttons do not deliver the hover/focus events Tooltip needs,
// so an explained row is only marked unavailable. The Tooltip wraps every row,
// so a reason that comes or goes never remounts the focused row.
const explained = disabled && disabledReason !== undefined
return (
<div className={css.itemWrap}>
{separatorBefore && <div className={css.separator} role="separator" />}
<Tooltip label={disabledReason ?? ''} disabled={!explained} side="bottom" delayMs={500} maxWidth={300} portal>
<button
type="button"
role="menuitem"
className={clsx(css.item, danger && css.danger)}
disabled={disabled && !explained}
aria-disabled={explained || undefined}
aria-describedby={explained ? reasonId : undefined}
aria-keyshortcuts={shortcut?.aria}
aria-current={current === true ? 'page' : undefined}
onClick={explained ? undefined : onSelect}
>
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
<span className={css.itemLabel}>{children}</span>
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
</Tooltip>
{explained && <span id={reasonId} hidden>{disabledReason}</span>}
</div>
)
}
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
return 'type' in entry && entry.type === 'separator'
}
function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label'
}
/** Mark icon columns within each separator-delimited data group. */
function iconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet<MenuEntry> {
const aligned = new Set<MenuEntry>()
if (!contextual) return aligned
let group: MenuEntry[] = []
const finish = (): void => {
if (group.some(entry => !isLabel(entry) && !isSeparator(entry) && entry.icon !== undefined)) {
for (const entry of group) aligned.add(entry)
}
group = []
}
for (const entry of entries) {
if (isSeparator(entry)) finish()
else group.push(entry)
}
finish()
return aligned
export function MenuItemButton({ onSelect, ...props }: MenuItemButtonProps) {
return <MenuRow {...props} decorativeIcon={false} onClick={onSelect} />
}
/**
@@ -157,14 +101,21 @@ function iconColumns(entries: readonly MenuEntry[], contextual: boolean): Readon
* list instead — and Escape or Shift+Tab close it and return focus to the
* anchor's first button, and selecting a row does the same — the rows unmount
* with the list. Only a keyboard on the trigger or inside the list is
* intercepted; Tab presses elsewhere on the page stay the browser's.
* intercepted; Tab presses elsewhere on the page stay the browser's. Escape
* first returns from a visible child, while ArrowRight enters and ArrowLeft returns.
* When neither side fits, click or ArrowRight opens a single-panel child page;
* focus alone retains the parent page.
* @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment.
* @param props.exiting - retain an inert popup during dismissal or replacement fade.
* @param props.returnFocus - original control captured before asynchronous menu preparation.
* @param props.isExternalTrigger - recognizes external controls whose pointer presses should not dismiss;
* omitted, every press outside the anchor and menu panels dismisses.
* @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way.
* @param props.open - whether the list is showing (owner-controlled).
* @param props.anchor - the trigger element (rendered in place).
* @param props.items - selectable data rows and optional separators (default none; with no `children` either, the list is empty).
* @param props.submenuBackLabel - localized single-panel return label; omitted uses the caller's
* parent-row label beside a back arrow, without selecting that row.
* @param props.selectedId - row shown as selected.
* @param props.selectedIds - rows shown as selected when a menu contains independent option groups.
* @param props.onSelect - data-row activation callback (not called for disabled rows or submenu parents that only open children).
@@ -202,10 +153,12 @@ function iconColumns(entries: readonly MenuEntry[], contextual: boolean): Readon
* document.body outside the owner's DOM subtree.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: {
export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', anchorRef, anchorPoint, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus, isExternalTrigger }: {
open: boolean
/** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */
contextual?: boolean
/** Recognizes external triggers exempt from outside dismissal; omitted exempts no external nodes. */
isExternalTrigger?: (node: Node) => boolean
/** Resolve the original control for focus restoration and inert/hidden dismissal. */
returnFocus?: (() => HTMLElement | null) | undefined
/** Retain a dismissed context popup during its short fade. */
@@ -213,6 +166,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
autoFocus?: boolean
anchor: ReactNode
items?: readonly MenuEntry[]
/** Localized return action; omitted uses the caller's parent label with a back arrow. */
submenuBackLabel?: string | undefined
children?: ReactNode
footer?: readonly MenuEntry[]
selectedId?: string | undefined
@@ -296,14 +251,17 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
queueMicrotask(() => {
if (openRef.current && exitingRef.current === undefined) return
const active = document.activeElement
if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation)
if (active === null || active === document.body || listRef.current?.contains(active) === true
|| submenu.contains(active)) refocusAnchor(navigation)
})
}
const exitingRef = useRef(exiting)
exitingRef.current = exiting
const openRef = useRef(open)
openRef.current = open
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
const submenu = useMenuSubmenu({ open: open && exiting === undefined, items, footer, listRef, refocusAnchor })
const submenuEvents = useRef<Pick<typeof submenu, 'contains' | 'activeList' | 'returnToParent'>>(submenu)
submenuEvents.current = submenu
const close = useRef(onClose)
close.current = onClose
const anchorName = `--dsh-menu-trigger-${menuId.replaceAll(':', '')}`
@@ -359,15 +317,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
useEffect(() => {
if (!open || !positioned || exiting !== undefined || !autoFocus
|| listRef.current?.contains(document.activeElement)) return
|| listRef.current?.contains(document.activeElement) || submenu.contains(document.activeElement)) return
const first = listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')
walkIndex.current = first === undefined || first === null ? null : 0
if (first != null) focusWithoutRing(first)
}, [open, autoFocus, items.length, footer?.length, exiting, positioned])
}, [open, autoFocus, items.length, footer?.length, positioned, exiting])
useEffect(() => {
if (!open || exiting !== undefined) {
setOpenSubmenuId(null)
walkIndex.current = null
return
}
@@ -376,7 +333,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
if (!(e.target instanceof Node) || (contextual && e.button === 2)) return
// The portaled list is outside the anchor subtree; check both.
if (rootRef.current?.contains(e.target) === true || anchorRef?.current?.contains(e.target) === true) return
if (listRef.current?.contains(e.target) === true) return
if (isExternalTrigger?.(e.target) === true) return
if (listRef.current?.contains(e.target) === true || submenuEvents.current.contains(e.target)) return
onClose()
}
const onKeyDown = (e: KeyboardEvent) => {
@@ -385,24 +343,35 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// Where the keyboard is, computed once: the menu owns it when it holds a
// row or sits on its anchor region.
const focused = document.activeElement
const insideList = listRef.current?.contains(focused) === true
const insideList = listRef.current?.contains(focused) === true || submenuEvents.current.contains(focused)
const anchored = focused === returnFocus?.() || anchorRef?.current?.contains(focused) === true
|| rootRef.current?.contains(focused) === true || insideList
if (e.key === 'Escape' && !e.shiftKey) {
e.preventDefault()
if (e.repeat) return
if (submenuEvents.current.returnToParent()) return
// Closing hands the keyboard back when the menu had it — and, as this
// primitive always did for autoFocus menus, when it held the keyboard
// and lost it again (a row that unmounted under it).
onClose()
if (anchored || autoFocus) refocusAnchor()
}
if (e.key === 'ArrowLeft' && anchored && submenuEvents.current.returnToParent()) {
e.preventDefault()
return
}
if (e.key === 'ArrowRight' && insideList && focused instanceof HTMLButtonElement
&& focused.getAttribute('aria-haspopup') === 'menu') {
e.preventDefault()
focused.click()
return
}
// Tab settles like Enter and Shift+Tab leaves like Escape, so a menu's
// keys mean what they mean in the composer. Only a keyboard already on
// the trigger or inside the list is intercepted: Tab elsewhere on the
// page keeps the browser's traversal even while a menu is open.
if (e.key === 'Tab') {
const list = listRef.current
const list = submenuEvents.current.activeList(focused)
if (list === null || !anchored) return
if (e.shiftKey) {
e.preventDefault()
@@ -444,7 +413,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// otherwise re-enter at the near end on every press and the walk would
// alternate between two rows.
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
const list = listRef.current
const list = submenuEvents.current.activeList(focused)
if (list === null || !anchored) return
const buttons = Array.from(list.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
if (buttons.length === 0) return
@@ -478,7 +447,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
document.removeEventListener('keydown', onEscape, true)
window.removeEventListener('blur', onWindowBlur)
}
}, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus])
}, [open, onClose, autoFocus, contextual, exiting, anchorRef, returnFocus, isExternalTrigger])
useLayoutEffect(() => {
if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor()
@@ -492,99 +461,41 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
if (!open) cancelClose()
}, [open, cancelClose])
// The submenu card is absolutely positioned outside the list box; the
// scroll clip would crop it, so only submenu-free menus get the height cap.
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
const itemColumns = iconColumns(items, contextual)
const footerColumns = iconColumns(footer ?? [], contextual)
const itemColumns = menuIconColumns(items, contextual)
const footerColumns = menuIconColumns(footer ?? [], contextual)
const renderEntry = (entry: MenuEntry, columns: ReadonlySet<MenuEntry>) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
if ('type' in entry) return renderMenuDecoration(entry)
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
const subColumns = iconColumns(entry.submenu ?? [], contextual)
const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-keyshortcuts={entry.shortcut?.aria}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect?.(entry.id)
}}
>
{(entry.icon !== undefined || columns.has(entry)) && <span aria-hidden="true" className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{entry.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={entry.shortcut.keys} className={css.shortcutKeys} /></span>}
{/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
{selected && selection === 'check' && <IconCheckOutlineRegular className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<MenuSurface compact={compact} className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => isSeparator(sub)
? <div key={sub.id} className={css.separator} role="separator" />
: isLabel(sub) ? <div key={sub.id} className={css.label}>{sub.text}</div> : (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
aria-keyshortcuts={sub.shortcut?.aria}
onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }}
>
{(sub.icon !== undefined || subColumns.has(sub)) && <span aria-hidden="true" className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
{sub.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={sub.shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
))}
</MenuSurface>
)}
</div>
)
}
// Submenu exclusivity is decided from the list's own bubble, by DOM:
// reaching a top-level row that is not a submenu parent — by pointer or by
// focus, data row or component row — closes the open card. A parent opens
// its card in its own handlers; rows inside the card are not top-level rows.
const collapseSubmenuFrom = (e: SyntheticEvent<HTMLDivElement>): void => {
const row = e.target instanceof Element ? e.target.closest('button[role="menuitem"]') : null
if (row === null || row.getAttribute('aria-haspopup') === 'menu') return
if (row.closest('[role="menu"]') !== e.currentTarget) return
setOpenSubmenuId(null)
return <MenuRow key={entry.id} icon={entry.icon} reserveIcon={columns.has(entry)}
disabled={entry.disabled} danger={entry.danger} shortcut={entry.shortcut}
selected={entry.id === selectedId || selectedIds?.includes(entry.id) === true} selection={selection}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? submenu.entry?.id === entry.id && !submenu.hidden : undefined}
wrapperProps={hasSub ? {
onPointerEnter: (event) => {
if (!menuHoverAllowed(event) || entry.disabled) return
const trigger = event.currentTarget.querySelector<HTMLButtonElement>('button')
if (trigger !== null) submenu.openChild(entry.id, trigger)
},
onPointerLeave: (event) => { if (menuHoverAllowed(event)) submenu.leave() },
} : undefined}
onFocus={hasSub ? (event) => { submenu.focusParent(entry.id, event.currentTarget) } : undefined}
onClick={(event) => {
if (hasSub) submenu.openChild(entry.id, event.currentTarget, true)
else onSelect?.(entry.id)
}}>{entry.label}</MenuRow>
}
const list = open && positioned && (
<MenuSurface compact={compact} id={contextual ? menuId : undefined}
ref={listRef}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, css.scrollable, submenu.page && css.parentPage, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? { positionAnchor: anchorName } : undefined}
data-anchor-side={portal ? side : undefined}
data-anchor-align={portal ? align : undefined}
data-match-anchor-width={portal && minWidthFromAnchor ? '' : undefined}
role={exiting === undefined ? 'menu' : undefined}
aria-hidden={exiting === undefined ? undefined : true}
role={exiting === undefined && !submenu.page ? 'menu' : undefined}
aria-hidden={exiting === undefined && !submenu.page ? undefined : true}
data-context-menu={contextual ? '' : undefined}
phase={contextual ? exiting ?? 'enter' : undefined}
// React portals bubble synthetic events through the REACT tree: without
@@ -601,9 +512,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
onContextMenu={contextual ? (event) => {
event.preventDefault(); event.stopPropagation(); onClose()
} : undefined}
onMouseOver={collapseSubmenuFrom}
onFocus={collapseSubmenuFrom}
onPointerOver={(event) => { if (menuHoverAllowed(event)) submenu.collapseFrom(event) }}
onFocus={submenu.collapseFrom}
>
{/* Child cards anchor to this unscrolled box, which matches the card itself. */}
{submenu.entry !== undefined && <span aria-hidden="true" className={css.submenuAnchor} style={{ anchorName: submenu.parentAnchor }} />}
<div className={css.viewport} role="presentation">
{items.map(entry => renderEntry(entry, itemColumns))}
{children}
@@ -628,10 +541,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
position: 'fixed', left: point.x, top: point.y, width: 0, height: 0,
}) }}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
onPointerLeave={closeOnPointerLeave ? (event) => { if (open && menuHoverAllowed(event)) armClose() } : undefined}
>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
{open && exiting === undefined && submenu.entry !== undefined && <SubmenuPanel key={submenu.entry.id}
entry={submenu.entry} submenu={submenu} compact={compact} dense={dense} contextual={contextual}
backLabel={submenuBackLabel ?? submenu.entry.label} onSelect={onSelect} onClose={onClose} afterSelection={refocusAfterSelection} />}
</span>
)
}
@@ -0,0 +1,89 @@
/** Package-internal row presentation shared by component, data, and child menu entries. */
import { useId } from 'react'
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
import clsx from 'clsx'
import type { MenuEntry, MenuItemButtonProps, MenuLabel, MenuSeparator } from './Menu.tsx'
import { IconCheckOutlineRegular, IconChevronRightOutlineRegular } from './icons/index.tsx'
import { ShortcutKeys } from './ShortcutKeys.tsx'
import { Tooltip } from './Tooltip.tsx'
import css from './Menu.module.css'
/** Optional props that also accept `undefined`, so data rows can forward absent `MenuItem` fields. */
type OptionalProps<T, K extends keyof T> = { [P in K]?: T[P] | undefined }
/** Package-internal row props: the `MenuItemButton` row fields plus data- and child-row presentation. */
type MenuRowProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>
& Pick<MenuItemButtonProps, 'children'>
& OptionalProps<MenuItemButtonProps, 'icon' | 'shortcut' | 'current' | 'disabledReason' | 'danger' | 'separatorBefore'>
& {
/** Whether this data row is the menu's current selection. */
selected?: boolean
/** Selection marker: a trailing check, or the row fill. */
selection?: 'check' | 'fill'
/** Keep an empty icon column so grouped rows stay aligned. */
reserveIcon?: boolean
/** Hide the icon from assistive technology; component rows keep it exposed. */
decorativeIcon?: boolean
/** Pointer handlers on the row wrapper, used by submenu parents. */
wrapperProps?: HTMLAttributes<HTMLDivElement> | undefined
}
/** Render a focus-stable row with optional selection, shortcut, and disabled explanation.
* @param props - row content, native button events, and optional pointer wrapper events.
* @returns one menu item; explained disabled rows remain focusable but never activate.
*/
export function MenuRow({ children, icon, shortcut, current, disabled = false, disabledReason, danger,
separatorBefore, selected, selection = 'check', reserveIcon, decorativeIcon = true, wrapperProps, ...button }: MenuRowProps) {
const reasonId = useId()
const explained = disabled && disabledReason !== undefined
// Tooltip remains mounted when the explanation changes, preserving button focus.
return <div {...wrapperProps} className={css.itemWrap}>
{separatorBefore && <div className={css.separator} role="separator" />}
<Tooltip label={disabledReason ?? ''} disabled={!explained} side="bottom" delayMs={500} maxWidth={300} portal>
<button {...button} type="button" role="menuitem"
className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), danger && css.danger)}
disabled={disabled && !explained} aria-disabled={explained || undefined}
aria-describedby={explained ? reasonId : undefined} aria-keyshortcuts={shortcut?.aria}
aria-current={current === true ? 'page' : undefined} onClick={explained ? undefined : button.onClick}>
{(icon !== undefined || reserveIcon) && <span aria-hidden={decorativeIcon || undefined} className={css.itemIcon}>{icon}</span>}
<span className={css.itemLabel}>{children}</span>
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={shortcut.keys} className={css.shortcutKeys} /></span>}
{selected && selection === 'check' && <IconCheckOutlineRegular className={css.check} />}
{button['aria-haspopup'] === 'menu' && <IconChevronRightOutlineRegular aria-hidden="true" className={css.check} />}
</button>
</Tooltip>
{explained && <span id={reasonId} hidden>{disabledReason}</span>}
</div>
}
/** Mark icon columns within each separator-delimited data group.
* @param entries - entries in one menu area.
* @param contextual - whether to align grouped context-menu icons.
* @returns entries that reserve a leading icon column.
*/
export function menuIconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet<MenuEntry> {
const aligned = new Set<MenuEntry>()
if (!contextual) return aligned
let group: MenuEntry[] = []
const finish = (): void => {
if (group.some(entry => !('type' in entry) && entry.icon !== undefined)) {
for (const entry of group) aligned.add(entry)
}
group = []
}
for (const entry of entries) {
if ('type' in entry && entry.type === 'separator') finish()
else group.push(entry)
}
finish()
return aligned
}
/** Render a separator or group heading outside the keyboard walk.
* @param entry - non-interactive menu entry.
* @returns the corresponding element.
*/
export function renderMenuDecoration(entry: MenuSeparator | MenuLabel): ReactNode {
return entry.type === 'separator' ? <div key={entry.id} className={css.separator} role="separator" />
: <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
@@ -0,0 +1,51 @@
/** Portaled child menu presentation; interaction state belongs to useMenuSubmenu. */
import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { menuHoverAllowed, type useMenuSubmenu } from './useMenuSubmenu.ts'
import { menuIconColumns, MenuRow, renderMenuDecoration } from './MenuRow.tsx'
import { IconChevronLeftOutlineRegular } from './icons/index.tsx'
import { MenuSurface } from './MenuSurface.tsx'
import css from './Menu.module.css'
/** Name the parent card anchor, and hide the child card until its layout is known. */
function anchors(submenu: ReturnType<typeof useMenuSubmenu>, hidden: boolean): CSSProperties & Record<`--dsh-submenu-${string}`, string> {
return { '--dsh-submenu-parent': submenu.parentAnchor, ...hidden ? { visibility: 'hidden' } : {} }
}
/** Render one child list with a non-selecting back row in single-page placement.
* @param props - active entry, submenu operations, and shared row presentation options.
* @returns a body portal that preserves child DOM identity across placement changes.
*/
export function SubmenuPanel({ entry, submenu, compact, dense, contextual, backLabel, onSelect, onClose, afterSelection }: {
entry: NonNullable<ReturnType<typeof useMenuSubmenu>['entry']>
submenu: ReturnType<typeof useMenuSubmenu>
compact: boolean
dense: boolean
contextual: boolean
backLabel: ReactNode
onSelect: ((id: string) => void) | undefined
onClose: () => void
afterSelection: () => void
}) {
const columns = menuIconColumns(entry.submenu, contextual)
return createPortal(<MenuSurface ref={submenu.panelRef} compact={compact}
className={clsx(css.submenu, css.scrollable, dense && css.denseList, compact && css.compactList)}
style={anchors(submenu, submenu.hidden)}
data-submenu-layout={submenu.measured ? submenu.page ? 'page' : 'side' : undefined}
data-contextual={contextual ? '' : undefined} role="menu" onPointerEnter={submenu.cancelClose}
onPointerLeave={(event) => { if (menuHoverAllowed(event)) submenu.leave() }}
onContextMenu={contextual ? (event) => { event.preventDefault(); event.stopPropagation(); onClose() } : undefined}
onClick={(event) => { event.stopPropagation(); afterSelection() }}>
{submenu.page && <>
<MenuRow data-menu-back="" icon={<IconChevronLeftOutlineRegular className={css.check} />}
onClick={submenu.returnToParent}>{backLabel}</MenuRow>
<div className={css.separator} role="separator" />
</>}
<div className={css.viewport} role="presentation">
{entry.submenu.map(sub => 'type' in sub ? renderMenuDecoration(sub)
: <MenuRow key={sub.id} icon={sub.icon} reserveIcon={columns.has(sub)} disabled={sub.disabled}
danger={sub.danger} shortcut={sub.shortcut} onClick={() => { onSelect?.(sub.id) }}>{sub.label}</MenuRow>)}
</div>
</MenuSurface>, document.body)
}
@@ -0,0 +1,20 @@
/** Choose between a side child card and a single-panel child page from layout-viewport space. */
/** Gap between the parent card and a side child card, matching `.submenu` in Menu.module.css. */
export const SUBMENU_GAP = 4
/** Layout-viewport inset shared with every portalled Menu card. */
export const SUBMENU_MARGIN = 12
/**
* Report whether neither side of the parent card can hold the child card.
* CSS anchors place the side card and flip it; this decision only selects the page layout.
* @param parent - Parent card rectangle in client coordinates.
* @param width - Natural child card width.
* @param viewportWidth - Layout viewport width.
* @returns true when the child must replace the parent page.
*/
export function submenuNeedsPage(parent: DOMRect, width: number, viewportWidth: number): boolean {
const right = parent.right + SUBMENU_GAP + width <= viewportWidth - SUBMENU_MARGIN
const left = parent.left - SUBMENU_GAP - width >= SUBMENU_MARGIN
return !right && !left
}
@@ -0,0 +1,182 @@
/** Owns one menu's child layout mode, pointer grace, and focus transitions. */
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { RefObject, SyntheticEvent } from 'react'
import type { MenuEntry, MenuItem } from './Menu.tsx'
import { submenuNeedsPage } from './menu-submenu-position.ts'
import { usePointerGrace } from './pointer-grace.ts'
/** Whether a pointer event can request hover interactions.
* @param event - current pointer, including mixed-input touch events.
* @returns true for non-touch input with an available hover device.
*/
export function menuHoverAllowed(event: { pointerType: string }): boolean {
return event.pointerType !== 'touch' && window.matchMedia('(any-hover: hover)').matches
}
interface MenuSubmenu {
entry: (MenuItem & { submenu: readonly MenuEntry[] }) | undefined
panelRef: RefObject<HTMLDivElement>
/** CSS anchor name of the root card's anchor box; the child card is placed against it. */
parentAnchor: string
/** Whether the layout mode for the current child is known; the card stays hidden until it is. */
measured: boolean
page: boolean
hidden: boolean
contains: (node: Node | null) => boolean
activeList: (focused: Node | null) => HTMLDivElement | null
/** Returns false when no visible child can handle the navigation. */
returnToParent: () => boolean
openChild: (id: string, target: HTMLButtonElement, enter?: boolean) => void
focusParent: (id: string, target: HTMLButtonElement) => void
leave: () => void
cancelClose: () => void
collapseFrom: (event: SyntheticEvent<HTMLDivElement>) => void
}
/** Manage a single child card without owning the root menu's document listeners.
* @param options - current entries, root lifetime, and fallback focus destination.
* @returns child rendering data and narrow interaction operations.
*/
export function useMenuSubmenu(options: {
open: boolean
items: readonly MenuEntry[]
footer: readonly MenuEntry[] | undefined
listRef: RefObject<HTMLDivElement>
refocusAnchor: () => void
}): MenuSubmenu {
const { open, items, footer, listRef, refocusAnchor } = options
const [expanded, setExpanded] = useState<{ id: string; trigger: HTMLButtonElement; activated: boolean } | null>(null)
const panelRef = useRef<HTMLDivElement>(null)
// A DOM target suppresses its focus-open handler until focus restoration completes.
const pendingFocus = useRef<'child' | HTMLButtonElement | null>(null)
const entry = [...items, ...(footer ?? [])].find((item): item is MenuItem & { submenu: readonly MenuEntry[] } =>
!('type' in item) && item.id === expanded?.id && item.disabled !== true
&& item.submenu !== undefined && item.submenu.length > 0)
const trigger = expanded?.trigger ?? null
const anchorId = useId().replaceAll(':', '')
const parentAnchor = `--dsh-submenu-parent-${anchorId}`
// Null until the active child has been measured against the layout viewport.
const [needsPage, setNeedsPage] = useState<boolean | null>(null)
const contains = (node: Node | null): boolean => panelRef.current?.contains(node) === true
const invalidated = expanded !== null && entry === undefined
// These reads precede React removing an invalidated panel or a page's return row.
const active = document.activeElement
const restoreInvalidatedFocus = open && invalidated && (contains(active) || trigger === active)
const backWasFocused = contains(active) && active?.hasAttribute('data-menu-back') === true
const measured = needsPage !== null
const page = entry !== undefined && expanded?.activated === true && needsPage === true
const hidden = !measured || (needsPage && !page)
// CSS anchors place the card against the parent card. JavaScript selects the layout and
// supplies two lengths: the card's natural height and the active row's offset from the
// parent card's top. Both are relative sizes, so page zoom and panning leave them valid.
const visible = open && entry !== undefined
useLayoutEffect(() => {
const panel = panelRef.current
const parent = listRef.current
if (!visible || panel === null || parent === null || trigger === null) { setNeedsPage(null); return }
const measureRow = (): void => {
panel.style.setProperty('--dsh-submenu-row-offset', `${trigger.getBoundingClientRect().top - parent.getBoundingClientRect().top}px`)
}
// The layout uses the side card's natural size, not the size a page layout gives it.
const measure = (): void => {
const layout = panel.getAttribute('data-submenu-layout')
panel.removeAttribute('data-submenu-layout')
const width = panel.offsetWidth
panel.style.setProperty('--dsh-submenu-height', `${panel.offsetHeight}px`)
if (layout !== null) panel.setAttribute('data-submenu-layout', layout)
measureRow()
const viewport = document.documentElement.clientWidth || window.innerWidth
setNeedsPage(submenuNeedsPage(parent.getBoundingClientRect(), width, viewport))
}
measure()
window.addEventListener('resize', measure)
parent.addEventListener('scroll', measureRow, true)
const observer = new ResizeObserver(measure)
for (const element of [parent, trigger, panel]) observer.observe(element)
return () => {
window.removeEventListener('resize', measure)
parent.removeEventListener('scroll', measureRow, true)
observer.disconnect()
}
}, [visible, entry, trigger, listRef])
const focusFirstChild = (): void => {
const panel = panelRef.current
const first = panel?.querySelector<HTMLButtonElement>('button[role="menuitem"]:not(:disabled):not([data-menu-back])')
?? panel?.querySelector<HTMLButtonElement>('button:not(:disabled)')
first?.focus()
}
const returnToParent = (): void => {
cancelClose()
pendingFocus.current = trigger
setExpanded(null)
}
const collapse = (): void => {
cancelClose()
if (contains(document.activeElement)) returnToParent()
else setExpanded(null)
}
const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { if (!page) collapse() })
const openChild = (id: string, target: HTMLButtonElement, enter = false): void => {
cancelClose()
if (trigger !== target && contains(document.activeElement)) {
pendingFocus.current = target
target.focus()
pendingFocus.current = null
}
pendingFocus.current = enter ? 'child' : null
setExpanded(current => current?.id === id
? enter && !current.activated ? { ...current, activated: true } : current
: { id, trigger: target, activated: enter })
if (enter) focusFirstChild()
}
useLayoutEffect(() => {
if (!open || invalidated) {
cancelClose()
pendingFocus.current = null
setExpanded(null)
if (restoreInvalidatedFocus && (document.activeElement === document.body || document.activeElement === trigger)) {
const target = trigger?.isConnected === true && !trigger.disabled ? trigger
: listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')
pendingFocus.current = target ?? null
if (target != null) target.focus()
else refocusAnchor()
pendingFocus.current = null
}
return
}
if (entry === undefined && pendingFocus.current instanceof HTMLButtonElement) {
pendingFocus.current.focus()
pendingFocus.current = null
}
if (entry === undefined || hidden) return
if (pendingFocus.current === 'child' || (page && listRef.current?.contains(document.activeElement))
|| (!page && backWasFocused)) {
focusFirstChild()
pendingFocus.current = null
}
}, [open, entry, needsPage, page, hidden, invalidated, restoreInvalidatedFocus, backWasFocused, cancelClose])
const collapseFrom = (event: SyntheticEvent<HTMLDivElement>): void => {
// React DOM normalizes text-node event targets to their parent element.
const row = (event.target as Element).closest('button[role="menuitem"]')
if (row === null || row.getAttribute('aria-haspopup') === 'menu') return
if (row.closest('[role="menu"]') === event.currentTarget) collapse()
}
return {
entry, panelRef, parentAnchor, measured, page, hidden,
contains,
activeList: (focused: Node | null) => page || contains(focused) ? panelRef.current : listRef.current,
returnToParent: (): boolean => {
if (entry === undefined || hidden) return false
returnToParent()
return true
},
openChild,
focusParent: (id: string, target: HTMLButtonElement): void => {
if (pendingFocus.current !== target) openChild(id, target)
},
leave: (): void => { if (!page) armClose() },
cancelClose,
collapseFrom,
}
}
@@ -174,6 +174,7 @@ describe('MenuItemButton', () => {
describe('Menu', () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
@@ -600,9 +601,9 @@ describe('Menu', () => {
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
fireEvent.focus(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
fireEvent.pointerEnter(parent.parentElement as HTMLElement)
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Row' }))
fireEvent.pointerOver(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
})
@@ -634,13 +635,13 @@ describe('Menu', () => {
fireEvent.click(parent)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.focus(parent)
fireEvent.mouseEnter(wrap)
fireEvent.pointerEnter(wrap)
expect(screen.getByTestId('sub-ic')).toBeDefined()
expect(screen.getByRole('menuitem', { name: 'Create ok' }).getAttribute('aria-keyshortcuts')).toBe('Meta+N')
expect(screen.getByRole('menuitem', { name: 'Create ok' }).querySelectorAll('kbd')).toHaveLength(2)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
expect(onSelect).toHaveBeenCalledWith('ok')
fireEvent.mouseLeave(wrap)
fireEvent.focus(plain)
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
@@ -805,7 +806,7 @@ describe('Menu', () => {
expect(onSelect).toHaveBeenCalledWith('new')
})
it('caps the list height for internal scrolling unless a submenu row is present', () => {
it('caps the list height for internal scrolling including menus with submenus', () => {
const { rerender } = render(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
@@ -817,7 +818,7 @@ describe('Menu', () => {
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
})
@@ -0,0 +1,14 @@
// @vitest-environment jsdom
/** Submenu layout mode: CSS anchors place the card, so only the side-or-page decision is unit tested. */
import { expect, it } from 'vitest'
import { submenuNeedsPage } from '../src/menu-submenu-position.ts'
it.each([
{ name: 'right side fits', parentX: 100, width: 200, viewport: 1024, page: false },
{ name: 'left side fits', parentX: 780, width: 200, viewport: 1024, page: false },
{ name: 'right side fits exactly at the margin', parentX: 12, width: 188, viewport: 416, page: false },
{ name: 'neither side fits', parentX: 100, width: 200, viewport: 390, page: true },
{ name: 'a wide child fits neither side', parentX: 100, width: 500, viewport: 390, page: true },
])('$name: page=$page', ({ parentX, width, viewport, page }) => {
expect(submenuNeedsPage(new DOMRect(parentX, 100, 200, 100), width, viewport)).toBe(page)
})
@@ -0,0 +1,614 @@
// @vitest-environment jsdom
/** Submenu interactions use explicit geometry and pointer capabilities, not the host screen. */
import { useRef, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Menu, MenuItemButton, type MenuEntry } from '../src/Menu.tsx'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
let panel: DOMRect
let row: DOMRect
let childHeight: number
let hover: boolean
beforeEach(() => {
panel = new DOMRect(100, 100, 200, 300)
row = new DOMRect(104, 140, 192, 34)
childHeight = 100
hover = true
vi.stubGlobal('innerWidth', 1024)
vi.stubGlobal('innerHeight', 768)
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: hover })))
vi.useFakeTimers()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.tagName === 'BUTTON' ? row : panel
})
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(200)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
return this.className.includes('submenu') ? childHeight : panel.height
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function pointer(
target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'pen' | 'touch', relatedTarget: EventTarget | null = null,
) {
const event = new MouseEvent(type, { bubbles: true, relatedTarget })
Object.defineProperty(event, 'pointerType', { value: pointerType })
fireEvent(target, event)
}
const entries = [
{ id: 'parent', label: 'Applications', submenu: [
{ id: 'disabled', label: 'Unavailable', disabled: true },
{ id: 'first', label: 'First app' },
{ id: 'second', label: 'Second app' },
] },
{ id: 'other', label: 'Other action' },
]
function Fixture({ onSelect = () => {}, backLabel = 'Go back' }: { onSelect?: (id: string) => void; backLabel?: string }) {
const [open, setOpen] = useState(false)
return <Menu open={open} submenuBackLabel={backLabel}
anchor={<button onClick={() =>{ setOpen(true) }}>More</button>}
items={entries} onClose={() =>{ setOpen(false) }}
onSelect={(id) => { onSelect(id); setOpen(false) }}>
<MenuItemButton onSelect={() =>{ setOpen(false) }}>Plugin action</MenuItemButton>
</Menu>
}
function ExternalTriggerFixture({ onClose }: { onClose: () => void }) {
const trigger = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <>
<button ref={trigger} data-context-menu-trigger="" aria-haspopup="menu"
onClick={() => { setOpen(value => !value) }}><span>External more</span></button>
<Menu open={open} anchor={null} portal contextual returnFocus={() => trigger.current}
isExternalTrigger={node => trigger.current?.contains(node) === true}
anchorRef={trigger}
items={entries} onClose={() => { onClose(); setOpen(false) }} />
</>
}
function openMenu() {
const trigger = screen.getByRole('button', { name: 'More' })
act(() =>{ trigger.focus() })
fireEvent.click(trigger)
return trigger
}
function childMenu() {
return screen.getByRole('menuitem', { name: 'First app' }).closest<HTMLElement>('[role="menu"]')!
}
describe('Menu submenus', () => {
it('dismisses a non-contextual menu when an external marked trigger is pressed', () => {
const close = vi.fn()
render(<>
<button data-context-menu-trigger="">Header more</button>
<Menu open anchor={null} items={entries} onClose={close} />
</>)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Header more' }))
expect(close).toHaveBeenCalledOnce()
})
it('does not activate an external popup trigger on ArrowRight', () => {
render(<ExternalTriggerFixture onClose={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'External more' })
act(() => { trigger.focus() })
fireEvent.click(trigger)
fireEvent.keyDown(trigger, { key: 'ArrowRight' })
expect(screen.getByRole('menu')).toBeDefined()
expect(document.activeElement).toBe(trigger)
})
it('dismisses on a return-focus subtree that is not marked as a menu trigger', () => {
const close = vi.fn()
const restore = document.createElement('div')
const target = document.createElement('button')
restore.append(target)
document.body.append(restore)
try {
render(<Menu open anchor={null} contextual returnFocus={() => restore} items={entries} onClose={close} />)
fireEvent.pointerDown(target)
expect(close).toHaveBeenCalledOnce()
} finally { restore.remove() }
})
it.each(['mouse', 'pen'] as const)('accepts a secondary %s when the primary pointer cannot hover', (pointerType) => {
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' })))
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
pointer(parent, 'pointerover', 'touch')
fireEvent.mouseOver(parent)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
pointer(parent, 'pointerover', pointerType)
expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
})
it('lets an external return-focus trigger toggle closed through pointerdown then click', () => {
const close = vi.fn()
render(<ExternalTriggerFixture onClose={close} />)
const target = screen.getByText('External more')
fireEvent.pointerDown(target)
fireEvent.click(target)
expect(screen.getByRole('menu')).toBeDefined()
fireEvent.pointerDown(target)
expect(close).not.toHaveBeenCalled()
fireEvent.click(target)
expect(screen.queryByRole('menu')).toBeNull()
})
it.each(['removed', 'disabled', 'empty'] as const)('restores the root page when the active parent becomes %s', (change) => {
vi.stubGlobal('innerWidth', 390)
const props = { open: true, anchor: null, submenuBackLabel: 'Go back', onClose: vi.fn() }
const view = render(<Menu {...props} items={entries} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
const changed: MenuEntry[] = change === 'removed' ? [entries[1]!]
: [{ ...entries[0]!, ...(change === 'disabled' ? { disabled: true } : { submenu: [] }) }, entries[1]!]
view.rerender(<Menu {...props} items={changed} />)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined()
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: change === 'empty' ? 'Applications' : 'Other action' }))
view.rerender(<Menu {...props} items={entries} />)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Applications' }).getAttribute('aria-expanded')).toBe('false')
})
it.each([390, 1024])('restores focus when an active footer submenu is removed at %ipx', (width) => {
vi.stubGlobal('innerWidth', width)
const props = { open: true, anchor: null, items: [entries[1]!], onClose: vi.fn() }
const view = render(<Menu {...props} footer={[entries[0]!]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
view.rerender(<Menu {...props} />)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' }))
})
it('preserves focus already moved outside an invalidated submenu', () => {
vi.stubGlobal('innerWidth', 390)
const props = { open: true, anchor: <button>Outside control</button>, onClose: vi.fn() }
const view = render(<Menu {...props} items={entries} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const outside = screen.getByRole('button', { name: 'Outside control' })
act(() => { outside.focus() })
view.rerender(<Menu {...props} items={[entries[1]!]} />)
expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined()
expect(document.activeElement).toBe(outside)
})
it('returns focus to the anchor when invalidation leaves no enabled root row', () => {
vi.stubGlobal('innerWidth', 390)
const props = { open: true, anchor: <button>More</button>, onClose: vi.fn() }
const view = render(<Menu {...props} items={[entries[0]!]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
view.rerender(<Menu {...props} items={[]} />)
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'More' }))
})
it('shows a chevron, isolates the child keyboard walk, and returns before closing the root', () => {
render(<Fixture />)
const trigger = openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
expect(parent.querySelector('[data-icon="dsw-icon-chevron-right-outline-regular"]')).not.toBeNull()
act(() =>{ parent.focus() })
expect(parent.getAttribute('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(parent)
fireEvent.keyDown(parent, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Second app' }))
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' })
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(parent)
fireEvent.keyDown(parent, { key: 'ArrowRight' })
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.getByRole('menu')).toBeDefined()
expect(document.activeElement).toBe(parent)
fireEvent.keyDown(parent, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it.each(['click', 'Tab'])('selects the child with %s once and restores the original trigger', async (method) => {
const select = vi.fn()
render(<Fixture onSelect={select} />)
const trigger = openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const child = screen.getByRole('menuitem', { name: 'First app' })
fireEvent.pointerDown(child)
expect(screen.getAllByRole('menu')).toHaveLength(2)
if (method === 'click') fireEvent.click(child)
else fireEvent.keyDown(child, { key: 'Tab' })
await act(async () => { await Promise.resolve() })
expect(select).toHaveBeenCalledExactlyOnceWith('first')
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('portals a side child anchored to the parent card at the active row offset, outside parent scrolling', () => {
panel = new DOMRect(780, 620, 200, 120)
row = new DOMRect(784, 680, 192, 34)
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
const root = parent.closest<HTMLElement>('[role="menu"]')!
expect(menu.parentElement).toBe(document.body)
expect(root.contains(menu)).toBe(false)
expect(menu.getAttribute('data-submenu-layout')).toBe('side')
expect(menu.style.visibility).toBe('')
// CSS anchors own placement and flipping; the card names its anchor and sets no coordinates.
const box = root.querySelector<HTMLElement>(':scope > [aria-hidden="true"][style*="anchor-name"]')
expect(box?.style.anchorName).toBe(menu.style.getPropertyValue('--dsh-submenu-parent'))
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(menu.style.getPropertyValue('--dsh-submenu-height')).toBe('100px')
expect(menu.style.getPropertyValue('--dsh-submenu-row-offset')).toBe('60px')
row = new DOMRect(784, 650, 192, 34)
fireEvent.scroll(root.querySelector('[role="presentation"]')!)
expect(menu.style.getPropertyValue('--dsh-submenu-row-offset')).toBe('30px')
})
it('re-measures the row offset on observed size changes and stops observing on close', () => {
const observers: { callback: ResizeObserverCallback; observed: Element[]; disconnect: ReturnType<typeof vi.fn> }[] = []
vi.stubGlobal('ResizeObserver', class {
private readonly record
constructor(callback: ResizeObserverCallback) {
this.record = { callback, observed: [] as Element[], disconnect: vi.fn() }
observers.push(this.record)
}
observe(element: Element) { this.record.observed.push(element) }
unobserve() {}
disconnect() { this.record.disconnect() }
})
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
const observer = observers.at(-1)!
expect(observer.observed).toEqual(expect.arrayContaining([parent, menu, parent.closest('[role="menu"]')]))
expect(menu.style.getPropertyValue('--dsh-submenu-row-offset')).toBe('40px')
row = new DOMRect(104, 200, 192, 34)
act(() => { observer.callback([], {} as ResizeObserver) })
expect(menu.style.getPropertyValue('--dsh-submenu-row-offset')).toBe('100px')
fireEvent.keyDown(menu, { key: 'Escape' })
expect(observer.disconnect).toHaveBeenCalled()
})
it('uses one visible panel at 390px and a localized non-selecting return action', () => {
vi.stubGlobal('innerWidth', 390)
panel = new DOMRect(170, 100, 200, 300)
const select = vi.fn()
render(<Fixture onSelect={select} backLabel="返回上级" />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
expect(screen.getAllByRole('menu')).toHaveLength(1)
expect(menu.getAttribute('data-submenu-layout')).toBe('page')
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
fireEvent.click(screen.getByRole('menuitem', { name: '返回上级' }))
expect(select).not.toHaveBeenCalled()
expect(document.activeElement).toBe(parent)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
})
it('keeps a narrow menu on its parent page until explicit submenu activation', () => {
vi.stubGlobal('innerWidth', 390)
const close = vi.fn()
render(<Menu open autoFocus anchor={null} submenuBackLabel="Go back" items={entries} onClose={close} />)
const parent = screen.getByRole('menuitem', { name: 'Applications' })
expect(document.activeElement).toBe(parent)
expect(parent.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
fireEvent.keyDown(parent, { key: 'Escape' })
expect(close).toHaveBeenCalledOnce()
fireEvent.keyDown(parent, { key: 'ArrowRight' })
expect(screen.getByRole('menuitem', { name: 'Go back' })).toBeDefined()
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
})
it('uses the caller parent label when no explicit return label was supplied', () => {
vi.stubGlobal('innerWidth', 390)
render(<Menu open anchor={null} items={entries} onClose={() => {}} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const back = screen.getByRole('menuitem', { name: 'Applications' })
expect(back.querySelector('[data-icon="dsw-icon-chevron-left-outline-regular"]')).not.toBeNull()
fireEvent.click(back)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
})
it('preserves the focused child DOM node while resizing between side and single-panel placement', () => {
render(<Fixture />)
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const child = screen.getByRole('menuitem', { name: 'Second app' })
act(() =>{ child.focus() })
vi.stubGlobal('innerWidth', 390)
fireEvent(window, new Event('resize'))
expect(screen.getAllByRole('menu')).toHaveLength(1)
expect(document.activeElement).toBe(child)
vi.stubGlobal('innerWidth', 1024)
fireEvent(window, new Event('resize'))
expect(screen.getAllByRole('menu')).toHaveLength(2)
expect(document.activeElement).toBe(child)
})
it('keeps wide touch devices in side placement and ignores touch and compatibility mouse hover', () => {
hover = false
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
pointer(parent, 'pointerover', 'touch')
fireEvent.mouseOver(parent)
pointer(parent, 'pointerover', 'mouse')
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
pointer(parent, 'pointerdown', 'touch')
fireEvent.click(parent)
expect(screen.getAllByRole('menu')).toHaveLength(2)
expect(childMenu().getAttribute('data-submenu-layout')).toBe('side')
expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull()
hover = true
pointer(screen.getByRole('menuitem', { name: 'Other action' }), 'pointerover', 'touch')
fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Other action' }))
expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
})
it.each(['Other action', 'Plugin action'])('keeps keyboard navigation after hovering root row %s', (name) => {
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
const target = screen.getByRole('menuitem', { name })
pointer(menu, 'pointerover', 'mouse', parent)
pointer(menu, 'pointerout', 'mouse', target)
pointer(target, 'pointerover', 'mouse', menu)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(parent)
fireEvent.keyDown(parent, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' }))
})
it('keeps child keyboard navigation when hover replaces its submenu', () => {
render(<Menu open anchor={null} onClose={() => {}} items={[
entries[0]!,
{ id: 'next', label: 'Next group', submenu: [{ id: 'next-one', label: 'Next choice' }] },
]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const menu = childMenu()
const next = screen.getByRole('menuitem', { name: 'Next group' })
pointer(menu, 'pointerover', 'mouse')
pointer(menu, 'pointerout', 'mouse', next)
pointer(next, 'pointerover', 'mouse', menu)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(next)
fireEvent.keyDown(next, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Next choice' }))
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(next)
})
it('does not return to the submenu parent when a root row already holds focus', () => {
render(<Fixture />)
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const target = screen.getByRole('menuitem', { name: 'Other action' })
act(() => { target.focus() })
pointer(target, 'pointerover', 'mouse')
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(target)
})
it('returns child focus after pointer grace so root arrow navigation can continue', () => {
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
pointer(menu, 'pointerover', 'mouse', parent)
pointer(menu, 'pointerout', 'mouse', document.body)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(parent)
fireEvent.keyDown(parent, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' }))
})
it.each(['More', 'Other action'])('preserves focus moved to %s before pointer grace ends', (name) => {
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const menu = childMenu()
pointer(menu, 'pointerover', 'mouse', parent)
pointer(menu, 'pointerout', 'mouse', document.body)
const target = screen.getByRole(name === 'More' ? 'button' : 'menuitem', { name })
act(() => { target.focus() })
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
expect(document.activeElement).toBe(target)
})
it('allows crossing the hover gap and cancels stale grace after keyboard return', () => {
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
pointer(parent, 'pointerover', 'mouse')
pointer(parent, 'pointerout', 'mouse')
pointer(childMenu(), 'pointerover', 'mouse')
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
pointer(parent, 'pointerout', 'mouse')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(parent)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
pointer(screen.getByRole('menuitem', { name: 'Plugin action' }), 'pointerover', 'mouse')
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
})
it('moves focus from a removed return row to the first child on width expansion', () => {
vi.stubGlobal('innerWidth', 390)
render(<Fixture />)
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
act(() =>{ screen.getByRole('menuitem', { name: 'Go back' }).focus() })
vi.stubGlobal('innerWidth', 1024)
fireEvent(window, new Event('resize'))
expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
})
it('keeps the return row reachable when a narrow child has no enabled choices', () => {
vi.stubGlobal('innerWidth', 390)
render(<Menu open anchor={null} submenuBackLabel="Go back" onClose={() => {}}
items={[{ id: 'parent', label: 'Applications', submenu: [{ id: 'disabled', label: 'Unavailable', disabled: true }] }]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Go back' }))
})
it('preserves the document composition guard across child opening and placement updates', () => {
render(<Menu open anchor={null} items={entries} onClose={vi.fn()} />)
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.compositionStart(document)
fireEvent.click(parent)
vi.stubGlobal('innerWidth', 390)
fireEvent(window, new Event('resize'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
fireEvent.compositionEnd(document)
fireEvent.keyUp(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'Escape' })
expect(document.activeElement).toBe(parent)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
})
it('walks the single-panel child and closes to the original anchor on Shift+Tab', () => {
vi.stubGlobal('innerWidth', 390)
render(<Fixture />)
const trigger = openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Second app' }))
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Go back' }))
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
fireEvent.keyDown(document.activeElement!, { key: 'Tab', shiftKey: true })
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('cancels a pending child close when the root closes and reopens', () => {
const props = { anchor: null, items: entries, onClose: vi.fn() }
const view = render(<Menu {...props} open />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const child = childMenu()
pointer(child, 'pointerover', 'mouse')
pointer(child, 'pointerout', 'mouse', document.body)
view.rerender(<Menu {...props} open={false} />)
view.rerender(<Menu {...props} open />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
})
it.each([false, true])('requires caller opt-in for external controls even in contextual=%s menus', (contextual) => {
const close = vi.fn()
const props = { open: true, anchor: null, contextual, items: entries, onClose: close }
const view = render(<><button data-context-menu-trigger="">External</button><Menu {...props} /></>)
const external = screen.getByRole('button', { name: 'External' })
fireEvent.pointerDown(external)
expect(close).toHaveBeenCalledOnce()
view.rerender(<><button data-context-menu-trigger="">External</button>
<Menu {...props} isExternalTrigger={node => external.contains(node)} /></>)
fireEvent.pointerDown(external.firstChild!)
expect(close).toHaveBeenCalledOnce()
fireEvent.pointerDown(document.body)
expect(close).toHaveBeenCalledTimes(2)
})
it.each([false, true])('handles child context gestures only in contextual=%s menus', (contextual) => {
const close = vi.fn()
render(<Menu open contextual={contextual} anchor={null} items={entries} onClose={close} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const child = screen.getByRole('menuitem', { name: 'First app' })
const nativeMenuAllowed = fireEvent.contextMenu(child)
expect(nativeMenuAllowed).toBe(!contextual)
expect(close).toHaveBeenCalledTimes(contextual ? 1 : 0)
})
it('does not collapse a compact child on touch leave', () => {
render(<Menu open compact dense anchor={null} items={entries} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const child = childMenu()
pointer(child, 'pointerover', 'touch')
pointer(child, 'pointerout', 'touch', document.body)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
})
it('retains an activated page when an armed side-card grace expires after resizing', () => {
render(<Fixture />)
openMenu()
const parent = screen.getByRole('menuitem', { name: 'Applications' })
fireEvent.click(parent)
const child = childMenu()
pointer(child, 'pointerover', 'mouse')
pointer(child, 'pointerout', 'mouse', document.body)
vi.stubGlobal('innerWidth', 390)
fireEvent(window, new Event('resize'))
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.getAllByRole('menu')).toHaveLength(1)
pointer(child, 'pointerover', 'mouse')
pointer(child, 'pointerout', 'mouse', document.body)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
})
it('keeps focus on an enabled parent when its hovered child becomes empty', () => {
const props = { open: true, anchor: null, onClose: vi.fn() }
const view = render(<Menu {...props} items={entries} />)
const parent = screen.getByRole('menuitem', { name: 'Applications' })
act(() => { parent.focus() })
view.rerender(<Menu {...props} items={[{ ...entries[0]!, submenu: [] }, entries[1]!]} />)
expect(document.activeElement).toBe(parent)
expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
})
it('ignores nested custom menu rows and text-target events during submenu exclusivity', () => {
render(<Menu open anchor={null} items={entries} onClose={vi.fn()}>
<div role="menu"><button role="menuitem">Nested row</button></div>
<span>Non-row text</span>
</Menu>)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
pointer(screen.getByRole('menuitem', { name: 'Nested row' }), 'pointerover', 'mouse')
const event = new MouseEvent('pointerover', { bubbles: true })
Object.defineProperty(event, 'pointerType', { value: 'mouse' })
fireEvent(screen.getByText('Non-row text').firstChild!, event)
expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
})
it('does not open disabled submenu parents and dismisses on outside pointerdown', () => {
const close = vi.fn()
render(<Menu open anchor={null} items={[{ ...entries[0]!, disabled: true }]} onClose={close} />)
pointer(screen.getByRole('menuitem'), 'pointerover', 'mouse')
fireEvent.click(screen.getByRole('menuitem'))
expect(screen.getAllByRole('menu')).toHaveLength(1)
fireEvent.pointerDown(document.body)
expect(close).toHaveBeenCalledOnce()
})
})
@@ -429,6 +429,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
useStore={bindSnapshotSelector(conversation)}
actions={conversation.actions}
renderSlot={() => null}
renderFactorySlot={() => null}
open={vi.fn()}
selectView={conversation.actions.setView}
t={tConversation}
@@ -33,11 +33,11 @@
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: cb2dea9fe7e3b060
zh: f8d65c3920ff2cff
en: 668ee06286b227b6
zh: aafdb03cde1b93c9
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: e14859b6f1043860
zh: 7cd95796084b2158
en: 8f170900a4096408
zh: 85332e80adf39403
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
en: 868904dbfb5713f8
zh: 35d3c285ed770885
+2 -2
View File
@@ -77,7 +77,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr
Grouped and flat Session rows show a clock mark when the Session has active scheduled tasks. It is an occupant of the row's `sidebar.session.row.leading` seat, so it renders only on a row whose primary status is idle and never beside the row's own status dot; an archived row keeps that cell blank, and a search result has no leading seat and shows no mark. It is not a button, has no tab stop, and a press on its area does not open the row. The mark's own read and the meaning of its active-task condition belong to [ui-schedule](../ui-schedule/README.md).
Workspace headers share one `workspace` context-menu target between right-click and the ellipsis. Rename and Delete use the existing confirmation dialogs and recheck Workspace existence and access before opening; native directory reveal is contributed by [ui-open-in-app](../ui-open-in-app/README.md). The menu remains available for native and plugin actions when mutation permissions are absent. Opening it changes neither expansion nor Session selection.
Workspace headers share one `workspace` context-menu target, declared by this package, between right-click and the ellipsis. Rename and Delete use the existing confirmation dialogs and recheck Workspace existence and access before opening; native directory reveal is contributed by [ui-open-in-app](../ui-open-in-app/README.md). The menu remains available for native and plugin actions when mutation permissions are absent. Opening it changes neither expansion nor Session selection.
Session rows share their existing action contributions between right-click and the ellipsis trigger through [ui-context-menu](../ui-context-menu/README.md). The captured Session stays the target without changing selection; `sidebar.workspaces.session.menu.item` remains the component extension position. The shipped groups are Pin, Rename, Fork; Copy session ID and the optional native directory reveal; then Archive, with separators before Copy session ID and Archive.
@@ -109,7 +109,7 @@ The browser entry declares three root-scoped `list` child seats on each Session
The leading seat occupies the status cell only while the row's primary status is idle and leaves archived rows blank. The trailing seat accepts non-interactive icons in ascending `order`, after the time label and before the pin marker. Blank rows omit the trailing seat; an empty seat adds no spacing. The hover seat mounts only while the row's hover card is open.
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves. Built-in Session and Workspace row action buttons use 24px click targets with compensated margins that retain their compact layout footprint and icon positions.
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves. Built-in Session and Workspace row action buttons use 24px click targets with compensated margins that retain their compact layout footprint and icon positions. Both row types render the shared `contextMenu.button` Factory with a captured target, localized label, and row styling; they omit the toolbar size to preserve row geometry. The browser passes its standard `renderFactorySlot` through grouped and flat lists, while rows read the shared target to keep the action strip visible and suppress hover cards.
Session rows accept Tab focus and open with unmodified Enter or Space; Tab also reaches their action buttons, while modified keys remain available to global shortcuts.
+2 -2
View File
@@ -77,7 +77,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
当成组的与平铺的 Session 行所对应的 Session 存在活动定时任务时,该行显示时钟标记。它是行首 `sidebar.session.row.leading` seat 的占用方,因此仅在该行主状态为 idle 时渲染,绝不与行自身的状态点同时出现;归档行该格同样留空,搜索结果没有该 seat,也不显示标记。它不是按钮,没有 Tab stop,点击其所在区域不会打开整行。标记自身的读取及其「活动」条件的确切含义由 [ui-schedule](../ui-schedule/README.zh.md) 负责。
工作区标题的右键和省略号共用 `workspace` 右键操作对象。重命名和删除沿用现有确认对话框,打开前再次检查工作区是否存在及操作权限;本机目录显示由 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供。没有修改权限时仍可使用本机和插件菜单操作。打开菜单不改变展开状态或选中的会话。
工作区标题的右键和省略号共用本包声明的 `workspace` 右键操作对象。重命名和删除沿用现有确认对话框,打开前再次检查工作区是否存在及操作权限;本机目录显示由 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供。没有修改权限时仍可使用本机和插件菜单操作。打开菜单不改变展开状态或选中的会话。
会话行通过 [ui-context-menu](../ui-context-menu/README.zh.md) 在右键与省略号按钮间共享现有操作贡献。操作对象固定为被点击的会话,不改变选中状态;`sidebar.workspaces.session.menu.item` 仍是组件扩展位置。内置分组依次为置顶、重命名、分叉;复制 Session ID 和可选的本机目录显示;归档,复制 Session ID 与归档之前各有一条分割线。
@@ -109,7 +109,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
前置 slot 仅在该行主状态为 idle 时占用状态单元,归档行留空。尾部 slot 按 `order` 升序放置非交互图标,位置在时间标签之后、置顶标记之前。空白行不渲染尾部 slot;空 slot 不增加间距。悬浮 slot 仅在该行的悬浮卡片打开时挂载。
在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。内置 Session 和 Workspace 行操作按钮使用 24px 点击热区,通过补偿外边距保留紧凑的布局占位与图标位置。
在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。内置 Session 和 Workspace 行操作按钮使用 24px 点击热区,通过补偿外边距保留紧凑的布局占位与图标位置。两类行均通过共享 `contextMenu.button` Factory 传入捕获的操作对象、本地化标签与行样式,不传工具栏尺寸以保留行布局。浏览器将标准 `renderFactorySlot` 经分组及扁平列表传给行组件;行仍读取共享操作对象,使操作区在菜单打开时保持可见并抑制悬浮卡片。
Session 行可通过 Tab 聚焦,使用不带修饰键的 Enter 或空格打开;Tab 也可移到行内操作按钮,组合键则继续交由全局快捷键处理。
@@ -36,7 +36,7 @@
*/
import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import type {
HostObservable, InjectFace, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
HostObservable, InjectFace, PropsHooks, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
} from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
@@ -241,12 +241,8 @@ export type DirectoryPickingHooks = PropsHooks<DirectoryPickingInjected['hooks']
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
/** Open the registered Workspace actions without changing expansion or selection. */
openWorkspaceMenu: (workspaceId: WorkspaceId, path: string, displayTitle: string, point: { x: number; y: number }, mode?: 'toggle') => void
/** Dismiss the settled Workspace dialog. */
settleWorkspaceAction: () => void
/** Open actions for the clicked Session without selecting it. */
openSessionMenu: (sessionId: SessionId, displayTitle: string, point: { x: number; y: number }, mode?: 'toggle') => void
hooks: DirectoryPickingInjected['hooks'] & {
workspaceActionRequest: HostObservable<WorkspaceActionRequest | null>
contextMenuTarget: HostObservable<ContextMenuTarget | null>
@@ -498,6 +494,7 @@ export type RowToastProps =
/** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderFactories
& PropsRenderSlots<
| 'sidebar.workspaces.directoryFlow'
| 'sidebar.workspaces.session.row.action'
@@ -288,17 +288,7 @@ export function apply(ctx: Context): void {
run: () => { requestWorkspaceAction(workspaceId, displayTitle, 'delete') } }] : [],
}), 'workspace: delete menu action')
const browserInjected = (): WorkspaceBrowserInjected => ({
openWorkspaceMenu: (workspaceId, path, displayTitle, point, mode) => {
const target = ctx.contextMenus.active.getSnapshot()
if (mode === 'toggle' && target?.kind === 'workspace' && target.value.workspaceId === workspaceId) ctx.contextMenus.close()
else ctx.contextMenus.open({ kind: 'workspace', value: { workspaceId, path, displayTitle } }, point)
},
settleWorkspaceAction: () => { workspaceActionRequest.set(null) },
openSessionMenu: (sessionId, displayTitle, point, mode) => {
const target = ctx.contextMenus.active.getSnapshot()
if (mode === 'toggle' && target?.kind === 'session' && target.value.sessionId === sessionId) ctx.contextMenus.close()
else ctx.contextMenus.open({ kind: 'session', value: { sessionId, displayTitle } }, point)
},
// Explicit group actions keep their target; unscoped New Session inherits
// the current Session Workspace before the recent-Workspace fallback.
startSession: (workspaceId, options) => { uiWorkspace.startSession(workspaceId, options) },
@@ -3,12 +3,11 @@
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only, and a session row's clipped
* title marquees programmatically while the row is hovered. Workspace row
* menus share captured Workspace actions with right-click. A Session row's "..." menu and
* its hover buttons are the `sidebar.workspaces.session.menu.item` and
* `sidebar.workspaces.session.row.action` lists, rendered through the
* browser's `renderSlot` with the menu's open state as the occurrence's hook
* context; this package's own actions are entries like any plugin's. The
* session and workspace hover cards are suppressed while a menu is open.
* menus share captured Workspace actions with right-click. Both row types
* render the shared `contextMenu.button` factory for their menu trigger.
* Session hover buttons use the browser's `renderSlot` for the
* `sidebar.workspaces.session.row.action` list. Session and Workspace
* hover cards are suppressed while their menu is open.
*/
import { useEffect, useMemo, useRef } from 'react'
import type { PointerEvent, RefObject } from 'react'
@@ -16,7 +15,7 @@ import clsx from 'clsx'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
HoverCard, IconArchiveOutlineRegular,
IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular,
IconFolderCloseRegular, IconFolderOpenRegular,
IconNewChatOutlineRegular, IconPinFillRegular,
IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, relativeTime, StateDot, Tooltip, useRowDragPreview,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -230,7 +229,7 @@ type ProjectRowItemProps = {
/** Host account home; POSIX home-rooted hover paths display as `~`. */
home?: string | undefined
t: RowTranslate
} & Pick<WorkspaceBrowserProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'openWorkspaceMenu' | 'useContextMenuTarget' | 'useContextMenu'>
} & Pick<WorkspaceBrowserProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useContextMenu'>
/** Which of one header row's actions the caller's access snapshot allows. */
interface ProjectRowAccess {
@@ -279,7 +278,7 @@ function WorkspaceProjectRow({ workspaceId, ...props }: ProjectRowItemProps & {
function ProjectRow({
group, containsCurrentDescendant = false, onToggle, onCreate, drag: offeredDrag, home, newShortcut, t, access,
openWorkspaceMenu, useContextMenuTarget, useContextMenu,
renderFactorySlot, useContextMenuTarget, useContextMenu,
}: ProjectRowItemProps & { access: ProjectRowAccess }) {
const { getTriggerProps } = useContextMenu()
const drag = access.reorder ? offeredDrag : undefined
@@ -321,13 +320,11 @@ function ProjectRow({
</span>
<span className={css.rowActions}>
{workspaceMenuTarget !== undefined && (
<button type="button" className={css.iconButton} data-context-menu-trigger
aria-label={t('actions.workspace.aria', { name: label })} aria-haspopup="menu" aria-expanded={menuOpen}
onClick={(event) => {
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
openWorkspaceMenu(workspaceMenuTarget.workspaceId, workspaceMenuTarget.path, label, { x: rect.left, y: rect.bottom }, 'toggle')
}}><IconEllipsisOutlineRegular /></button>
renderFactorySlot('contextMenu.button', {
target: { kind: 'workspace', value: { ...workspaceMenuTarget, displayTitle: label } },
label: t('actions.workspace.aria', { name: label }),
className: css.iconButton,
})
)}
{access.create && (
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500} portal>
@@ -595,11 +592,11 @@ export function SearchResultItem({
* @returns the session row.
*/
export function SessionNodeItem({
node, currentId, now, onOpen, onRenameRequest, renderSlot, openSessionMenu, useContextMenuTarget,
node, currentId, now, onOpen, onRenameRequest, renderSlot, renderFactorySlot, useContextMenuTarget,
useContextMenu, onReveal, drag, home, useSessionAccess, t,
}: {
useContextMenu: WorkspaceBrowserProps['useContextMenu']
openSessionMenu: WorkspaceBrowserProps['openSessionMenu']
renderFactorySlot: WorkspaceBrowserProps['renderFactorySlot']
useContextMenuTarget: WorkspaceBrowserProps['useContextMenuTarget']
node: SessionNode
currentId: string | undefined
@@ -750,15 +747,11 @@ export function SessionNodeItem({
button needs no propagation handling of its own. */}
{!row.blank && (
<span className={css.rowActions} onClick={(e) => { e.stopPropagation() }}>
<button type="button" className={css.iconButton}
aria-label={t('actions.session.aria', { name: title })}
onPointerDown={(event) => { event.stopPropagation() }}
data-context-menu-trigger=""
onClick={(event) => {
event.currentTarget.focus({ preventScroll: true })
const rect = event.currentTarget.getBoundingClientRect()
openSessionMenu(node.id, row.title, { x: rect.left, y: rect.bottom }, 'toggle')
}}><IconEllipsisOutlineRegular /></button>
{renderFactorySlot('contextMenu.button', {
target: { kind: 'session', value: { sessionId: node.id, displayTitle: row.title } },
label: t('actions.session.aria', { name: title }),
className: css.iconButton,
})}
<span className={css.rowQuickActions}>
{renderSlot('sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: row.title })}
</span>
@@ -252,7 +252,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessionStatus' | 'startSession' | 'open' | 'openSessionMenu' | 'openWorkspaceMenu' | 'useContextMenuTarget' | 'useContextMenu'
'useSessionStatus' | 'startSession' | 'open' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useContextMenu'
| 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
| 'useRemoteAccess' | 'useSessionAccess' | 'useWorkspaceAccess'
> & PropsRenderSlots<
@@ -316,7 +316,7 @@ function SessionTree({
rowState, onLeaveArchivedOnly,
workspaceReady, animationResetKey, usePanelInfo, useRemoteAccess, useSessionAccess, useWorkspaceAccess,
onSessionRenameRequest,
renderSlot, openSessionMenu, openWorkspaceMenu, useContextMenuTarget, useContextMenu,
renderSlot, renderFactorySlot, useContextMenuTarget, useContextMenu,
insertWorkspaceBefore,
nestWorkspaces, groupExpansion, setGroupExpanded,
setSessionOrder, home, t,
@@ -554,7 +554,7 @@ function SessionTree({
<ProjectRowItem
newShortcut={shortcuts.find(row => row.id === 'session.new')}
group={group}
openWorkspaceMenu={openWorkspaceMenu} useContextMenu={useContextMenu} useContextMenuTarget={useContextMenuTarget}
renderFactorySlot={renderFactorySlot} useContextMenu={useContextMenu} useContextMenuTarget={useContextMenuTarget}
useRemoteAccess={useRemoteAccess}
useWorkspaceAccess={useWorkspaceAccess}
containsCurrentDescendant={currentAncestors.has(group.key)}
@@ -617,7 +617,7 @@ function SessionTree({
onOpen={open}
onRenameRequest={onSessionRenameRequest}
renderSlot={renderSlot}
openSessionMenu={openSessionMenu} useContextMenu={useContextMenu}
renderFactorySlot={renderFactorySlot} useContextMenu={useContextMenu}
useContextMenuTarget={useContextMenuTarget}
onReveal={node.id === revealSessionId && group.key === revealGroup
? () => { onSessionRevealed(node.id) }
@@ -681,7 +681,7 @@ function SessionTree({
function FlatList({
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, useSessionAccess, open, onSessionRenameRequest,
usePanelInfo, setSessionOrder, workspaceReady, animationResetKey,
revealSessionId, onSessionRevealed, renderSlot, openSessionMenu, useContextMenuTarget, useContextMenu, home, t,
revealSessionId, onSessionRevealed, renderSlot, renderFactorySlot, useContextMenuTarget, useContextMenu, home, t,
}: Pick<
SessionTreeProps,
| 'useSessionStatus'
@@ -689,7 +689,7 @@ function FlatList({
| 'open'
| 'onSessionRenameRequest'
| 'renderSlot'
| 'openSessionMenu' | 'useContextMenuTarget' | 'useContextMenu'
| 'renderFactorySlot' | 'useContextMenuTarget' | 'useContextMenu'
| 'usePanelInfo'
| 'setSessionOrder'
| 'workspaceReady'
@@ -750,7 +750,7 @@ function FlatList({
onOpen={open}
onRenameRequest={onSessionRenameRequest}
renderSlot={renderSlot}
openSessionMenu={openSessionMenu} useContextMenu={useContextMenu}
renderFactorySlot={renderFactorySlot} useContextMenu={useContextMenu}
useContextMenuTarget={useContextMenuTarget}
onReveal={node.id === revealSessionId
? () => { onSessionRevealed(node.id) }
@@ -925,7 +925,7 @@ export function WorkspaceBrowser({
requestAddWorkspace,
closeAddWorkspace,
setDirectoryBusy,
renderSlot, openSessionMenu, openWorkspaceMenu, useContextMenuTarget, useContextMenu,
renderSlot, renderFactorySlot, useContextMenuTarget, useContextMenu,
t,
}: WorkspaceBrowserProps) {
const home = useHostInfo(info => info.home)
@@ -1389,7 +1389,7 @@ export function WorkspaceBrowser({
open={guardedOpen}
onSessionRenameRequest={requestSessionRename}
renderSlot={renderSlot}
openSessionMenu={openSessionMenu} useContextMenu={useContextMenu}
renderFactorySlot={renderFactorySlot} useContextMenu={useContextMenu}
useContextMenuTarget={useContextMenuTarget}
setSessionOrder={saveSessionOrder}
revealSessionId={revealSessionId}
@@ -1409,8 +1409,7 @@ export function WorkspaceBrowser({
useSessionStatus={useSessionStatus}
onSessionRenameRequest={requestSessionRename}
renderSlot={renderSlot}
openSessionMenu={openSessionMenu} useContextMenu={useContextMenu}
openWorkspaceMenu={openWorkspaceMenu}
renderFactorySlot={renderFactorySlot} useContextMenu={useContextMenu}
useContextMenuTarget={useContextMenuTarget}
workspaces={orderedWorkspaces}
ungroupedSessionIds={orderedUngroupedSessionIds}
@@ -1,4 +1,3 @@
import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
import { Context } from '@deepseek-ai/cordis'
@@ -938,12 +937,6 @@ it('captures Workspace menu requests, rechecks mutable access, and withdraws reg
await fiber.await()
const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected
const target = { kind: 'workspace' as const, value: { workspaceId: 'alpha' as WorkspaceId, path: '/projects/alpha', displayTitle: 'Alpha' } }
browser.openWorkspaceMenu(target.value.workspaceId, target.value.path, 'Alpha', { x: 20, y: 30 })
expect(vi.spyOn(b.ctx.contextMenus, 'open')).toHaveBeenLastCalledWith(target, { x: 20, y: 30 })
const active = b.ctx.contextMenus.active as ReturnType<typeof createSnapshotStore<ContextMenuTarget | null>>
active.set(target)
browser.openWorkspaceMenu(target.value.workspaceId, target.value.path, 'Alpha', { x: 20, y: 30 }, 'toggle')
expect(vi.spyOn(b.ctx.contextMenus, 'close')).toHaveBeenCalledOnce()
const menu = b.ctx.contextMenus.resolve(target)
await menu.ready
expect(menu.source.getSnapshot().map(item => item.id)).toEqual(['workspace.rename/rename', 'workspace.delete/delete'])
@@ -0,0 +1,9 @@
/** Inert factory content for row placement and label tests; menu gestures belong to ui-context-menu. */
import type { ContextMenuButtonProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots'
/** Factory placeholder retaining only the caller's accessible label and styling. */
export const renderMenuButton: PropsRenderFactories['renderFactorySlot'] = (_name, owner) => {
const { label, className } = owner as ContextMenuButtonProps
return <button type="button" aria-label={label} className={className} />
}
@@ -17,6 +17,7 @@ import {
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
import { en, zh } from '../src/client/locales.ts'
import rowCss from '../src/client/rows/Rows.module.css'
import { renderMenuButton } from './menu-button.tsx'
afterEach(cleanup)
beforeEach(() => { vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(any-hover: hover)' })) })
@@ -52,22 +53,24 @@ function realDoubleClick(el: Element): void {
fireEvent.doubleClick(el)
}
function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, openSessionMenu = vi.fn(),
function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, renderFactorySlot = renderMenuButton,
useContextMenuTarget = select => select(null), useSessionAccess = allowAllAccess.useSessionAccess, ...props }: Omit<
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'openSessionMenu' | 'useContextMenuTarget' | 'useSessionAccess' | 'useContextMenu'
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'openSessionMenu' | 'useContextMenuTarget' | 'useSessionAccess' | 'useContextMenu'>>) {
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useSessionAccess' | 'useContextMenu'
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useSessionAccess' | 'useContextMenu'>>) {
return <SessionNodeItemComponent {...props} renderSlot={renderSlot} onRenameRequest={onRenameRequest}
useContextMenu={props.useContextMenu ?? contextMenuHook((target, point) => { if (target.kind === 'session') openSessionMenu(target.value.sessionId, target.value.displayTitle, point) })} openSessionMenu={openSessionMenu} useContextMenuTarget={useContextMenuTarget} useSessionAccess={useSessionAccess} />
useContextMenu={props.useContextMenu ?? contextMenuHook()} renderFactorySlot={renderFactorySlot}
useContextMenuTarget={useContextMenuTarget} useSessionAccess={useSessionAccess} />
}
// Direct header specs hold every access answer unless the case refuses one.
function ProjectRowItem({
useRemoteAccess = allowAllAccess.useRemoteAccess, useWorkspaceAccess = allowAllAccess.useWorkspaceAccess,
openWorkspaceMenu = vi.fn(), useContextMenuTarget = select => select(null), ...props
}: Omit<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'openWorkspaceMenu' | 'useContextMenuTarget' | 'useContextMenu'>
& Partial<Pick<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'openWorkspaceMenu' | 'useContextMenuTarget' | 'useContextMenu'>>) {
renderFactorySlot = renderMenuButton, useContextMenuTarget = select => select(null), ...props
}: Omit<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useContextMenu'>
& Partial<Pick<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess' | 'renderFactorySlot' | 'useContextMenuTarget' | 'useContextMenu'>>) {
return <ProjectRowItemComponent {...props} useRemoteAccess={useRemoteAccess} useWorkspaceAccess={useWorkspaceAccess}
useContextMenu={props.useContextMenu ?? contextMenuHook((target, point) => { if (target.kind === 'workspace') openWorkspaceMenu(target.value.workspaceId, target.value.path, target.value.displayTitle, point) })} openWorkspaceMenu={openWorkspaceMenu} useContextMenuTarget={useContextMenuTarget} />
useContextMenu={props.useContextMenu ?? contextMenuHook()} renderFactorySlot={renderFactorySlot}
useContextMenuTarget={useContextMenuTarget} />
}
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
@@ -655,21 +658,25 @@ describe('workspace browser rows', () => {
expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
})
it('workspace right-click and ellipsis capture the row without toggling expansion', () => {
const openWorkspaceMenu = vi.fn(), onToggle = vi.fn()
it('gives right-click and the shared button the Workspace row target without changing expansion', () => {
const openMenu = vi.fn(), onToggle = vi.fn()
const renderFactorySlot = vi.fn(renderMenuButton)
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
const view = render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} openWorkspaceMenu={openWorkspaceMenu} t={t} />)
const target = { kind: 'workspace', value: { workspaceId: wid('project'), path: '/projects/project', displayTitle: 'Project' } } as const
const view = render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()}
useContextMenu={contextMenuHook(openMenu)} renderFactorySlot={renderFactorySlot} t={t} />)
fireEvent.contextMenu(screen.getByRole('treeitem'), { clientX: 36, clientY: 48 })
expect(openWorkspaceMenu).toHaveBeenLastCalledWith(wid('project'), '/projects/project', 'Project', { x: 36, y: 48 })
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
expect(openWorkspaceMenu).toHaveBeenLastCalledWith(wid('project'), '/projects/project', 'Project', { x: 0, y: 0 }, 'toggle')
expect(openMenu).toHaveBeenLastCalledWith(target, { x: 36, y: 48 })
expect(renderFactorySlot).toHaveBeenLastCalledWith('contextMenu.button', {
target, label: '工作区“Project”的操作', className: rowCss.iconButton,
})
expect(onToggle).not.toHaveBeenCalled()
view.rerender(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} t={t}
useContextMenuTarget={select => select({ kind: 'workspace', value: { workspaceId: wid('project'), path: group.cwd!, displayTitle: 'Project' } })} />)
expect(screen.getByRole('button', { name: '工作区“Project”的操作' }).getAttribute('aria-expanded')).toBe('true')
useContextMenuTarget={select => select(target)} />)
expect(screen.getByRole('treeitem').className).toContain(rowCss.menuOpen)
})
it('workspace row offers only the actions its Workspace access snapshot allows', () => {
@@ -952,24 +959,30 @@ describe('workspace browser rows', () => {
})
})
it('routes both context and More gestures to the clicked Session without selecting it', () => {
const onOpen = vi.fn(), openSessionMenu = vi.fn()
it('gives right-click and the shared button the Session row target and omits blank-row actions', () => {
const onOpen = vi.fn(), openMenu = vi.fn()
const renderFactorySlot = vi.fn(renderMenuButton)
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
}
render(<SessionNodeItem node={node} currentId={sid('other')} now={0} onOpen={onOpen} openSessionMenu={openSessionMenu} t={t} />)
const target = { kind: 'session', value: { sessionId: node.id, displayTitle: 'One' } }
render(<SessionNodeItem node={node} currentId={sid('other')} now={0} onOpen={onOpen}
useContextMenu={contextMenuHook(openMenu)} renderFactorySlot={renderFactorySlot} t={t} />)
fireEvent.contextMenu(screen.getByRole('treeitem'), { clientX: 31, clientY: 47 })
expect(openSessionMenu).toHaveBeenLastCalledWith(node.id, 'One', { x: 31, y: 47 })
fireEvent.pointerDown(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
expect(openSessionMenu).toHaveBeenLastCalledWith(node.id, 'One', { x: 0, y: 0 }, 'toggle')
expect(openMenu).toHaveBeenLastCalledWith(target, { x: 31, y: 47 })
expect(renderFactorySlot).toHaveBeenLastCalledWith('contextMenu.button', {
target, label: '会话“One”的操作', className: rowCss.iconButton,
})
expect(onOpen).not.toHaveBeenCalled()
cleanup()
render(<SessionNodeItem node={{ ...node, blank: true }} currentId={sid('other')} now={0} onOpen={onOpen} openSessionMenu={openSessionMenu} t={t} />)
openSessionMenu.mockClear()
openMenu.mockClear()
renderFactorySlot.mockClear()
render(<SessionNodeItem node={{ ...node, blank: true }} currentId={sid('other')} now={0} onOpen={onOpen}
useContextMenu={contextMenuHook(openMenu)} renderFactorySlot={renderFactorySlot} t={t} />)
fireEvent.contextMenu(screen.getByRole('treeitem'))
expect(openSessionMenu).not.toHaveBeenCalled()
expect(openMenu).not.toHaveBeenCalled()
expect(renderFactorySlot).not.toHaveBeenCalled()
})
it('renders the row action list inside the actions area after the menu trigger', () => {
@@ -21,6 +21,8 @@ import { createWorkspaceShortcutControls } from '../src/client/shortcuts.ts'
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
import { renderMenuButton } from './menu-button.tsx'
import rowCss from '../src/client/rows/Rows.module.css'
import { zh } from '../src/client/locales.ts'
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
@@ -115,10 +117,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
const request = createSnapshotStore<WorkspaceActionRequest | null>(null)
const store = createWorkspaceViewStore().create()
const props: WorkspaceBrowserProps = {
openWorkspaceMenu: vi.fn(),
useWorkspaceActionRequest: bindSnapshotSelector(request),
settleWorkspaceAction: () => { request.set(null) },
openSessionMenu: vi.fn(),
useContextMenuTarget: selector => selector(null),
useShortcuts: select => select([]),
useWorkspaceShortcuts: bindSnapshotSelector(controls.state),
@@ -153,6 +153,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
useHostInfo: selector => selector({ home: undefined, isLoopback: true }),
renderSlot: renderDirectoryFlowOnly,
renderFactorySlot: renderMenuButton,
t,
...overrides,
}
@@ -170,6 +171,28 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
}
describe('WorkspaceBrowser', () => {
it.each(['workspace', 'flat', 'ungrouped'] as const)('passes the standard menu factory through the %s list', (mode) => {
const preferences = createWorkspaceViewStore().create()
preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
preferences.actions.setGroupExpanded(mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha', true)
const renderFactorySlot = vi.fn(renderMenuButton)
mount({
useSessions: hook(sessionState([summary('session-one', 10)])),
useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['session-one'])])),
renderFactorySlot,
})
expect(renderFactorySlot).toHaveBeenCalledWith('contextMenu.button', {
target: { kind: 'session', value: { sessionId: sid('session-one'), displayTitle: 'session-one' } },
label: '会话“session-one”的操作', className: rowCss.iconButton,
})
if (mode === 'workspace') {
expect(renderFactorySlot).toHaveBeenCalledWith('contextMenu.button', {
target: { kind: 'workspace', value: { workspaceId: wid('alpha'), path: '/projects/alpha', displayTitle: 'alpha' } },
label: '工作区“alpha”的操作', className: rowCss.iconButton,
})
}
})
it('binds the list area as the section scroller only while the sidebar is wide', () => {
const bindScrollerRef = vi.fn()
const browser = mount({ useSectionScroller: () => ({ bindScrollerRef }) })
@@ -124,7 +124,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'contextMenu.content\', () => ctx.slots.register(\n { name: \'contextMenu.content\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-context-menu/src/client/index.ts:34',
source: 'packages/client/ui-context-menu/src/client/index.ts:42',
},
{
key: 'contextMenu.icon',
@@ -166,10 +166,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-open-in-app ContextMenuApplicationIcon key \'open-in-app.application\'',
'client-ui-sidebar-right IconCloseOutlineRegular key \'sidebar.close\'',
'client-ui-workspace Icon',
'session-log-export Icon',
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'contextMenu.icon\', () => ctx.slots.register(\n { name: \'contextMenu.icon\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-context-menu/src/client/index.ts:32',
source: 'packages/client/ui-context-menu/src/client/index.ts:40',
},
{
key: 'conversation.approval.detail',
@@ -813,7 +814,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290',
},
{
key: 'conversation.composer.bar',
@@ -856,7 +857,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:329',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:336',
},
{
key: 'conversation.composer.dock',
@@ -917,7 +918,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:308',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:315',
},
{
key: 'conversation.header',
@@ -956,7 +957,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:230',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
},
{
key: 'conversation.header.leading',
@@ -988,7 +989,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:259',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
},
{
key: 'conversation.hero.agentPreset',
@@ -1029,7 +1030,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:291',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
},
{
key: 'conversation.hero.brand.mark',
@@ -1061,7 +1062,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:289',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296',
},
{
key: 'conversation.hero.frame',
@@ -1108,7 +1109,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'wraps-owner-content',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.frame\', () => ctx.slots.register(\n { name: \'conversation.hero.frame\', id: \'my-layer\', order: 100 },\n ({ children }) => React.createElement(\'div\', null, children),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:287',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
},
{
key: 'conversation.hero.options',
@@ -1168,7 +1169,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:293',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300',
},
{
key: 'conversation.hero.workspace',
@@ -1204,7 +1205,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -1284,7 +1285,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:324',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:331',
},
{
key: 'conversation.input.attachments',
@@ -1329,7 +1330,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:334',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:341',
},
{
key: 'conversation.input.dock',
@@ -1398,7 +1399,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:305',
},
{
key: 'conversation.input.left',
@@ -1456,7 +1457,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:313',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:320',
},
{
key: 'conversation.input.model',
@@ -1500,7 +1501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:355',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:362',
},
{
key: 'conversation.input.model.label',
@@ -1666,7 +1667,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:303',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:310',
},
{
key: 'conversation.input.permission',
@@ -1710,7 +1711,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:348',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:355',
},
{
key: 'conversation.input.plan',
@@ -1753,7 +1754,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:343',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:350',
},
{
key: 'conversation.input.right',
@@ -1811,7 +1812,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:318',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:325',
},
{
key: 'conversation.plan-review.actions',
@@ -1914,7 +1915,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:224',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231',
},
{
key: 'conversation.session.header',
@@ -1955,7 +1956,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:232',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:239',
},
{
key: 'conversation.session.header.actions',
@@ -2019,7 +2020,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:247',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:254',
},
{
key: 'conversation.session.header.actions.subagent.footer',
@@ -2122,7 +2123,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:277',
},
{
key: 'conversation.session.header.lineage',
@@ -2167,7 +2168,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:241',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:248',
},
{
key: 'conversation.session.header.utilities',
@@ -2225,15 +2226,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-open-in-app SessionOpenInAppAction id \'open-in-app\'',
'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'',
'experimental-client-ui-forge HeaderButton id \'forge-panel\'',
'experimental-liveshare LiveshareShareAction id \'liveshare-share\'',
'session-log-export SessionLogDownloadHeaderAction id \'session-log-download\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:253',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
},
{
key: 'conversation.session.header.views.actions',
@@ -2288,7 +2287,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.views.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.views.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283',
},
{
key: 'conversation.trajectory.images',
@@ -2392,7 +2391,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:221',
},
{
key: 'conversation.view',
@@ -2458,7 +2457,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285',
},
{
key: 'deliverables.file.actions',
@@ -2667,7 +2666,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:222',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229',
},
{
key: 'plugins.add.actions',
@@ -3957,6 +3956,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'experimental-client-ui-forge ActionToast id \'forge-panel.toast\'',
'cua PermissionToast id \'cua-permission-outcome\'',
'session-log-export SessionMarkdownCopyToast id \'session-log-download.copy-toast\'',
'session-log-export SessionLogDownloadDialog id \'session-log-download\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -88,7 +88,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-client-ui-layout` | no | Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels) |
| `@deepseek-ai/dsh-client-ui-message-feedback` | no | The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes |
| `@deepseek-ai/dsh-client-ui-model-selection` | no | Model selection over the shared model catalog, Session projection, and session.selectModel |
| `@deepseek-ai/dsh-client-ui-open-in-app` | no | Web "Open In..." controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file |
| `@deepseek-ai/dsh-client-ui-open-in-app` | no | Session application submenus and native opening controls for directories and previewed files |
| `@deepseek-ai/dsh-client-ui-permission-presets` | no | Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection |
| `@deepseek-ai/dsh-client-ui-plan` | no | Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews |
| `@deepseek-ai/dsh-client-ui-plugin-manager` | yes | Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages |
@@ -18,8 +18,8 @@
en: bb12bdeafa8571b5
zh: e7df75c03478f7ed
/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown:
en: 55c163a4ae5b8d5d
zh: c1dac154ed1fa56d
en: 259be867138d8d92
zh: 984959dd966168bf
/deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it:
en: 4011f686afd24f09
zh: 2710b9995c80ba1f
@@ -27,8 +27,8 @@
en: 252bc78a7850557a
zh: e695658a94c8c2bf
/deepseek-ai-dsh-session-log-export/use-this-package/composition:
en: 4168a2d3e653ab73
zh: 8bf955cfab7b08e2
en: dc562975a59e0175
zh: e8b0964a00ffef69
/deepseek-ai-dsh-session-log-export/use-this-package/configuration:
en: 377a0d8860c46f63
zh: 9eb7698f813b2fa3
@@ -36,8 +36,8 @@
en: 26329688b9a924a7
zh: 94e21c10fdcfc1ac
/deepseek-ai-dsh-session-log-export/use-this-package/what-to-expect:
en: 44be1f1d1903d12a
zh: c8f1eb5b92b71a30
en: e4ad198e0e410fbf
zh: 06a815208987d006
/deepseek-ai-dsh-session-log-export/use-this-package/failures:
en: e92a382bebfe99e3
zh: 671178b06808bfb9
@@ -45,11 +45,11 @@
en: 7cb82a6851405f95
zh: b8c525170d19dc6c
/deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split:
en: 3bb8e18b107c353f
zh: 3cc45ce2b5699066
en: a1a931677dabc1aa
zh: d4bd10bde23c14e9
/deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow:
en: bd46d2fa1273a6a4
zh: 4d3eb2cf0a9bf12b
en: 00002fe0448b1654
zh: 9d8f88e42d88fd15
/deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow:
en: d4e02b56ffb8ee8a
zh: 0a329039788a13ee
@@ -33,9 +33,9 @@ Use this package when the Web bundle should let users export a session log. It r
The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline.
The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back.
The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header menu disables its copy row until the operation settles; unrelated actions remain available. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back.
When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently.
When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin’s availability; both export actions remain independently available. [`ui-open-in-app`](../../client/ui-open-in-app/README.md) contributes its application submenu in a separated group after download and before feedback.
### When to choose it
@@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?:
name: '@deepseek-ai/dsh-session-log-export'
```
The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface.
The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, `dsh-client-ui-context-menu`, and `dsh-client-ui-layout`. The conversation package owns the Header trigger, the context-menu service renders its shared popup, and layout declares `shell.overlay` for the download dialog and copy-feedback toast. Locale and command services supply translated copy and command acknowledgements.
### Configuration
@@ -78,7 +78,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u
### What to expect
The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full.
The root dialog identifies the exported Session by ID and reports preparing, download started, or failed. Switching conversations keeps it visible. Different Sessions download concurrently, while their dialogs appear one at a time in initiation order; closing one reveals the next open dialog. Closing does not cancel the download or reopen its dialog on settlement. Repeated requests for the same in-flight Session share its operation and keep its original position; a new request after settlement takes a new position. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full.
Attachment collection reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields remain unchanged in the exported log but do not cause attachment reads.
@@ -100,11 +100,11 @@ This section explains how the package wires the export control and points at the
The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download.
The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control.
The shared context-menu package declares the captured `sessionHeader` target, independent of Session-row and title context actions; the conversation package owns its Header trigger. This package contributes export and optional feedback rows that observe controller, availability, and locale sources. Its download dialog and copy toast mount in the root overlay independently of the Header lifetime.
### Download flow
Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. The root component selects the earliest open entry from the controller’s Session-keyed snapshot using each operation’s initiation order; no separate dialog queue is maintained. Disposal waits for all preflights to settle and prevents a late response from starting a browser save; a download already handed to the browser remains browser-owned.
The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics.
@@ -33,9 +33,9 @@ kind: "package-reference"
格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。
剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=<id>` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。
剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=<id>` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 菜单在复制操作结束前禁用各自的复制行;无关操作保持可用。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。
挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。
挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。[`ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 在下载之后、反馈之前的独立分隔组中贡献应用子菜单。
### 何时选择
@@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?:
name: '@deepseek-ai/dsh-session-log-export'
```
Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。
Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation`、`dsh-client-ui-context-menu` 和 `dsh-client-ui-layout` 一起挂载。conversation 包拥有 Header 入口,右键菜单服务渲染其共享菜单,layout 声明下载弹窗与复制反馈浮层使用的 `shell.overlay` 插槽。locale 与命令服务提供翻译文案和命令确认。
### 配置
@@ -78,7 +78,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
### 预期行为
弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。
根级弹窗通过 ID 标识导出的 Session,并报告准备中、开始下载或失败。切换会话后弹窗仍然保留。不同 Session 可以并行下载,弹窗按发起顺序一次显示一个;关闭后显示下一个未关闭的弹窗。关闭不会取消下载,也不会在操作结束时重新打开弹窗。同一 Session 下载进行中的重复请求共用该操作并保留原位置;操作结束后的新请求取得新的位置。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。
附件收集读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段在导出日志中保持不变,但不会触发附件读取。
@@ -100,11 +100,11 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。
Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。
共享右键菜单包声明被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作;conversation 包拥有其 Header 入口。本包贡献导出及可选反馈行,观察控制器、可用性与语言源。下载弹窗和复制反馈浮层挂载在根 overlay 中,独立于 Header 生命周期。
### 下载流程
两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。根组件依据每次操作的发起顺序,从控制器按 Session 键控的快照中选择最早的未关闭条目,不维护独立弹窗队列。释放会等待所有预检结束,防止迟到响应启动浏览器保存;已经交给浏览器的下载仍由浏览器管理。
Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。
@@ -57,12 +57,12 @@
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-commands": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
"@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-commands": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
@@ -76,10 +76,10 @@
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-commands",
"@deepseek-ai/dsh-client-ui-context-menu",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session"
"@deepseek-ai/dsh-client-ui-renderer"
],
"platform": "web"
}

Some files were not shown because too many files have changed in this diff Show More