From af6f68064f180531784f306decfb8c0e142f5c40 Mon Sep 17 00:00:00 2001 From: Jianghan <58623049+SuperstructureJH@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:25:14 +0800 Subject: [PATCH] fix(session): localize deletion confirmation dialog --- docs/STATUS.md | 11 ++ packages/dsh-desktop-client-ui/client.js | 106 +++++++++--- test/desktop-client-ui.test.ts | 1 + test/session-delete-locale.test.mjs | 208 +++++++++++++++++++++++ 4 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 test/session-delete-locale.test.mjs diff --git a/docs/STATUS.md b/docs/STATUS.md index cba720ac6..9efce5870 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,3 +1,14 @@ +## macOS 删除会话确认弹窗中文化 — 2026-10-29 + +- 基线:远端 `dataelement/dsh-desktop:main@7eac110fd516908771e4026bdb46a5ac92eae456`,包版本 `0.12.1`,实际安装 Harness `0.2.1-rc.3` / Electron `43.0.0`。远端 `v0.9.0` / `V0.9.0` 查询结果为空;本次沿用已定位问题的社区当前主分支。独立工作树 `.worktrees/dsh-desktop-session-delete-locale`,分支 `codex/session-delete-locale`;历史 v0.9.0 集成目录工作区干净。 +- 根因:删除入口向 `window.confirm` 传入本地化正文,Electron 为确认框生成固定的 `OK` / `Cancel` 按钮。现由现有 `shell.overlay` 根级列表出口承载 Harness `Modal`,菜单、正文、按钮、进度和失败提示统一使用 locale 服务;删除接口继续使用 `uiWorkspace.deleteSession`。 +- 菜单关闭后先归还焦点,再打开确认框;默认焦点位于“取消”。删除期间锁定重复提交与关闭,失败保留错误及重试入口;插件卸载清理请求与订阅,迟到结果仅对有效请求生效。 +- 扩展契约:复用 `shell.overlay`(list / root)与 `sidebar.workspaces.session.menu.item`,每个插件实例维护一份删除请求;原菜单关闭、会话行卸载后,根级弹窗继续管理请求。产品依赖、原生权限与第三方补丁沿用基线。 +- 自动验证:新增 `test/session-delete-locale.test.mjs` 的 8 项行为回归通过,覆盖中英文、应用语言与页面语言差异、打开后的语言切换、标题安全渲染、菜单卸载、焦点、取消、进行中锁定、同步/异步失败重试与插件卸载。旧代码运行这 8 项回归全部失败;`test/desktop-client-ui.test.ts` 同步增加根级弹窗注册断言。相关 23 项回归、类型检查、生产构建及差异检查通过。 +- 完整回归:最终运行 2,846 项通过、5 项条件跳过,Office 预览导出 1 项超时;上一轮出现 Excel 重算失败。包含这两组 Office 测试及全部删除会话相关测试的最终串行复测 46 项全部通过。 +- macOS 验证:使用临时用户目录的 Electron 43.0.0 / arm64 窗口,加载本次插件及已安装的真实 Menu / Modal / Button 实现,验证中文按钮、会话标题、取消、Tab 焦点约束、Esc、焦点返回、英文切换、删除中锁定、失败重试及成功关闭。后端删除使用受控替身;社区用户安装包、真实会话及 Windows 验收待执行。 +- 本地证据保存在总工作区 `outputs/dsh-session-delete-locale-20261029/`。本次交付为本地代码与验证,发布和安装包更新待后续安排。 + ## Windows 导入苹方 PPT 的字体与字重解析 — 2026-10-15 - 基线:核实远端 `V0.12.0@8ade5b7cc3d391fe881dd2af64a8648e771653f5`;工作树 `codex/windows-ppt-font-layout`,Harness `0.2.0-rc.2`、Kit `0.1.3`、Electron `43.0.0`。 diff --git a/packages/dsh-desktop-client-ui/client.js b/packages/dsh-desktop-client-ui/client.js index 80afdf4cf..a14f81717 100644 --- a/packages/dsh-desktop-client-ui/client.js +++ b/packages/dsh-desktop-client-ui/client.js @@ -6,7 +6,7 @@ window.__ModuleLoader__.load({ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) const React = require('react') - const { BrandWordmark, FishLogo, MenuItemButton, Tooltip } = require('@deepseek-ai/dsh-client-ui-primitives') + const { BrandWordmark, FishLogo, MenuItemButton, Tooltip, Modal, Button } = require('@deepseek-ai/dsh-client-ui-primitives') // Tight bounds of the mark inside its 1000x1000 source artwork. const BRAND_MARK_VIEWBOX = { x: 42, y: 218, width: 898, height: 564 } @@ -59,23 +59,73 @@ window.__ModuleLoader__.load({ ) } - function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, deleteSession }) { + const DELETE_SESSION_NS = 'dsh-desktop-delete-session' + + // The shell owns the request: closing a row menu unmounts its occupants. + function createSessionDeletion(deleteSession) { + let state = null + let active = true + const listeners = new Set() + const publish = (next) => { + state = next + listeners.forEach(listener => listener()) + } + return { + subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) }, + getSnapshot: () => state, + request: (sessionId, displayTitle) => { + if (active && state === null) publish({ sessionId, displayTitle, busy: false, error: null }) + }, + close: () => { if (state && state.busy === false) publish(null) }, + confirm: async () => { + if (state === null || state.busy || active === false) return + const pending = { ...state, busy: true, error: null } + publish(pending) + try { + await deleteSession(pending.sessionId) + if (active && state === pending) publish(null) + } catch (reason) { + if (active && state === pending) { + publish({ ...pending, busy: false, error: reason instanceof Error ? reason.message : String(reason) }) + } + } + }, + dispose: () => { active = false; publish(null); listeners.clear() } + } + } + + function DeleteSessionMenuItem({ sessionId, displayTitle, useMenuOpenState, deletion, t }) { const [, setMenuOpen] = useMenuOpenState() - const chinese = document.documentElement.lang?.toLowerCase().startsWith('zh') - const label = chinese ? '永久删除会话' : 'Delete session permanently' - const warning = chinese - ? `确定删除“${displayTitle || sessionId}”?工作区文件会保留。此操作无法撤销。` - : `Delete “${displayTitle || sessionId}”? Workspace files are kept. This can’t be undone.` return React.createElement(MenuItemButton, { danger: true, onSelect: () => { setMenuOpen(false) - if (!window.confirm(warning)) return - void Promise.resolve().then(() => deleteSession(sessionId)).catch((reason) => { - window.alert(reason instanceof Error ? reason.message : String(reason)) - }) + // Let Menu restore its trigger focus before Modal captures return focus. + setTimeout(() => deletion.request(sessionId, displayTitle), 0) } - }, label) + }, t('menu')) + } + + function DeleteSessionDialog({ deletion, t }) { + const request = React.useSyncExternalStore(deletion.subscribe, deletion.getSnapshot) + return React.createElement(Modal, { + open: request !== null, + onClose: deletion.close, + title: t('title'), + closeLabel: t('close'), + description: request ? t('description', { name: request.displayTitle || request.sessionId }) : '', + footer: request && React.createElement(React.Fragment, null, + React.createElement(Button, { + variant: 'outline', disabled: request.busy, onClick: deletion.close, + 'data-modal-autofocus': true + }, t('cancel')), + React.createElement(Button, { + variant: 'primary', disabled: request.busy, onClick: deletion.confirm + }, t(request.busy ? 'pending' : 'confirm'))) + }, request?.busy && React.createElement('p', { role: 'status' }, t('pending')), + request && request.error !== null && React.createElement('div', { role: 'alert' }, + React.createElement('p', null, t('failed')), + React.createElement('p', null, request.error))) } function OpenSessionFolderMenuItem({ sessionId, useMenuOpenState, openInFinder }) { @@ -195,6 +245,29 @@ window.__ModuleLoader__.load({ const inject = ['locale', 'slots', 'remote.session', 'sessions', 'uiWorkspace'] function apply(ctx) { + ctx.effect(() => ctx.locale.register(DELETE_SESSION_NS, { + zh: { + menu: '永久删除会话', title: '删除会话', close: '关闭', cancel: '取消', confirm: '删除会话', + description: '确定删除“{name}”?工作区文件会保留。此操作无法撤销。', + pending: '正在删除会话…', failed: '删除失败,请重试。' + }, + en: { + menu: 'Delete session permanently', title: 'Delete session', close: 'Close', cancel: 'Cancel', confirm: 'Delete session', + description: 'Delete “{name}”? Workspace files are kept. This can’t be undone.', + pending: 'Deleting session…', failed: 'Deletion failed. Please try again.' + } + })) + const deletion = createSessionDeletion((sessionId) => { + const workspace = ctx.get('uiWorkspace') + if (!workspace) throw new Error('Workspace navigation is unavailable') + return workspace.deleteSession(sessionId) + }) + ctx.effect(() => () => deletion.dispose()) + const deletionText = ctx.locale.bind(DELETE_SESSION_NS) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'desktop-delete-session-dialog', locale: DELETE_SESSION_NS, + inject: () => ({ deletion, t: deletionText }) + }, DeleteSessionDialog)) ctx.effect(() => ctx.locale.register(ENTERPRISE_NS, { zh: { learnMore: '了解企业版' }, en: { learnMore: 'Explore Enterprise' } })) @@ -298,13 +371,8 @@ window.__ModuleLoader__.load({ name: 'sidebar.workspaces.session.menu.item', id: 'desktop-delete-session', order: 900, - inject: () => ({ - deleteSession: (sessionId) => { - const workspace = ctx.get('uiWorkspace') - if (!workspace) throw new Error('Workspace navigation is unavailable') - return workspace.deleteSession(sessionId) - } - }) + locale: DELETE_SESSION_NS, + inject: () => ({ deletion, t: deletionText }) }, DeleteSessionMenuItem) ) ctx.slots.inject('sidebar.workspaces.session.menu.item', () => diff --git a/test/desktop-client-ui.test.ts b/test/desktop-client-ui.test.ts index 5115c8940..3ca42f3fc 100644 --- a/test/desktop-client-ui.test.ts +++ b/test/desktop-client-ui.test.ts @@ -88,6 +88,7 @@ describe('DSH Desktop client slot occupants', () => { expect(plugin.inject).toEqual(['locale', 'slots', 'remote.session', 'sessions', 'uiWorkspace']) expect(registrations.map(({ config }) => config.name)).toEqual([ + 'shell.overlay', 'settings.sidebar.footer', 'sidebar.settings.action', 'conversation.input.dock', diff --git a/test/session-delete-locale.test.mjs b/test/session-delete-locale.test.mjs new file mode 100644 index 000000000..bb115d2c7 --- /dev/null +++ b/test/session-delete-locale.test.mjs @@ -0,0 +1,208 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import React, { act } from 'react' +import * as ReactDOM from 'react-dom' +import * as jsxRuntime from 'react/jsx-runtime' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeAll, expect, it, vi } from 'vitest' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const source = readFileSync(process.env.DSH_TEST_DELETE_CLIENT_SOURCE ?? 'packages/dsh-desktop-client-ui/client.js', 'utf8') +let primitives, cleanup +beforeAll(async () => { + // Exercise the installed Modal/Menu/Button behavior; CSS has no layout in jsdom. + // The distributed ESM also initializes unrelated markdown highlighters. Load + // the UI prefix through Modal, keeping its implementations and focus helpers. + const bundle = execFileSync(process.execPath, ['--input-type=module', '-e', + `import { build } from 'esbuild'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + const source = readFileSync(require.resolve('@deepseek-ai/dsh-client-ui-primitives'), 'utf8'); + const boundary = source.indexOf('//#region lib/types/RiskConfirmation.js'); + if (boundary < 0) throw new Error('Recheck the installed primitive layout'); + const result = await build({ stdin: { contents: source.slice(0, boundary) + '\\nexport { Modal, Menu, MenuItemButton, Button };', resolveDir: process.cwd() }, + bundle: true, write: false, format: 'cjs', platform: 'browser', + plugins: [{ name: 'ui-only', setup(build) { + build.onResolve({ filter: /\\.css$/ }, args => ({ path: args.path, namespace: 'css' })); + build.onLoad({ filter: /.*/, namespace: 'css' }, () => ({ contents: 'export default {}', loader: 'js' })); + build.onResolve({ filter: /^[^./]/ }, args => ({ path: args.path, external: true, sideEffects: false })); + } }] + }); process.stdout.write(result.outputFiles[0].text)` + ], { encoding: 'utf8', maxBuffer: 4_000_000 }) + const module = { exports: {} } + new Function('module', 'exports', 'require', bundle)(module, module.exports, name => { + if (name === 'react') return React + if (name === 'react-dom') return ReactDOM + if (name === 'react/jsx-runtime') return jsxRuntime + if (name === 'clsx') return (...values) => values.filter(Boolean).join(' ') + throw new Error(name) + }) + primitives = module.exports +}) +afterEach(async () => { await cleanup?.(); cleanup = undefined; vi.restoreAllMocks() }) + +async function fixture({ language = 'zh', deleteSession = vi.fn(async () => {}) } = {}) { + let plugin, locale = language + const entries = new Map(), dictionaries = new Map(), disposers = [] + const nativeConfirm = vi.spyOn(window, 'confirm').mockImplementation(() => { throw new Error('Native confirmation invoked') }) + const nativeAlert = vi.spyOn(window, 'alert').mockImplementation(() => { throw new Error('Native alert invoked') }) + new Function('window', source)({ __ModuleLoader__: { load({ factory }) { + plugin = factory(name => { + if (name === 'react') return React + if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives + throw new Error(name) + }) + } } }) + plugin.apply({ + get: name => { expect(name).toBe('uiWorkspace'); return { deleteSession } }, + effect(fn) { const dispose = fn(); if (typeof dispose === 'function') disposers.push(dispose) }, + locale: { + register(ns, dictionary) { dictionaries.set(ns, dictionary); return () => dictionaries.delete(ns) }, + bind: ns => (key, args = {}) => dictionaries.get(ns)[locale][key].replace(/\{(\w+)\}/g, (_, name) => args[name]) + }, + slots: { + inject(name, fn) { if (['shell.overlay', 'sidebar.workspaces.session.menu.item'].includes(name)) fn() }, + register(meta, Component) { entries.set(meta.id, { meta, Component }); return () => {} } + } + }) + const menu = entries.get('desktop-delete-session'), overlay = entries.get('desktop-delete-session-dialog') + expect(menu.meta.locale).toBe(overlay.meta.locale) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + let mounted = true + const menuProps = menu.meta.inject(), overlayProps = overlay.meta.inject() + function App() { + const [open, setOpen] = React.useState(false) + return React.createElement(React.Fragment, null, + React.createElement(primitives.Menu, { + open, onClose: () => setOpen(false), portal: true, + anchor: React.createElement('button', { onClick: () => setOpen(true), 'data-trigger': true }, 'Session actions') + }, React.createElement(menu.Component, { + ...menuProps, sessionId: 'session-a', displayTitle: '测试会话