diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index ff756d1b95..32efedf65c 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -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' diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 3a0b48db93..328bdb9397 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -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 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index d1e5f18fa1..143814ea79 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -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. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 0ff96d3ba3..e4e290f581 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -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)。 diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts index 49d438aaf2..a9e86575fd 100644 --- a/apps/desktop/scripts/desktop-publication-summary.ts +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -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!)}
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []), row('下载地址预览', `${installers.map(file => link(url(file.key))).join('
')}
发布后的目标地址,审批前不保证可下载。`), diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index 77ac3ca698..7ef28cac0c 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -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 { 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 { 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 diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e6a8d4c7f0..6a9cfd1183 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -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 }), } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f881e0d48a..e3d14abbff 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -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' diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 04402925a5..dd6e2e7f41 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -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 }) } }) diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts index 87646edbe1..287af3e4a2 100644 --- a/apps/desktop/tests/desktop-publish-record.spec.ts +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -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 }) } }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index 18f3fa18fa..b6f81d69df 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -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) }) diff --git a/apps/desktop/tests/expected/publication-mac-arm64.md b/apps/desktop/tests/expected/publication-mac-arm64.md index 7b376d9d65..f975154887 100644 --- a/apps/desktop/tests/expected/publication-mac-arm64.md +++ b/apps/desktop/tests/expected/publication-mac-arm64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | mac-arm64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/mac-arm64/deepseek-harness-1.0.0-mac-arm64.dmg
发布后的目标地址,审批前不保证可下载。 | diff --git a/apps/desktop/tests/expected/publication-production-website.md b/apps/desktop/tests/expected/publication-production-website.md index 464650c653..4ba276895c 100644 --- a/apps/desktop/tests/expected/publication-production-website.md +++ b/apps/desktop/tests/expected/publication-production-website.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | diff --git a/apps/desktop/tests/expected/publication-production.md b/apps/desktop/tests/expected/publication-production.md index a3b93ccb87..716e499d5e 100644 --- a/apps/desktop/tests/expected/publication-production.md +++ b/apps/desktop/tests/expected/publication-production.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | diff --git a/apps/desktop/tests/expected/publication-win-x64.md b/apps/desktop/tests/expected/publication-win-x64.md index aa7569c0a2..65684cf866 100644 --- a/apps/desktop/tests/expected/publication-win-x64.md +++ b/apps/desktop/tests/expected/publication-win-x64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/win-x64/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 78d959375b..9b2c2b650f 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -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 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) diff --git a/apps/web/tests/context-menus.overlay.yml b/apps/web/tests/context-menus.overlay.yml new file mode 100644 index 0000000000..ca2581fa55 --- /dev/null +++ b/apps/web/tests/context-menus.overlay.yml @@ -0,0 +1,4 @@ +# Context gestures do not query the runner's native file associations. +- id: session-controller + config: + nativeOpen: false diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt index 5468d20e51..ac477e2304 100644 --- a/apps/web/tests/expected/context-menus/session.txt +++ b/apps/web/tests/expected/context-menus/session.txt @@ -2,5 +2,4 @@ Pin session Rename Fork session Copy Session ID -Show in folder Archive session diff --git a/apps/web/tests/expected/context-menus/ssh-header.txt b/apps/web/tests/expected/context-menus/ssh-header.txt new file mode 100644 index 0000000000..00155b9379 --- /dev/null +++ b/apps/web/tests/expected/context-menus/ssh-header.txt @@ -0,0 +1,5 @@ +- menu: + - menuitem "Copy Session as Markdown" + - menuitem "Download session log" + - separator + - menuitem "Feedback" diff --git a/apps/web/tests/expected/open-in-app-menus/global-export.expected.md b/apps/web/tests/expected/open-in-app-menus/global-export.expected.md new file mode 100644 index 0000000000..b1e0c986d9 --- /dev/null +++ b/apps/web/tests/expected/open-in-app-menus/global-export.expected.md @@ -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" diff --git a/apps/web/tests/expected/session-row-hover/actions.expected.json b/apps/web/tests/expected/session-row-hover/actions.expected.json index 2423722518..02224a309f 100644 --- a/apps/web/tests/expected/session-row-hover/actions.expected.json +++ b/apps/web/tests/expected/session-row-hover/actions.expected.json @@ -7,7 +7,9 @@ { "stage": "more-hover", "details": true, - "tooltips": [] + "tooltips": [ + "Session actions for Use the read tool twice" + ] }, { "stage": "archive-before-dwell", diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index 6724e2373d..f3836b2d55 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -4,6 +4,6 @@ - menuitem "Fork session" - separator - menuitem "Copy Session ID" - - menuitem "Show in Finder" + - menuitem "Open in" - separator - menuitem "Archive session" diff --git a/apps/web/tests/open-in-app-menus.e2e.ts b/apps/web/tests/open-in-app-menus.e2e.ts new file mode 100644 index 0000000000..c7c0bff288 --- /dev/null +++ b/apps/web/tests/open-in-app-menus.e2e.ts @@ -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 = '' +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 { + 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 { + 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, +): Promise { + 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() + const settled = Promise.withResolvers() + 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() + 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(':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((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((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) + } + }) +}) diff --git a/apps/web/tests/open-in-app-no-export.overlay.yml b/apps/web/tests/open-in-app-no-export.overlay.yml new file mode 100644 index 0000000000..3503bab635 --- /dev/null +++ b/apps/web/tests/open-in-app-no-export.overlay.yml @@ -0,0 +1,3 @@ +# Header ownership must not depend on the optional export contributor. +- id: session-log-download + disabled: true diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index ff57285d88..52ca0d0f27 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -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']) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 096c159be4..593bb26b86 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -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') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fb40d60bb7..ec47d94afe 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml new file mode 100644 index 0000000000..bfb46f8f3d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md new file mode 100644 index 0000000000..cc694a6a7f --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md @@ -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. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md new file mode 100644 index 0000000000..6c410f8e81 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md @@ -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 后仍然保留。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 9888957fd5..921d71cd23 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -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 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index fb307f3e6a..5808cf01be 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -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: diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml index 1f54beccbb..4c2006bb68 100644 --- a/packages/client/ui-context-menu/README.i18n.yaml +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md index dff6ea78cc..49a558e69b 100644 --- a/packages/client/ui-context-menu/README.md +++ b/packages/client/ui-context-menu/README.md @@ -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. diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md index 78f154feae..6296443b82 100644 --- a/packages/client/ui-context-menu/README.zh.md +++ b/packages/client/ui-context-menu/README.zh.md @@ -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。 diff --git a/packages/client/ui-context-menu/src/client/Button.tsx b/packages/client/ui-context-menu/src/client/Button.tsx new file mode 100644 index 0000000000..468122b889 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/Button.tsx @@ -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 + 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) => { event.stopPropagation() }, + onClick: (event: MouseEvent) => { + event.stopPropagation() + event.currentTarget.focus({ preventScroll: true }) + const rect = event.currentTarget.getBoundingClientRect() + toggle(id, target, { x: rect.left, y: rect.bottom }) + }, + } + return + {size === 'sm' + ? } + +} diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx index cf6c3760af..81d06653a1 100644 --- a/packages/client/ui-context-menu/src/client/View.tsx +++ b/packages/client/ui-context-menu/src/client/View.tsx @@ -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> - & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace + & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace & 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 <> !item.afterContent), catalog, renderSlot)} diff --git a/packages/client/ui-context-menu/src/client/external-trigger.ts b/packages/client/ui-context-menu/src/client/external-trigger.ts new file mode 100644 index 0000000000..f56835dc2c --- /dev/null +++ b/packages/client/ui-context-menu/src/client/external-trigger.ts @@ -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 +} diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts index ac4fc288be..b4244c4153 100644 --- a/packages/client/ui-context-menu/src/client/index.ts +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -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(null) + // Instance id of the shared button whose activation opened the current popup. + const opener = createSnapshotStore(null) let sequence = 0 let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined let closeTimer: ReturnType | 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((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((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)) } diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts index d8b33a422e..f44a8f96c3 100644 --- a/packages/client/ui-context-menu/src/client/locales.ts +++ b/packages/client/ui-context-menu/src/client/locales.ts @@ -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 + 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 diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts index 3725bb9fed..af89692538 100644 --- a/packages/client/ui-context-menu/src/client/registry.ts +++ b/packages/client/ui-context-menu/src/client/registry.ts @@ -14,6 +14,9 @@ interface Provider { /** React-free registry; each occurrence captures the providers registered at opening. */ export class MenuRegistry { + private readonly kindsSource = createSnapshotStore([]) + /** Registered target kinds, independent of asynchronous action availability. */ + readonly registeredKinds: ObservableSnapshot = this.kindsSource private readonly providers = new Map() 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) } } diff --git a/packages/client/ui-context-menu/src/client/target-key.ts b/packages/client/ui-context-menu/src/client/target-key.ts index 4304d2d617..27d982388d 100644 --- a/packages/client/ui-context-menu/src/client/target-key.ts +++ b/packages/client/ui-context-menu/src/client/target-key.ts @@ -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' /** diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts index d32a788fb4..f21dc165ff 100644 --- a/packages/client/ui-context-menu/src/client/types.ts +++ b/packages/client/ui-context-menu/src/client/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 + /** Target kinds with registered contributions; updates synchronously on registration and withdrawal, without resolving actions. */ + readonly registeredKinds: ObservableSnapshot /** * 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(contribution: MenuContribution): () => 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. diff --git a/packages/client/ui-context-menu/tests/button.client.spec.tsx b/packages/client/ui-context-menu/tests/button.client.spec.tsx new file mode 100644 index 0000000000..01c4a5d7b5 --- /dev/null +++ b/packages/client/ui-context-menu/tests/button.client.spec.tsx @@ -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(() => {})) + 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
+ {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' })} +
Right-click target
+ {renderSlot('shell.overlay', {})} +
+ }) + 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) +}) diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx index 54194a811b..42268256b9 100644 --- a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx +++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx @@ -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(<> - select([])} useStore={useStore} actions={store.actions} close={close} select={run} + 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 { @@ -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( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} + const view = render( 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( 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') diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts index afd3d97350..4d2ab784ba 100644 --- a/packages/client/ui-context-menu/tests/plugin.client.spec.ts +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -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['create']> - ctx.provide('slots', { provideRoot: () => () => {}, + ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => { 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['create']> - ctx.provide('slots', { provideRoot: () => () => {}, + ctx.provide('slots', { provideRoot: () => () => {}, registerFactory: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { store: ReturnType }) => { 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[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() }) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts index c533f3b49d..20f77e808b 100644 --- a/packages/client/ui-context-menu/tests/registry.client.spec.ts +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts' import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { - interface 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() { } 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 }] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5821391a8..c88fda7554 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d9c67692b0..041aa0d5d4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c9d1b93019..0ac0d779d1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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 不会激活。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5ce7e7efb7..e7ae7285ab 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 21446d59a2..dd4bf49ee3 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index fa1c3a3722..39d6f62fef 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index cc309bbfab..83b3af7029 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -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({
{renderSlot('conversation.session.header.utilities', {})} + {renderFactorySlot('contextMenu.button', { + target: { kind: 'sessionHeader', value: { sessionId } }, + label: t('header.more'), className: css.headerMenuButton, size: 'sm', + })}
)} diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index ff9f49b18c..2a099a7d7b 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -191,6 +191,7 @@ function mount( const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] + const headerFactory = vi.fn(() => 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() 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() diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 16367c590b..cfc7291505 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index c2fb730478..9b0f75963b 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -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- ## 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
Implementation internals — click to expand -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 [` ## 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 diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 04aa5edc1e..7bac1ae79f 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -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" ## 使用本包 -把本插件与 [`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"
实现内幕——点击展开 -两个目录插槽均通过显式的 `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" ## 模型体验 -无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。 +无。这些打开控件与菜单是浏览器界面;不会进入模型请求。 #### KV Cache 影响 diff --git a/packages/client/ui-open-in-app/locale/zh.json b/packages/client/ui-open-in-app/locale/zh.json index 078d8a6924..d167d8c088 100644 --- a/packages/client/ui-open-in-app/locale/zh.json +++ b/packages/client/ui-open-in-app/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "外部应用打开界面", - "description": "在会话和文档预览中提供「打开方式」入口。" + "description": "在应用中打开会话目录或预览文件。" } } diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 58b7c79d3a..3bd04d8d10 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -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" diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index ceb84fc611..cdbf77d3b6 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -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 diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index 2f6fb14295..5810a3aab7 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -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, 'icon'>) { - return icon.src ? : + const [failedSource, setFailedSource] = useState() + return icon.src && icon.src !== failedSource + ? { setFailedSource(icon.src) }} /> + : } diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index c2e25085e8..bd43f8af7e 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -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 /** * 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) } diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 49e5fe25e4..e706f9eb25 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -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, - ) { - 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, diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index bd87413a4e..d4d3c48548 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -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 = { + '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', diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts new file mode 100644 index 0000000000..7119958e9e --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts @@ -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`) + } +} diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx deleted file mode 100644 index 0dcf0c3a86..0000000000 --- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx +++ /dev/null @@ -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') -} diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index e0457da73f..ca27f69e50 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -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 = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!() + const face: Partial = 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, 'sessionId' | 'useSessions' - >> - const id = 'main' as SessionId - const props = { sessionId: id, useSessions: (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 | 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 | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action') const { hooks } = injected diff --git a/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts new file mode 100644 index 0000000000..b8db4a0154 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts @@ -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({ 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() + 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) +}) diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts deleted file mode 100644 index 1638722be5..0000000000 --- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts +++ /dev/null @@ -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({ 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' }) }) - } -}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a73e6e2d85..da586b0139 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 46a29dfee6..c7ffd2e5c8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A disabled `MenuItemButton` with a `disabledReason` stays hoverable and focusable (`aria-disabled`) but cannot be activated; it shows the reason in a `Tooltip` and exposes it as the row's description. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Shift+Tab closes back to the anchor; Escape returns from an open submenu before closing the root. Selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A disabled `MenuItemButton` with a `disabledReason` stays hoverable and focusable (`aria-disabled`) but cannot be activated; it shows the reason in a `Tooltip` and exposes it as the row's description. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | @@ -97,7 +97,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` or `Medium`. Line artwork uses stroke widths of 1 and 1.3 SVG user units respectively; the `size` prop controls rendered dimensions. Strokes scale with the viewport: a 1-unit stroke in a 16-unit view box renders at 0.875 CSS px at 14px and 0.75 CSS px at 12px, before further transforms. The renderer does not apply non-scaling strokes; filled contours scale as geometry and do not respond to stroke-width changes. Shared menus render icons at 14px, or 12px in compact lists, through `Menu.module.css`; these are rendered sizes, not fixed stroke widths. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A submenu-free portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` or `Medium`. Line artwork uses stroke widths of 1 and 1.3 SVG user units respectively; the `size` prop controls rendered dimensions. Strokes scale with the viewport: a 1-unit stroke in a 16-unit view box renders at 0.875 CSS px at 14px and 0.75 CSS px at 12px, before further transforms. The renderer does not apply non-scaling strokes; filled contours scale as geometry and do not respond to stroke-width changes. Shared menus render icons at 14px, or 12px in compact lists, through `Menu.module.css`; these are rendered sizes, not fixed stroke widths. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredMaxHeight` keeps bottom-anchored overlays within the visible viewport; an oversized panel keeps the near margin instead of sliding off that edge. A portalled `Menu` caps its card height against the layout viewport, with its pinned footer outside the inner scrolling region; child cards use their own body portal, so a scrolling parent never clips them. `HoverCard` adds 20px to the frame's published top clearance, or keeps 20px in native fullscreen, while preserving its own minimum margin in ordinary browsers; while an anchor is inside that top strip, the card is never pushed lower than the anchor's bottom edge plus its gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists use CSS Anchor Positioning to follow their trigger and constrain their dimensions to the layout viewport. Every placement candidate reserves 12px layout-viewport margins and uses the frame overlay inset as its minimum top edge; pointer-positioned contextual menus keep 12px at the top. When the preferred placement cannot fit, CSS tries a horizontal adjustment or the opposite side before using a window-bounded fallback. Browser zoom and panning move the menu with the page; it may extend beyond the visual viewport, including while the keyboard is open. Internal scrolling, dismissal, and focus restoration remain independent. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. @@ -142,10 +142,14 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry. -Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. +Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. `isExternalTrigger(node)` lets the caller identify external controls whose pointer presses must reach its toggle handler without outside dismissal. Without this prop, presses outside the anchor and menu panels dismiss, including presses on `returnFocus` targets. `returnFocus` alone only controls focus restoration. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. `Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Tall portal menus retain the trigger’s horizontal alignment while using the window’s vertical scrolling space; horizontal clamping applies only when the card cannot fit beside that edge. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. +Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and uses the root menu's visual-viewport limits and WebKit coordinate normalization with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. + +`useMenuSubmenu` owns child state, focus transitions, and pointer grace; `SubmenuPanel` renders the portal, and `MenuRow` supplies the shared component, data, and child row presentation. Root document listeners use the hook's containment, active-list, and return operations. Child cards use CSS Anchor Positioning against an unscrolled box inside the parent card that matches the card. A side card opens to the parent's right with a 4px gap and aligns its top edge with the active row; when it does not fit, CSS tries the left side. The hook measures the row's offset within the parent card and the child's natural height on opening, window resize, list scrolling, and observed size changes; the card moves up only as far as needed to stay within the layout viewport, keeping 12px margins and the frame top clearance. `submenuNeedsPage` compares the child's natural width with both sides; when neither fits, an activated child replaces the parent page at the parent's position and at least its width. Pinch zoom moves child cards with the page, as it does parent menus. + `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f3d36cb06d..f90076add7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,7 +49,7 @@ kind: "package-library" | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | | `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 带 `disabledReason` 的禁用 `MenuItemButton` 仍可悬停和聚焦(`aria-disabled`),但不能激活;它在 `Tooltip` 中显示原因,并把原因作为该行的描述。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Shift+Tab 关闭并把焦点还给锚点;Escape 先退出已打开的子菜单,再关闭根菜单。选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 带 `disabledReason` 的禁用 `MenuItemButton` 仍可悬停和聚焦(`aria-disabled`),但不能激活;它在 `Tooltip` 中显示原因,并把原因作为该行的描述。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | @@ -97,7 +97,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 或 `Medium` 结尾。线条图形分别使用 1 和 1.3 SVG 用户单位的描边宽度;`size` prop 控制渲染尺寸。描边随视口缩放:16 单位 viewBox 中宽度为 1 的描边,在 14px 下为 0.875 CSS px,在 12px 下为 0.75 CSS px,尚未计入额外变换。渲染器不使用非缩放描边;填充轮廓随几何缩放,不受 stroke-width 变化影响。共享菜单通过 `Menu.module.css` 将图标渲染为 14px,紧凑列表为 12px;这些是渲染尺寸,不是固定描边宽度。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。无子菜单的 portal 模式 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 或 `Medium` 结尾。线条图形分别使用 1 和 1.3 SVG 用户单位的描边宽度;`size` prop 控制渲染尺寸。描边随视口缩放:16 单位 viewBox 中宽度为 1 的描边,在 14px 下为 0.875 CSS px,在 12px 下为 0.75 CSS px,尚未计入额外变换。渲染器不使用非缩放描边;填充轮廓随几何缩放,不受 stroke-width 变化影响。共享菜单通过 `Menu.module.css` 将图标渲染为 14px,紧凑列表为 12px;这些是渲染尺寸,不是固定描边宽度。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredMaxHeight` 将底部锚定浮层限制在可见视口内;过大的面板保留近端边距,不会滑出该边缘。portal 模式的 `Menu` 按布局视口限制卡片高度,固定页脚位于内部滚动区域之外;子卡片使用各自的 body portal,父级滚动不会裁剪它们。`HoverCard` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,卡片不会被推到低于锚点底边加自身间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 通过 CSS Anchor Positioning 跟随触发元素,并按布局视口限制尺寸。每个候选位置都保留 12px 布局视口边距,并将框架浮层起点作为顶部下限;按指针定位的上下文菜单顶部保留 12px。首选位置放不下时,CSS 尝试横向调整或相反方向,最后使用窗口内的回退位置。浏览器缩放和平移时,菜单随页面移动,可以超出视觉视口,包括键盘打开时。内部滚动、菜单关闭和焦点恢复保持独立。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 @@ -142,10 +142,14 @@ kind: "package-library" `Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。 -锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 +锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。调用方通过 `isExternalTrigger(node)` 指定外部控件,使其指针按下事件不触发外部关闭,而由自己的切换处理器处理。省略该 prop 时,锚点与菜单面板之外的按下事件都会关闭菜单,包括 `returnFocus` 目标上的按下事件。单独设置 `returnFocus` 只控制焦点恢复。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 `Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。较高的 portal 菜单使用窗口内的纵向滚动空间时,仍保持与触发元素的横向对齐;只有卡片在该侧放不下时才调整横向位置。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 +子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并采用根菜单相同的 visual viewport 限制与 WebKit 坐标归一化规则,在内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 + +`useMenuSubmenu` 持有子级状态、焦点转换与指针宽限;`SubmenuPanel` 渲染 portal,`MenuRow` 统一组件行、数据行与子行的展示。根菜单文档监听通过 hook 的包含判定、当前列表与返回操作交互。子卡片以父卡片内一个不随列表滚动、与卡片同尺寸的锚点框为锚点,使用 CSS Anchor Positioning 定位。侧开卡片默认在父卡片右侧打开,间距 4px,顶边与当前父行对齐;放不下时 CSS 尝试左侧。hook 在打开、窗口尺寸变化、列表滚动和观察到尺寸变化时测量该行在父卡片内的偏移与子卡片的自然高度;卡片只在需要时上移,以留在布局视口内,并保留 12px 边距和框架顶部避让。`submenuNeedsPage` 比较子卡片的自然宽度与两侧空间;两侧都放不下时,激活的子菜单以单面板形式替换父页面,位于父卡片的位置,宽度至少与父卡片相同。双指缩放时子卡片和父菜单一样随页面移动。 + `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 31fc8a1939..34625df712 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -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'], diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index d433e02be2..47b31bb407 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -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 ( -
- {separatorBefore &&
} - - - - {explained && } -
- ) -} - -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 { - const aligned = new Set() - 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 } /** @@ -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(null) + const submenu = useMenuSubmenu({ open: open && exiting === undefined, items, footer, listRef, refocusAnchor }) + const submenuEvents = useRef>(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('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('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) => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } + 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 ( -
{ setOpenSubmenuId(entry.id) } : undefined} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( - - {entry.submenu.map(sub => isSeparator(sub) - ?
- : isLabel(sub) ?
{sub.text}
: ( - - ))} - - )} -
- ) - } - - // 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): 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 { + if (!menuHoverAllowed(event) || entry.disabled) return + const trigger = event.currentTarget.querySelector('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} } const list = open && positioned && ( { 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 &&