Merge branch 'master' into fix/file-upload-resolver-dispose

This commit is contained in:
Turtle authored and GitHub committed 2026-10-19 18:04:10 +08:00
commit ec620b1449
98 files changed
+2271 -213

No files matched your search

+6 -6
View File
@@ -33,8 +33,8 @@
en: 372297005b57a08b
zh: 074a635ac17e4362
/deepseek-harness-desktop/installation-ownership:
en: 22153a298a6662e1
zh: 3600803c8c9d0274
en: 7e2329afb2bfa6e4
zh: bee6b40de5551daf
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: a1a47b09f4a72ba4
zh: 7b1cf27f0b72cf31
@@ -54,11 +54,11 @@
en: ac31ebdd9de54f34
zh: 921e534d5c4b3a16
/deepseek-harness-desktop/windows-installer-interface:
en: 8cb4b7babbd8714d
zh: b4aebc1d2d152e01
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
/deepseek-harness-desktop/updates:
en: b83a37681e23a3cc
zh: e11ab8381fa4362d
en: 87db37529b51da62
zh: 4fc0d752e437e7d6
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
+6 -2
View File
@@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
The image-drag bridge accepts original PNG, JPEG, GIF, WebP, BMP, ICO, or SVG bytes from the owned application main frame. Pending and retained files share a 64 MiB limit; when it is full, new preparation fails until unused files are released or the app restarts. It prepares private temporary files and asynchronous system file icons before `startDrag`, exposes only opaque handles, and never accepts renderer filesystem paths or decodes full images in the main process. Unused files are released with their source image; files handed to the OS remain readable until exit because receivers may read them after the drag returns. Shutdown waits for pending writes and releases, retries failed deletions, and does not wait for the system icon service. Update preparation completes cleanup before launching the installer; failed installation restores the bridge. Cleanup errors do not interrupt Host shutdown. Native drag errors use the application’s localized image feedback.
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
@@ -253,9 +255,9 @@ The Windows installer uses native NSIS pages with light and dark palettes, syste
The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation.
Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform.
Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
@@ -267,6 +269,8 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com
On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step.
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
+6 -2
View File
@@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
图片拖放桥接只接受主应用所属主框架传入的 PNG、JPEG、GIF、WebP、BMP、ICO 或 SVG 原图字节。准备中及保留的文件共享 64 MiB 上限;达到上限后,新准备会失败,直到未使用文件被释放或应用重启。桥接在 `startDrag` 前准备私有临时文件和异步系统文件图标,只暴露不透明句柄,不接受渲染进程指定的文件系统路径,也不在主进程解码完整图片。未使用文件随来源图片释放;已交给操作系统的文件保留到退出,因为接收方可能在拖动返回后继续读取。退出等待进行中的写入和释放,并重试失败的删除,但不等待系统图标服务。更新准备在启动安装器前完成清理;安装失败后恢复桥接。清理错误不会中断 Host 关闭。原生拖放错误使用应用的本地化图片反馈。
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
@@ -253,9 +255,9 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影
主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
@@ -267,6 +269,8 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP
Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
+1 -1
View File
@@ -62,7 +62,7 @@ Function InstallerProgressShow
StrCpy $0 1
StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp"
${EndIf}
System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, i $InstallerControlHover, i $InstallerControlPressed, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
Pop $InstallerProgressWindow
${If} $InstallerProgressWindow == 0
MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
+11
View File
@@ -18,6 +18,8 @@ Var InstallerEditFrame
Var InstallerBrowse
Var InstallerLaunch
Var InstallerExpanded
Var InstallerMinimizeButton
Var InstallerCloseButton
!include "${__FILEDIR__}\path.nsh"
!include "${__FILEDIR__}\drawing.nsh"
@@ -59,11 +61,13 @@ Function InstallerCreate
!insertmacro InstallerControlColors $4
${NSD_CreateButton} 0 0 0 0 "−"
Pop $4
StrCpy $InstallerMinimizeButton $4
!insertmacro InstallerPlace $4 504 8 40 32
${NSD_OnClick} $4 InstallerMinimize
${NSD_OnNotify} $4 InstallerPaintButton
${NSD_CreateButton} 0 0 0 0 "×"
Pop $4
StrCpy $InstallerCloseButton $4
!insertmacro InstallerPlace $4 548 8 40 32
${NSD_OnClick} $4 InstallerClose
${NSD_OnNotify} $4 InstallerPaintButton
@@ -321,6 +325,13 @@ Function InstallerPaintButton
${If} $R1 != 0
StrCpy $R3 $InstallerControlHover
${EndIf}
${If} $R0 == $InstallerMinimizeButton
${OrIf} $R0 == $InstallerCloseButton
IntOp $R1 $R9 & 1
${If} $R1 != 0
StrCpy $R3 $InstallerControlPressed
${EndIf}
${EndIf}
${EndIf}
System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)'
System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)'
+7 -1
View File
@@ -22,6 +22,9 @@
!define INSTALLER_PRIMARY_HOVER 0xFF2D3135
!define INSTALLER_PRIMARY_PRESSED 0xFF000000
!define INSTALLER_CONTROL_HOVER 0xFFEEF0F2
!define INSTALLER_CONTROL_PRESSED 0xFFD4DBE9
!define INSTALLER_DARK_CONTROL_HOVER 0xFF303034
!define INSTALLER_DARK_CONTROL_PRESSED 0xFF45454B
!define INSTALLER_TRACK_COLOR 0xFFE9ECF2
!define INSTALLER_TEXT_COLORREF 0x15110F
@@ -35,6 +38,7 @@ Var InstallerPrimary
Var InstallerPrimaryHover
Var InstallerPrimaryPressed
Var InstallerControlHover
Var InstallerControlPressed
Var InstallerTrack
Var InstallerButtonText
Var InstallerBorder
@@ -70,7 +74,8 @@ Function InstallerResolveTheme
StrCpy $InstallerPrimary 0xFFF9FAFB
StrCpy $InstallerPrimaryHover 0xFFE9ECF2
StrCpy $InstallerPrimaryPressed 0xFFD4DBE9
StrCpy $InstallerControlHover 0xFF303034
StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER}
StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED}
StrCpy $InstallerTrack 0xFF61666B
StrCpy $InstallerButtonText 0x15110F
StrCpy $InstallerBorder 0xFF61666B
@@ -84,6 +89,7 @@ Function InstallerResolveTheme
StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER}
StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED}
StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER}
StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED}
StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR}
StrCpy $InstallerButtonText 0xFFFFFF
StrCpy $InstallerBorder 0xFFBBC0C8
+164 -35
View File
@@ -113,7 +113,24 @@ struct ProgressPage {
UINT dpi;
ULONG_PTR gdiplus;
Image* brand;
Bitmap* buffer;
HDC bufferDc;
HBITMAP bufferBitmap;
HGDIOBJ previousBitmap;
UINT pressedCommand;
UINT hoveredCommand;
ARGB controlHover;
ARGB controlPressed;
WCHAR captions[5][128];
~ProgressPage() {
delete buffer;
delete brand;
if (previousBitmap) SelectObject(bufferDc, previousBitmap);
if (bufferBitmap) DeleteObject(bufferBitmap);
if (bufferDc) DeleteDC(bufferDc);
if (gdiplus) GdiplusShutdown(gdiplus);
}
};
// NSIS shows its page after MUI's SHOW callback; keep its controls off screen.
@@ -139,6 +156,45 @@ static void FillProgress(Graphics& graphics, Brush& brush, REAL width) {
graphics.FillPath(&brush, &path);
}
static UINT CaptionCommand(const ProgressPage& page, LPARAM position) {
const int x = static_cast<short>(LOWORD(position));
const int y = static_cast<short>(HIWORD(position));
if (y < 0 || y >= MulDiv(48, page.dpi, 96) || x < MulDiv(504, page.dpi, 96) || x >= MulDiv(600, page.dpi, 96)) return 0;
return x >= MulDiv(548, page.dpi, 96) ? SC_CLOSE : SC_MINIMIZE;
}
static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) {
SolidBrush background(page.dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.FillRectangle(&background, 504, 0, 96, 48);
SolidBrush ink(page.dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21));
FontFamily family(L"Microsoft YaHei UI");
Font font(&family, 16, FontStyleRegular, UnitPixel);
StringFormat centered;
centered.SetAlignment(StringAlignmentCenter);
centered.SetLineAlignment(StringAlignmentCenter);
for (UINT command : {SC_MINIMIZE, SC_CLOSE}) {
const int x = command == SC_MINIMIZE ? 504 : 548;
if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) {
const bool pressed = page.pressedCommand == command;
SolidBrush fill(Color(pressed ? page.controlPressed : page.controlHover));
GraphicsPath path;
path.AddArc(x, 8, 20, 20, 180, 90);
path.AddArc(x + 20, 8, 20, 20, 270, 90);
path.AddArc(x + 20, 20, 20, 20, 0, 90);
path.AddArc(x, 20, 20, 20, 90, 90);
path.CloseFigure();
graphics.FillPath(&fill, &path);
}
graphics.DrawString(command == SC_MINIMIZE ? L"\x2212" : L"\x00d7", -1,
&font, RectF(static_cast<REAL>(x), 8, 40, 32), &centered, &ink);
}
}
static void InvalidateCaptionButtons(HWND window, const ProgressPage& page) {
RECT bounds{MulDiv(504, page.dpi, 96), 0, MulDiv(600, page.dpi, 96), MulDiv(48, page.dpi, 96)};
InvalidateRect(window, &bounds, FALSE);
}
static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
if (message == WM_CREATE) {
@@ -148,29 +204,68 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
}
if (message == WM_ERASEBKGND) return 1;
if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; }
if (message == WM_LBUTTONDOWN && page) {
const int x = LOWORD(lparam) * 96 / page->dpi;
const int y = HIWORD(lparam) * 96 / page->dpi;
if (y < 48) {
HWND parent = GetParent(window);
if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0);
else if (x >= 504) ShowWindow(parent, SW_MINIMIZE);
else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); }
if (message == WM_MOUSEMOVE && page) {
TRACKMOUSEEVENT tracking{sizeof(tracking), TME_LEAVE, window, 0};
TrackMouseEvent(&tracking);
const UINT hovered = CaptionCommand(*page, lparam);
if (page->hoveredCommand != hovered) {
page->hoveredCommand = hovered;
InvalidateCaptionButtons(window, *page);
}
return 0;
}
if (message == WM_PAINT && page) {
PAINTSTRUCT paint;
HDC dc = BeginPaint(window, &paint);
if (message == WM_MOUSELEAVE && page) {
page->hoveredCommand = 0;
InvalidateCaptionButtons(window, *page);
return 0;
}
if (message == WM_LBUTTONDOWN && page) {
const int x = static_cast<short>(LOWORD(lparam));
const int y = static_cast<short>(HIWORD(lparam));
page->pressedCommand = CaptionCommand(*page, lparam);
if (page->pressedCommand) {
page->hoveredCommand = page->pressedCommand;
SetCapture(window);
InvalidateCaptionButtons(window, *page);
}
else if (x >= 0 && x < MulDiv(504, page->dpi, 96) && y >= 0 && y < MulDiv(48, page->dpi, 96)) {
HWND parent = GetParent(window);
ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0);
}
return 0;
}
if (message == WM_LBUTTONUP && page) {
const UINT command = page->pressedCommand;
page->pressedCommand = 0;
page->hoveredCommand = CaptionCommand(*page, lparam);
InvalidateCaptionButtons(window, *page);
const bool activate = command && GetCapture() == window && CaptionCommand(*page, lparam) == command;
if (GetCapture() == window) ReleaseCapture();
if (activate) {
if (command == SC_CLOSE) PostMessageW(GetParent(window), WM_CLOSE, 0, 0);
else ShowWindow(GetParent(window), SW_MINIMIZE);
}
return 0;
}
if ((message == WM_CAPTURECHANGED || message == WM_CANCELMODE) && page) {
page->pressedCommand = 0;
InvalidateCaptionButtons(window, *page);
if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture();
return 0;
}
if ((message == WM_PAINT || message == WM_PRINTCLIENT) && page) {
PAINTSTRUCT paint = {};
const bool printing = message == WM_PRINTCLIENT;
HDC dc = printing ? reinterpret_cast<HDC>(wparam) : BeginPaint(window, &paint);
if (printing) GetClientRect(window, &paint.rcPaint);
{
Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB);
Graphics graphics(&buffer);
Graphics graphics(page->buffer);
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
PaintCaptionButtons(graphics, *page);
SolidBrush background(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.FillRectangle(&background, 48, 480, 504, 56);
const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
page->progress.Advance(stage, fraction, GetTickCount64());
@@ -188,28 +283,25 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent);
SetWindowTextW(window, caption);
graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), &centered, &ink);
Font controls(&family, 16, FontStyleRegular, UnitPixel);
graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), &centered, &ink);
graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), &centered, &ink);
Graphics screen(dc);
screen.DrawImage(&buffer, 0, 0);
}
EndPaint(window, &paint);
BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top,
paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top,
page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY);
if (!printing) EndPaint(window, &paint);
if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast<HANDLE>(100));
return 0;
}
if (message == WM_NCDESTROY && page) {
KillTimer(window, 1);
delete page->brand;
GdiplusShutdown(page->gdiplus);
delete page;
}
return DefWindowProcW(window, message, wparam, lparam);
}
// Runs on the NSIS UI thread; the stock installation section runs on its worker.
// Caption colors are opaque ARGB from NSIS; failure preserves the stock page's visibility.
extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
BOOL dark, UINT dpi, ARGB controlHover, ARGB controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) {
HINSTANCE module = GetModuleHandleW(nullptr);
WNDCLASSW type = {};
@@ -222,22 +314,56 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
if (!page) return nullptr;
GdiplusStartupInput startup;
if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; }
HWND stockPage = GetParent(source);
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
GdiplusShutdown(page->gdiplus); delete page; return nullptr;
}
ShowWindow(stockPage, SW_HIDE);
page->dark = dark != FALSE;
page->dpi = dpi;
page->controlHover = controlHover;
page->controlPressed = controlPressed;
const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning};
for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128);
page->brand = new Image(brand);
if (page->brand->GetLastStatus() != Ok) {
delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr;
if (!page->brand || page->brand->GetLastStatus() != Ok) {
delete page; return nullptr;
}
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE,
const int size = MulDiv(600, dpi, 96);
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = size;
info.bmiHeader.biHeight = -size;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* pixels = nullptr;
page->bufferDc = CreateCompatibleDC(nullptr);
page->bufferBitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!page->bufferDc || !page->bufferBitmap) { delete page; return nullptr; }
page->previousBitmap = SelectObject(page->bufferDc, page->bufferBitmap);
if (!page->previousBitmap) { delete page; return nullptr; }
// Opaque pixels are shared by GDI+ drawing and the window's GDI blit.
page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(pixels));
if (!page->buffer || page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; }
{
// Static artwork stays in the buffer for this page's lifetime.
Graphics graphics(page->buffer);
graphics.ScaleTransform(dpi / 96.0f, dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
PaintCaptionButtons(graphics, *page);
}
delete page->brand;
page->brand = nullptr;
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD,
0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page);
if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; }
if (!window) { delete page; return nullptr; }
// Keep the stock page available until the replacement is fully constructed.
HWND stockPage = GetParent(source);
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
DestroyWindow(window); return nullptr;
}
ShowWindow(stockPage, SW_HIDE);
ShowWindow(window, SW_SHOW);
return window;
}
@@ -268,6 +394,8 @@ extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND windo
static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam,
LPARAM lparam, UINT_PTR id, DWORD_PTR) {
if (message == WM_NCCALCSIZE && wparam) return 0;
// Activation must not repaint the standard frame over the custom client area.
if (message == WM_NCACTIVATE) return DefSubclassProc(window, message, wparam, -1);
if (message == WM_NCHITTEST) {
const LRESULT hit = DefSubclassProc(window, message, wparam, lparam);
// The installer has a fixed size; its page provides the drag area.
@@ -291,7 +419,8 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window
ShowWindow(child, SW_HIDE);
}
}
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME);
// DWM retains native transitions; WM_NCCALCSIZE removes the visible caption.
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION);
const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
if (FAILED(result)) return result;
@@ -21,6 +21,7 @@ function render(view) {
'stop-failed': messages.updateStopFailed,
'tasks-changed': messages.updateTasksChanged,
'tasks-unavailable': messages.updateTasksUnavailable,
'fullscreen-timeout': messages.updateFullscreenTimeout,
}
const fallback = failed || update.phase === 'idle' || view.error !== undefined
let title = policy.title ?? messages.mandatoryTitle
@@ -14,7 +14,7 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi
if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' }
$vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat'
$compileScript = Join-Path $output 'compile-frame.cmd'
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib gdi32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
[IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
& $env:ComSpec /d /c $compileScript
if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
@@ -50,6 +50,12 @@ if ($TestProgress -or $CompileProgressOnly) {
& $presentationExecutable
if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' }
}
$renderSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-render.cpp'))
$renderExecutable = Join-Path $output 'render-test.exe'
$renderScript = Join-Path $output 'compile-render-test.cmd'
[IO.File]::WriteAllLines($renderScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib gdi32.lib' -f $renderSource, (Join-Path $output 'render-test.obj'), $renderExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default)
& $env:ComSpec /d /c $renderScript
if ($LASTEXITCODE -ne 0) { throw 'Installer render test compilation failed.' }
$reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp'))
$reportExecutable = Join-Path $output 'extract-report-test.exe'
$reportScript = Join-Path $output 'compile-extract-report-test.cmd'
@@ -58,10 +58,12 @@ try {
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
const progressTest = join(output, 'ui', 'progress-test.exe')
const presentationTest = join(output, 'ui', 'presentation-test.exe')
const renderTest = join(output, 'ui', 'render-test.exe')
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
if (sign) {
await sign({ path: progressTest, hash: 'sha256', isNest: false })
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
await sign({ path: renderTest, hash: 'sha256', isNest: false })
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
installWindowsNsisBootstrapSigner({ sign })
@@ -69,6 +71,23 @@ try {
if (!uninstallOnly) {
await execute(progressTest, [], childOptions)
await execute(presentationTest, [], childOptions)
const renderResults = []
const themeSource = await readFile(join(appRoot, 'installer', 'theme.nsh'), 'utf8')
const captionColor = (name) => {
const match = themeSource.match(new RegExp(`^!define ${name} (0x[0-9A-Fa-f]{8})\\r?$`, 'm'))
if (!match) throw new Error(`Missing installer caption color: ${name}`)
return match[1]
}
for (const dpi of [96, 192]) {
for (const theme of ['light', 'dark']) {
const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp'
const prefix = theme === 'dark' ? 'INSTALLER_DARK_CONTROL' : 'INSTALLER_CONTROL'
const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme,
captionColor(`${prefix}_HOVER`), captionColor(`${prefix}_PRESSED`)], childOptions)
renderResults.push(result.stdout)
}
}
await writeFile(join(output, 'render-results.log'), renderResults.join(''))
}
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
const payloadSource = join(output, 'payload.nsi')
+120
View File
@@ -0,0 +1,120 @@
/** Process-owned original image files staged for native copy drags. */
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
// Keep supported MIME types in sync with IMAGE_MEDIA_TYPES in
// packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx (extensions: adjacent index.ts).
const DRAG_FILE_EXTENSION_BY_MIME: Readonly<Record<string, string>> = {
'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
'image/bmp': 'bmp', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico', 'image/svg+xml': 'svg',
}
// Bounds aggregate original bytes in preparation and retained temporary files.
const MAX_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024
interface ImageDragFile {
readonly directory: string
readonly path: string
readonly bytes: number
used: boolean
}
/** Original files belong to the application; handed-off files remain readable until app shutdown. */
export class ImageDragFiles {
private readonly files = new Map<string, ImageDragFile>()
private readonly pending = new Set<Promise<string>>()
private readonly releasing = new Map<string, Promise<void>>()
private stagedBytes = 0
private disposed = false
/** @param temporaryDirectory - OS temporary directory in which private export directories are allocated. */
constructor(private readonly temporaryDirectory: string) {}
/**
* Validate renderer bytes and stage one original file without accepting renderer filesystem paths.
* @param bytes - complete image bytes; all pending and retained files share a 64 MiB budget.
* @param name - suggested filename; directory characters and platform-reserved characters are removed.
* @param mediaType - supported image MIME type, which owns the output suffix.
* @returns application-local opaque handle; failures retain no usable handle or partial file.
*/
prepare(bytes: unknown, name: unknown, mediaType: unknown): Promise<string> {
const outputExtension = typeof mediaType === 'string' && Object.hasOwn(DRAG_FILE_EXTENSION_BY_MIME, mediaType)
? DRAG_FILE_EXTENSION_BY_MIME[mediaType] : undefined
if (this.disposed || !(bytes instanceof Uint8Array) || bytes.byteLength === 0
|| bytes.byteLength > MAX_TOTAL_DRAG_FILE_BYTES - this.stagedBytes
|| typeof name !== 'string' || typeof mediaType !== 'string' || outputExtension === undefined) {
return Promise.reject(new Error('Invalid image transfer'))
}
let stem = name.replace(/\.[^.]*$/u, '').replace(/[<>:"/\\|?*\x00-\x1f]/gu, '_').replace(/^[. ]+|[. ]+$/gu, '').slice(0, 120) || 'image'
if (/^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(stem)) stem = `_${stem}`
this.stagedBytes += bytes.byteLength
const operation = this.write(bytes, `${stem}.${outputExtension}`).catch((error: unknown) => {
this.stagedBytes -= bytes.byteLength
throw error
})
this.pending.add(operation)
void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) })
return operation
}
private async write(bytes: Uint8Array, name: string): Promise<string> {
const directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-image-drag-'))
try {
const path = join(directory, name)
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
if (this.disposed) throw new Error('Image transfer closed')
const handle = randomUUID()
this.files.set(handle, { directory, path, bytes: bytes.byteLength, used: false })
return handle
} catch (error) {
await rm(directory, { recursive: true, force: true })
throw error
}
}
/**
* Resolve and retain a prepared file for an OS-owned drag.
* @param handle - handle created by this application's preparation operation.
* @param retain - whether the OS accepted the file; false while preparing or attempting a drag.
* @returns original file path, or undefined after release/disposal or for a foreign handle.
*/
take(handle: unknown, retain = true): string | undefined {
if (typeof handle !== 'string') return undefined
const file = this.files.get(handle)
if (file === undefined || this.disposed || this.releasing.has(handle)) return undefined
if (retain) file.used = true
return file.path
}
/**
* Release a preparation that no longer has a mounted image consumer.
* @param handle - application-local handle; OS-used files stay until disposal.
*/
async release(handle: unknown): Promise<void> {
if (typeof handle !== 'string') return
const file = this.files.get(handle)
if (file === undefined || file.used) return
await this.remove(handle, file)
}
private remove(handle: string, file: ImageDragFile): Promise<void> {
const existing = this.releasing.get(handle)
if (existing !== undefined) return existing
const operation = rm(file.directory, { recursive: true, force: true }).then(() => {
this.files.delete(handle)
this.stagedBytes -= file.bytes
}).finally(() => { this.releasing.delete(handle) })
this.releasing.set(handle, operation)
return operation
}
/** Stop preparation, await writes and releases, and report removal failure only after all files settle. */
async dispose(): Promise<void> {
this.disposed = true
await Promise.allSettled(this.pending)
await Promise.allSettled(this.releasing.values())
const results = await Promise.allSettled([...this.files].map(([handle, file]) => this.remove(handle, file)))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
}
+80
View File
@@ -0,0 +1,80 @@
/** Native copy drags authorized only for the owning application window. */
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron'
import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
import { ImageDragFiles } from './image-drag-files.ts'
/**
* Register native image preparation and drag operations for the application window.
* @param getWindow - current application window; subframes and other windows are rejected.
* @returns cleanup that waits for preparation and removes all exported temporary files.
*/
export function installDesktopImageDrag(getWindow: () => BrowserWindow | undefined): () => Promise<void> {
let disposed = false
const closed = Promise.withResolvers<void>()
const files = new ImageDragFiles(app.getPath('temp'))
const pending = new Set<Promise<string>>()
const icons = new Map<string, NativeImage>()
const owner = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = getWindow()
if (disposed || window === undefined || window.isDestroyed() || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame) throw new Error('Unowned image drag request')
assertDesktopSender(event, ['app'])
return window
}
const prepare = async (event: IpcMainInvokeEvent, bytes: unknown, name: unknown, mediaType: unknown): Promise<string> => {
owner(event)
const handle = await files.prepare(bytes, name, mediaType)
try {
const file = files.take(handle, false)
if (file === undefined) throw new Error('Image transfer closed')
const icon = await Promise.race([app.getFileIcon(file, { size: 'normal' }), closed.promise])
owner(event)
if (icon === undefined) throw new Error('Image transfer closed')
icons.set(handle, icon)
return handle
} catch (error) {
await files.release(handle)
throw error
}
}
ipcMain.handle(DESKTOP_IPC.imageDragPrepare, (event, bytes: unknown, name: unknown, mediaType: unknown) => {
const operation = prepare(event, bytes, name, mediaType)
pending.add(operation)
void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) })
return operation
})
ipcMain.handle(DESKTOP_IPC.imageDragStart, (event, handle: unknown) => {
const window = owner(event)
const icon = typeof handle === 'string' ? icons.get(handle) : undefined
if (icon === undefined) return false
const file = files.take(handle, false)
if (file === undefined) return false
try {
window.webContents.startDrag({ file, icon })
files.take(handle)
return true
} catch (_error) {
if (typeof handle === 'string') icons.delete(handle)
void files.release(handle).catch((error: unknown) => { console.error(error) })
// The renderer reports one localized failure; no filesystem paths cross IPC.
return false
}
})
ipcMain.handle(DESKTOP_IPC.imageDragRelease, async (event, handle: unknown) => {
owner(event)
if (typeof handle === 'string') icons.delete(handle)
await files.release(handle)
})
return async () => {
if (!disposed) {
disposed = true
closed.resolve()
ipcMain.removeHandler(DESKTOP_IPC.imageDragPrepare)
ipcMain.removeHandler(DESKTOP_IPC.imageDragStart)
ipcMain.removeHandler(DESKTOP_IPC.imageDragRelease)
}
await Promise.allSettled(pending)
icons.clear()
await files.dispose()
}
}
+10 -1
View File
@@ -21,6 +21,9 @@ export const DESKTOP_IPC = {
browserRelease: 'dsh-desktop:browser-release',
browserOpenRequested: 'dsh-desktop:browser-open-requested',
directoryPick: 'dsh-desktop:directory-pick',
imageDragPrepare: 'dsh-desktop:image-drag-prepare',
imageDragStart: 'dsh-desktop:image-drag-start',
imageDragRelease: 'dsh-desktop:image-drag-release',
deviceInfo: 'dsh-desktop:device-info',
localeBootstrap: 'dsh-desktop:locale-bootstrap',
localeChanged: 'dsh-desktop:locale-changed',
@@ -34,7 +37,7 @@ export const DESKTOP_IPC = {
} as const
/** Desktop release update state rendered by desktop-owned UI. */
export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable'
export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' | 'fullscreen-timeout'
export interface DesktopUpdateState {
readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error'
@@ -59,6 +62,7 @@ export type DesktopUpdateFailureKind =
| 'stop-failed'
| 'tasks-changed'
| 'tasks-unavailable'
| 'fullscreen-timeout'
/** Semantic status content; actions open main-process confirmation dialogs only. */
export interface DesktopUpdatePresentation {
@@ -72,6 +76,11 @@ export interface DesktopUpdatePresentation {
export interface DshDesktopProductApi {
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly imageDrag: {
prepare(bytes: Uint8Array, name: string, mediaType: string): Promise<string>
start(handle: string): Promise<boolean>
release(handle: string): void
}
readonly keyboard: DesktopKeyboardApi
readonly shortcuts: DesktopShortcutsApi
/**
+2
View File
@@ -128,6 +128,7 @@ export const en = {
updateStopTasks: 'Stop tasks and update',
updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.',
updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
updateFullscreenTimeout: 'Could not exit fullscreen. Exit fullscreen, then retry the update.',
updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
@@ -299,6 +300,7 @@ export const zh = {
updateStopTasks: '停止任务并更新',
updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。',
updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
updateFullscreenTimeout: '无法退出全屏,请退出全屏后重试更新',
updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。',
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
+22 -3
View File
@@ -26,6 +26,7 @@ import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopImageDrag } from './image-drag.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
@@ -35,6 +36,7 @@ import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLoc
import { claimDesktopSingleInstance } from './single-instance.ts'
import { desktopUpdateChannel } from './update-channel.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { prepareDesktopUpdateWindow } from './update-window.ts'
import { DesktopCommandManager } from './command-management.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
@@ -344,6 +346,17 @@ async function main(): Promise<void> {
let workspaceRecovery: Promise<void> | undefined
let mainWindow: BrowserWindow | undefined
let welcomeWindow: BrowserWindow | undefined
const imageDragCleanups = new Set([installDesktopImageDrag(() => mainWindow)])
let imageDragStopped = false
const disposeImageDrag = async (): Promise<void> => {
imageDragStopped = true
const results = await Promise.allSettled([...imageDragCleanups].map(async (release) => {
await release()
imageDragCleanups.delete(release)
}))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
let enteredWorkspace = false
// NSIS passes --updated when it launches the application after installation.
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
@@ -536,6 +549,10 @@ async function main(): Promise<void> {
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
}
if (state.phase === 'error' && state.failedOperation !== 'check') {
if (state.failedOperation === 'install' && imageDragStopped && !quitting) {
imageDragCleanups.add(installDesktopImageDrag(() => mainWindow))
imageDragStopped = false
}
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
shellInstallerOwnsQuit = false
updateStoppedHost = false
@@ -595,6 +612,8 @@ async function main(): Promise<void> {
const host = backend.host
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
const active = await host.updateTasks('inspect')
const parent = currentDialogWindow()
if (parent === undefined || !await prepareDesktopUpdateWindow(parent, locale.messages.updateFullscreenTimeout)) return false
const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform)
const confirmation: Electron.MessageBoxOptions = {
type: active ? 'warning' : 'info', title: locale.messages.updateTitle,
@@ -606,8 +625,6 @@ async function main(): Promise<void> {
if (isMandatory()) {
if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
} else {
const parent = currentDialogWindow()
if (parent === undefined) return false
const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false
}
@@ -620,6 +637,7 @@ async function main(): Promise<void> {
mandatoryUI?.preparingRestart(stillActive)
// The embedded Platform document holds credentials issued by the Host that is about to stop.
await platformView.closeAndWait()
await disposeImageDrag()
requireCleanStop = true
updateStopFailure = undefined
await backend.stop()
@@ -1260,7 +1278,8 @@ async function main(): Promise<void> {
mandatoryUI?.dispose()
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
platformView.dispose().catch((error: unknown) => { console.error(error) })])
platformView.dispose().catch((error: unknown) => { console.error(error) }),
disposeImageDrag().catch((error: unknown) => { console.error(error) })])
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
}
app.on('before-quit', (event) => {
+7
View File
@@ -14,6 +14,13 @@ function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
browser: createDesktopBrowserBridge(),
imageDrag: {
prepare: (bytes, name, mediaType) => ipcRenderer.invoke(DESKTOP_IPC.imageDragPrepare, bytes, name, mediaType) as Promise<string>,
start: handle => ipcRenderer.invoke(DESKTOP_IPC.imageDragStart, handle) as Promise<boolean>,
release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.imageDragRelease, handle).catch((_error: unknown) => {
// Main-process shutdown also releases every prepared file.
}) },
},
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
keyboard: {
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
+1
View File
@@ -33,6 +33,7 @@ export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: D
'stop-failed': messages.updateStopFailed,
'tasks-changed': messages.updateTasksChanged,
'tasks-unavailable': messages.updateTasksUnavailable,
'fullscreen-timeout': messages.updateFullscreenTimeout,
}
return summaries[kind]
}
+34
View File
@@ -0,0 +1,34 @@
/** Native window preparation before an update confirmation can stop the Host. */
import type { BrowserWindow } from 'electron'
import { DesktopUpdatePreparationError } from './update-error.ts'
type UpdateWindow = Pick<BrowserWindow, 'isDestroyed' | 'isFullScreen' | 'setFullScreen'> & Pick<NodeJS.EventEmitter, 'once' | 'off'>
const FULLSCREEN_EXIT_TIMEOUT_MS = 5000
/**
* Leave macOS fullscreen before displaying installation confirmation.
* @param window - Product window that owns the update confirmation.
* @param timeoutMessage - Locale-owned guidance to exit fullscreen and retry.
* @returns Whether the window survived the completed native transition; destruction cancels installation.
* @throws {DesktopUpdatePreparationError} If macOS does not report completion within five seconds.
*/
export async function prepareDesktopUpdateWindow(window: UpdateWindow, timeoutMessage: string): Promise<boolean> {
if (window.isDestroyed()) return false
if (process.platform !== 'darwin' || !window.isFullScreen()) return true
return new Promise<boolean>((resolve, reject) => {
const cleanup = (): void => {
clearTimeout(deadline)
window.off('leave-full-screen', left)
window.off('closed', closed)
}
const left = (): void => { cleanup(); resolve(true) }
const closed = (): void => { cleanup(); resolve(false) }
// Bound a missing native completion event; elapsed time never authorizes installation.
const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdatePreparationError('fullscreen-timeout', timeoutMessage)) }, FULLSCREEN_EXIT_TIMEOUT_MS)
window.once('leave-full-screen', left)
window.once('closed', closed)
try { window.setFullScreen(false) }
catch (error) { cleanup(); throw error }
})
}
@@ -0,0 +1,63 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { ImageDragFiles } from '../src/image-drag-files.ts'
async function files() {
const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-'))
const store = new ImageDragFiles(root)
onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) })
return { root, store }
}
it('exports original bytes and keeps a used file readable until disposal', async () => {
const { root, store } = await files()
const bytes = new Uint8Array([137, 80, 78, 71])
const handle = await store.prepare(bytes, '../../original.png', 'image/png')
const path = store.take(handle)!
expect(basename(path)).toBe('_.._original.png')
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.release(handle)
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.dispose()
expect(await readdir(root)).toEqual([])
expect(store.take(handle)).toBeUndefined()
})
it('rejects non-images, foreign handles and writes after disposal', async () => {
const { store } = await files()
await expect(store.prepare(new Uint8Array([1]), 'x.html', 'text/html')).rejects.toThrow('Invalid image transfer')
await expect(store.prepare(new Uint8Array(), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer')
await expect(store.prepare(new Uint8Array([1]), 'x.png', 'constructor')).rejects.toThrow('Invalid image transfer')
expect(store.take('foreign')).toBeUndefined()
await store.dispose()
await expect(store.prepare(new Uint8Array([1]), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer')
})
it('releases unused preparations and waits for a concurrent write before cleanup', async () => {
const { root, store } = await files()
const handle = await store.prepare(new Uint8Array([1]), 'x.png', 'image/png')
await store.release(handle)
expect(await readdir(root)).toEqual([])
const pending = store.prepare(new Uint8Array([2]), 'y.png', 'image/png')
const rejected = expect(pending).rejects.toThrow('Image transfer closed')
await store.dispose()
await rejected
expect(await readdir(root)).toEqual([])
})
it.each(['CON', 'prn', 'AUX', 'NUL', 'COM1', 'LPT9', 'COM¹', 'CON.extra'])('sanitizes the Windows device filename %s', async (name) => {
const { store } = await files()
const handle = await store.prepare(new Uint8Array([1]), `${name}.png`, 'image/png')
expect(basename(store.take(handle, false)!)).toBe(`_${name}.png`)
})
it('bounds all retained bytes, including pending writes, and frees unused capacity', async () => {
const { store } = await files()
const pending = store.prepare(new Uint8Array(64 * 1024 * 1024), 'large.png', 'image/png')
await expect(store.prepare(new Uint8Array([1]), 'extra.png', 'image/png')).rejects.toThrow('Invalid image transfer')
const handle = await pending
await store.release(handle)
await expect(store.prepare(new Uint8Array([1]), 'extra.png', 'image/png')).resolves.toEqual(expect.any(String))
})
@@ -0,0 +1,67 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
vi.mock('node:fs/promises', async importOriginal => ({
...await importOriginal<typeof import('node:fs/promises')>(), rm: vi.fn(),
}))
const fs = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
const { ImageDragFiles } = await import('../src/image-drag-files.ts')
afterEach(() => { vi.mocked(rm).mockReset() })
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-image-release-'))
const store = new ImageDragFiles(root)
vi.mocked(rm).mockImplementation(fs.rm)
onTestFinished(async () => {
vi.mocked(rm).mockImplementation(fs.rm)
await store.dispose()
await fs.rm(root, { recursive: true, force: true })
})
const handle = await store.prepare(new Uint8Array([1]), 'image.png', 'image/png')
return { root, store, handle }
}
it('waits for an in-flight release before disposal completes', async () => {
const { root, store, handle } = await fixture()
const barrier = Promise.withResolvers<undefined>()
vi.mocked(rm).mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
const releasing = store.release(handle)
const secondRelease = store.release(handle)
expect(store.take(handle, false)).toBeUndefined()
let stopped = false
const disposing = store.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
expect(await readdir(root)).toHaveLength(1)
barrier.resolve(undefined)
await Promise.all([releasing, secondRelease, disposing])
expect(await readdir(root)).toEqual([])
})
it('retains a failed release for the shutdown cleanup to retry', async () => {
const { root, store, handle } = await fixture()
const path = store.take(handle, false)!
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
await expect(store.release(handle)).rejects.toThrow('file busy')
expect(new Uint8Array(await readFile(path))).toEqual(new Uint8Array([1]))
await store.dispose()
expect(await readdir(root)).toEqual([])
})
it('waits for all removals before reporting a shutdown cleanup failure', async () => {
const { store } = await fixture()
await store.prepare(new Uint8Array([2]), 'second.png', 'image/png')
const barrier = Promise.withResolvers<undefined>()
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
.mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
let settled = false
const disposing = store.dispose().finally(() => { settled = true })
const rejected = expect(disposing).rejects.toThrow('file busy')
await vi.waitFor(() => { expect(rm).toHaveBeenCalledTimes(2) })
expect(settled).toBe(false)
barrier.resolve(undefined)
await rejected
await store.dispose()
})
+88
View File
@@ -0,0 +1,88 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { BrowserWindow, IpcMainInvokeEvent, NativeImage, WebContents, WebFrameMain } from 'electron'
import { DESKTOP_IPC } from '../src/ipc.ts'
const electron = vi.hoisted(() => ({
handle: vi.fn(), removeHandler: vi.fn(), getPath: vi.fn(), getFileIcon: vi.fn(), createFromPath: vi.fn(),
}))
vi.mock('electron', () => ({
app: electron,
ipcMain: electron,
nativeImage: { createFromPath: electron.createFromPath },
}))
const { installDesktopImageDrag } = await import('../src/image-drag.ts')
afterEach(() => { vi.resetAllMocks() })
type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-native-image-drag-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
electron.getPath.mockReturnValue(root)
const icon: NativeImage = Object.assign({} as NativeImage, { isEmpty: () => false, resize: () => icon })
electron.getFileIcon.mockResolvedValue(icon)
const frame = Object.assign({} as WebFrameMain, { url: 'dsh-app://app/' })
const window = Object.assign({} as BrowserWindow, { isDestroyed: () => false,
webContents: Object.assign({} as WebContents, { mainFrame: frame, startDrag: vi.fn() }) })
let current: BrowserWindow | undefined = window
const dispose = installDesktopImageDrag(() => current)
onTestFinished(dispose)
const handlers = new Map<string, Handler>(electron.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
const event = Object.assign({} as IpcMainInvokeEvent, { sender: window.webContents, senderFrame: frame })
const call = (channel: string, ...args: unknown[]) => (handlers.get(channel) as Handler)(event, ...args)
return { root, window, frame, event, icon, call, dispose, detach: () => { current = undefined } }
}
it('stages original bytes before the gesture and synchronously starts an OS copy drag', async () => {
const f = await fixture()
const bytes = new Uint8Array([137, 80, 78, 71])
const handle = await f.call(DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png')
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(true)
const [request] = f.window.webContents.startDrag.mock.calls[0] as [{ file: string; icon: NativeImage }]
expect(request.icon).toBe(f.icon)
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
await f.call(DESKTOP_IPC.imageDragRelease, handle)
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false)
await f.dispose()
expect(await readdir(f.root)).toEqual([])
})
it('rejects foreign senders, subframes, revoked handles and native failures', async () => {
const f = await fixture()
expect(f.call(DESKTOP_IPC.imageDragStart, 'foreign')).toBe(false)
const handle = await f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.png', 'image/png')
f.window.webContents.startDrag.mockImplementationOnce(() => { throw new Error('OS rejected drag') })
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false)
await vi.waitFor(async () => { expect(await readdir(f.root)).toEqual([]) })
f.frame.url = 'https://other.test/'
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('unowned renderer')
f.frame.url = 'dsh-app://app/'
Object.assign(f.event, { senderFrame: { url: 'dsh-app://app/' } })
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
Object.assign(f.event, { senderFrame: f.frame, sender: {} })
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
f.detach()
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
})
it('cancels icon preparation on shutdown without waiting for the icon service', async () => {
const f = await fixture()
const pending = Promise.withResolvers<NativeImage>()
electron.getFileIcon.mockReturnValue(pending.promise)
const prepare = f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.svg', 'image/svg+xml')
const rejected = expect(prepare).rejects.toThrow('Unowned image drag request')
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
let stopped = false
const stopping = f.dispose().then(() => { stopped = true })
await rejected
await stopping
expect(stopped).toBe(true)
pending.resolve(f.icon)
await pending.promise
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
expect(await readdir(f.root)).toEqual([])
})
+112 -2
View File
@@ -16,6 +16,9 @@ import { writeCrashReport } from '../src/crash-report.ts'
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
const imageDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise<void>>(), install: vi.fn() }))
vi.mock('../src/image-drag.ts', () => ({ installDesktopImageDrag: () => { imageDrag.install(); return imageDrag.dispose } }))
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
// dialog never outlives the test that triggered it.
@@ -259,6 +262,8 @@ const harness = await vi.hoisted(async () => {
navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
policyBlocked = deferred()
embeddedPolicy = undefined
imageDrag.install.mockReset()
imageDrag.dispose.mockReset().mockResolvedValue(undefined)
platformDisposeDeferred = undefined
platformCloseDeferred = undefined
},
@@ -1478,6 +1483,33 @@ describe('desktop main startup', () => {
expect(harness.app.quit).toHaveBeenCalledTimes(2)
})
it('reports image cleanup failure without ending Host shutdown early', async () => {
const host = await readyForUpdate()
const failure = new Error('image cleanup failed')
imageDrag.dispose.mockRejectedValueOnce(failure)
harness.app.quit()
await host.stopping.promise
await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith(failure) })
expect(harness.app.quit).toHaveBeenCalledOnce()
host.exited.resolve()
await harness.quitCompleted.promise
expect(harness.app.quit).toHaveBeenCalledTimes(2)
})
it('waits for image cleanup before stopping the Host for installation', async () => {
const host = await readyForUpdate()
const cleanup = Promise.withResolvers<undefined>()
imageDrag.dispose.mockReturnValueOnce(cleanup.promise)
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(imageDrag.dispose).toHaveBeenCalledOnce() })
expect(host.stop).not.toHaveBeenCalled()
cleanup.resolve(undefined)
await host.stopping.promise
host.exited.resolve()
await expect(preparing).resolves.toBe(true)
})
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
const host = await readyForUpdate()
const window = harness.windows[0]!
@@ -1786,6 +1818,84 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
})
it.each([false, true])('waits for macOS to leave fullscreen before installation confirmation, mandatory: %s', async (mandatory) => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
if (mandatory) {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
}
const host = await readyForUpdate()
if (mandatory) await harness.policyBlocked.promise
const window = harness.windows[0]!
const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
if (mandatory) expect(harness.handlers.get(MANDATORY_IPC.status)!({ sender: dialogOwner.webContents, senderFrame: dialogOwner.webContents.mainFrame })).not.toHaveProperty('confirmation')
expect(host.stop).not.toHaveBeenCalled()
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
window.fullscreen = false
window.emit('leave-full-screen')
if (mandatory) await answerMandatory('install', dialogOwner)
await host.stopping.promise
host.exited.resolve()
await expect(preparing).resolves.toBe(true)
expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave)
expect(window.listenerCount('closed')).toBe(baseline.closed)
})
it('keeps the Host running after a fullscreen timeout and accepts a later retry', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
const host = await readyForUpdate()
const window = harness.windows[0]!
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
const preparing = harness.prepareUpdate()
const rejected = expect(preparing).rejects.toMatchObject({ kind: 'fullscreen-timeout',
message: 'Could not exit fullscreen. Exit fullscreen, then retry the update.' })
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
await vi.advanceTimersByTimeAsync(5000)
await rejected
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
expect(host.stop).not.toHaveBeenCalled()
window.setFullScreen.mockImplementation((flag) => { window.fullscreen = flag; window.emit('leave-full-screen') })
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const retrying = harness.prepareUpdate()
await host.stopping.promise
host.exited.resolve()
await expect(retrying).resolves.toBe(true)
})
it.each([false, true])('cancels installation when the fullscreen product window closes, mandatory: %s', async (mandatory) => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
if (mandatory) {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
}
const host = await readyForUpdate()
if (mandatory) await harness.policyBlocked.promise
const window = harness.windows[0]!
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
window.destroy()
await expect(preparing).resolves.toBe(false)
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(host.stop).not.toHaveBeenCalled()
expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave)
})
it('waits for Platform storage cleanup before the installer takes over', async () => {
const host = await readyForUpdate()
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
@@ -1830,8 +1940,7 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
})
async function answerMandatory(action: 'install' | 'later') {
const modal = harness.windows[0]!
async function answerMandatory(action: 'install' | 'later', modal = harness.windows[0]!) {
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
await vi.waitFor(() => {
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
@@ -1901,6 +2010,7 @@ describe('desktop main startup', () => {
const restarted = harness.nextHostStart()
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
await restarted
expect(imageDrag.install).toHaveBeenCalledTimes(2)
const replacement = harness.hosts[1]!
replacement.url = 'http://127.0.0.1:3099/?token=replacement'
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
+23
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import type { DesktopImageDrag } from '../../../packages/client/ui-primitives/src/image-drag.ts'
import { JSDOM } from 'jsdom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
@@ -254,3 +255,25 @@ it('forwards browser guest input only for the focused live webview lease', async
expect(listener).toHaveBeenCalledOnce()
off()
})
it('prepares original image bytes and starts native drags without renderer filesystem paths', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/'))
await import('../src/preload-app.ts')
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
const bytes = new Uint8Array([137, 80, 78, 71])
electron.ipcRenderer.invoke.mockResolvedValueOnce('image-handle').mockResolvedValueOnce(true).mockResolvedValueOnce(undefined)
await expect(api.imageDrag.prepare(bytes, 'original.png', 'image/png')).resolves.toBe('image-handle')
await expect(api.imageDrag.start('image-handle')).resolves.toBe(true)
api.imageDrag.release('image-handle')
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
[DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png'],
[DESKTOP_IPC.imageDragStart, 'image-handle'],
[DESKTOP_IPC.imageDragRelease, 'image-handle'],
])
})
// The Host and Client compiler faces share no runtime entry; this assignment checks their image protocol.
const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['imageDrag']): DesktopImageDrag => value
const imageProtocolBack = (value: DesktopImageDrag): import('../src/ipc.ts').DshDesktopProductApi['imageDrag'] => value
void imageProtocol
void imageProtocolBack
@@ -73,13 +73,13 @@ function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alp
}
describe('desktop update coordinator', () => {
it('keeps safe preparation diagnostics separate and clears them on an explicit retry', async () => {
it.each(['stop-failed', 'fullscreen-timeout'] as const)('keeps safe %s diagnostics separate and clears them on an explicit retry', async (kind) => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError('stop-failed', zh.updateStopFailed, 'exit 0; shutdown acknowledged false'))
f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError(kind, zh.updateStopFailed, 'exit 0; shutdown acknowledged false'))
expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2',
failedOperation: 'install', preparationFailure: 'stop-failed', message: zh.updateStopFailed,
failedOperation: 'install', preparationFailure: kind, message: zh.updateStopFailed,
technicalDetails: 'exit 0; shutdown acknowledged false' })
expect(f.quitAndInstall).not.toHaveBeenCalled()
f.beforeRestart.mockResolvedValueOnce(false)
@@ -243,3 +243,19 @@ it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across i
})
await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`)
})
it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the mandatory update page: %s', async (language) => {
const p = page('mandatory-update')
const locale = resolveDesktopLocale(language)
const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false },
update: { phase: 'error', failedOperation: 'install', preparationFailure: 'fullscreen-timeout',
version: '0.1.6-nightly.1', message: 'different shell locale' } }
const action = vi.fn(async () => {})
Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: {
status: async () => state, action, subscribe: () => () => {},
} satisfies MandatoryUpdateApi })
p.run()
await expect.poll(() => p.element('error').textContent).toBe(locale.messages.updateFullscreenTimeout)
expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false)
expect(action).not.toHaveBeenCalled()
})
@@ -32,6 +32,7 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => {
expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed',
message: 'translated or changed copy', technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateStopFailed)
for (const [preparationFailure, expected] of [
['fullscreen-timeout', messages.updateFullscreenTimeout],
['tasks-changed', messages.updateTasksChanged], ['tasks-unavailable', messages.updateTasksUnavailable],
] as const) {
expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure,
+69
View File
@@ -0,0 +1,69 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import { prepareDesktopUpdateWindow } from '../src/update-window.ts'
import { en } from '../src/locale.ts'
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
function setup(platform = 'darwin', fullscreen = true, destroyed = false) {
vi.stubGlobal('process', { ...process, platform })
const window = Object.assign(new EventEmitter(), {
isDestroyed: () => destroyed,
isFullScreen: () => fullscreen,
setFullScreen: vi.fn(),
})
return { window, prepare: () => prepareDesktopUpdateWindow(window, en.updateFullscreenTimeout) }
}
it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window', async (platform) => {
const f = setup(platform, false)
await expect(f.prepare()).resolves.toBe(true)
expect(f.window.setFullScreen).not.toHaveBeenCalled()
})
it('preserves Windows fullscreen', async () => {
const windows = setup('win32')
await expect(windows.prepare()).resolves.toBe(true)
expect(windows.window.setFullScreen).not.toHaveBeenCalled()
})
it('refuses a destroyed product window', async () => {
const destroyed = setup('darwin', true, true)
await expect(destroyed.prepare()).resolves.toBe(false)
expect(destroyed.window.setFullScreen).not.toHaveBeenCalled()
})
it.each(['leave-full-screen', 'closed'])('settles on %s and releases both listeners and the deadline', async (event) => {
vi.useFakeTimers()
const f = setup()
const preparing = f.prepare()
expect(f.window.setFullScreen).toHaveBeenCalledWith(false)
f.window.emit(event)
await expect(preparing).resolves.toBe(event === 'leave-full-screen')
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
})
it('rejects a stalled native transition and allows a later attempt to complete', async () => {
vi.useFakeTimers()
const f = setup()
const failure = expect(f.prepare()).rejects.toMatchObject({ kind: 'fullscreen-timeout', message: en.updateFullscreenTimeout })
await vi.advanceTimersByTimeAsync(5000)
await failure
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
const retry = f.prepare()
f.window.emit('leave-full-screen')
await expect(retry).resolves.toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('releases native listeners when requesting the transition throws', async () => {
vi.useFakeTimers()
const f = setup()
const nativeError = new Error('native window unavailable')
f.window.setFullScreen.mockImplementation(() => { throw nativeError })
await expect(f.prepare()).rejects.toBe(nativeError)
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,210 @@
// Exercises the shipped progress window without extracting or installing a payload.
#define WIN32_LEAN_AND_MEAN
#define UNICODE
#define NOMINMAX
#include <windows.h>
#include <algorithm>
#include <cstdio>
#include <vector>
extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window);
extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
BOOL dark, UINT dpi, DWORD controlHover, DWORD controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning);
extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window);
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
return DefWindowProcW(window, message, wparam, lparam);
}
int wmain(int argc, wchar_t** argv) {
if (argc < 6) {
std::fprintf(stderr, "Usage: render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]\n");
return 1;
}
const int dpi = _wtoi(argv[2]);
if (dpi < 96 || dpi > 384) return 2;
const bool dark = wcscmp(argv[3], L"dark") == 0;
if (!dark && wcscmp(argv[3], L"light") != 0) return 2;
wchar_t* end = nullptr;
const DWORD hoverArgb = wcstoul(argv[4], &end, 0);
if (!*argv[4] || *end || (hoverArgb >> 24) != 255) return 2;
const DWORD pressedArgb = wcstoul(argv[5], &end, 0);
if (!*argv[5] || *end || (pressedArgb >> 24) != 255) return 2;
const bool interactive = argc > 6;
SetProcessDPIAware();
WNDCLASSW type = {};
type.lpfnWndProc = WindowProc;
type.hInstance = GetModuleHandleW(nullptr);
type.lpszClassName = L"HarnessInstallerRenderTest";
type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
if (!RegisterClassW(&type)) return 3;
HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[6] : L"Installer rendering test",
WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96),
nullptr, nullptr, type.hInstance, nullptr);
if (!window || FAILED(InstallerApplyFrame(window))) return 4;
HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr);
HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr);
SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast<HANDLE>(1));
SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(50));
const auto createProgress = [&]() {
return InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, argv[1],
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
};
ShowWindow(window, SW_SHOWNOACTIVATE);
bool clientFrame = true;
for (int x : {8, 300, 580}) {
POINT point{MulDiv(x, dpi, 96), MulDiv(24, dpi, 96)};
ClientToScreen(window, &point);
clientFrame = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)) == HTCLIENT && clientFrame;
}
HWND failed = InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, L"",
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
ShowWindow(stock, SW_SHOW);
const bool failurePreservesStock = !failed && IsWindowVisible(stock);
if (failed) DestroyWindow(failed);
// Warm retained font and window-animation resources before checking page-owned GDI objects.
HWND warmup = createProgress();
if (!warmup) { DestroyWindow(window); return 5; }
UpdateWindow(warmup);
ShowWindow(window, SW_MINIMIZE);
ShowWindow(window, SW_RESTORE);
UpdateWindow(warmup);
DestroyWindow(warmup);
const DWORD initialGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
HWND progress = createProgress();
if (!progress) { DestroyWindow(window); return 5; }
UpdateWindow(progress);
if (interactive) {
MSG message;
BOOL result;
while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
return result == 0 ? 0 : 7;
}
RECT originalBounds, restoredBounds;
GetWindowRect(window, &originalBounds);
const auto captionPixel = [&](int x) -> COLORREF {
// Print into a private bitmap: visible-window clipping must not affect pixel assertions.
HDC dc = CreateCompatibleDC(nullptr);
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = MulDiv(600, dpi, 96);
info.bmiHeader.biHeight = -MulDiv(600, dpi, 96);
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
void* pixels = nullptr;
HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!dc || !bitmap) {
if (dc) DeleteDC(dc);
if (bitmap) DeleteObject(bitmap);
return CLR_INVALID;
}
HGDIOBJ previous = SelectObject(dc, bitmap);
if (!previous) { DeleteObject(bitmap); DeleteDC(dc); return CLR_INVALID; }
SendMessageW(progress, WM_PRINTCLIENT, reinterpret_cast<WPARAM>(dc), PRF_CLIENT);
const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96));
SelectObject(dc, previous);
DeleteObject(bitmap);
DeleteDC(dc);
return pixel;
};
const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255);
const auto colorref = [](DWORD argb) { return RGB((argb >> 16) & 255, (argb >> 8) & 255, argb & 255); };
const COLORREF hoverColor = colorref(hoverArgb);
const COLORREF pressedColor = colorref(pressedArgb);
const auto expectPixel = [&](int x, COLORREF expected) {
const COLORREF actual = captionPixel(x);
if (actual != CLR_INVALID && actual == expected) return true;
std::fprintf(stderr, "caption pixel x=%d dpi=%d expected=0x%08lx actual=0x%08lx\n", x, dpi, expected, actual);
return false;
};
bool captionStates = true;
ShowWindow(progress, SW_HIDE);
for (int x : {504, 548}) {
const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96));
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, 0, point);
captionStates = expectPixel(x, hoverColor) && captionStates;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point);
captionStates = expectPixel(x, pressedColor) && captionStates;
const int other = x == 504 ? 548 : 504;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(MulDiv(other + 20, dpi, 96), MulDiv(24, dpi, 96)));
captionStates = expectPixel(other, normalColor) && captionStates;
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1));
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point);
captionStates = expectPixel(x, pressedColor) && captionStates;
SendMessageW(progress, WM_CANCELMODE, 0, 0);
captionStates = expectPixel(x, hoverColor) && captionStates;
SendMessageW(progress, WM_MOUSELEAVE, 0, 0);
captionStates = expectPixel(x, normalColor) && captionStates;
}
ShowWindow(progress, SW_SHOW);
const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96));
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
bool buttonRelease = !IsIconic(window) && GetCapture() == progress;
SendMessageW(progress, WM_LBUTTONUP, 0, MAKELPARAM(-1, -1));
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
ReleaseCapture();
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
buttonRelease = buttonRelease && !IsIconic(window);
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
SendMessageW(progress, WM_CANCELMODE, 0, 0);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
const bool minimized = IsIconic(window) != FALSE;
ShowWindow(window, SW_RESTORE);
GetWindowRect(window, &restoredBounds);
const bool restored = !IsIconic(window) && EqualRect(&originalBounds, &restoredBounds);
const LPARAM closePoint = MAKELPARAM(MulDiv(568, dpi, 96), MulDiv(24, dpi, 96));
MSG closeMessage;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
bool closeRelease = !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
closeRelease = closeRelease && !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
SendMessageW(progress, WM_LBUTTONUP, 0, closePoint);
closeRelease = closeRelease && PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
LARGE_INTEGER frequency, started, finished;
QueryPerformanceFrequency(&frequency);
std::vector<double> samples;
// Full repaint is the exposure/restore path; fixture startup is excluded.
constexpr int frameCount = 300;
for (int frame = 0; frame < frameCount; ++frame) {
InvalidateRect(progress, nullptr, FALSE);
QueryPerformanceCounter(&started);
UpdateWindow(progress);
QueryPerformanceCounter(&finished);
samples.push_back(1000.0 * (finished.QuadPart - started.QuadPart) / frequency.QuadPart);
}
double total = 0;
for (double sample : samples) total += sample;
std::printf("samples_ms=[");
for (size_t index = 0; index < samples.size(); ++index) {
std::printf("%s%.3f", index ? "," : "", samples[index]);
}
std::printf("]\n");
std::sort(samples.begin(), samples.end());
std::printf("fixture=standalone-progress dpi=%d dark=%d frames=%d screen=%dx%d foreground=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n",
dpi, dark, frameCount, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), GetForegroundWindow() == window,
total / samples.size(), samples[samples.size() / 2], samples[samples.size() * 95 / 100], samples.back());
const bool completed = InstallerFinishProgress(progress) &&
GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast<HANDLE>(100);
WCHAR caption[160] = {};
GetWindowTextW(progress, caption, ARRAYSIZE(caption));
const bool finalCaption = wcscmp(caption, L"Extracting 100%") == 0;
DestroyWindow(progress);
const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
DestroyWindow(window);
std::printf("client_frame=%d stock_preserved=%d completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n",
clientFrame, failurePreservesStock, completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi);
return clientFrame && failurePreservesStock && completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6;
}
+22 -1
View File
@@ -1082,7 +1082,7 @@ else process.exit(1);
width: (node as HTMLImageElement).naturalWidth,
height: (node as HTMLImageElement).naturalHeight,
draggable: (node as HTMLImageElement).draggable,
}))).toEqual({ width: 1, height: 1, draggable: false })
}))).toEqual({ width: 1, height: 1, draggable: true })
const centering = await tinyImage.evaluate((node) => {
const image = node.getBoundingClientRect()
const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
@@ -1105,6 +1105,14 @@ else process.exit(1);
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
await expect.poll(() => imageZoom.innerText()).toBe('100%')
await tinyImage.dragTo(input)
const draggedPreview = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
const draggedBytes = await draggedPreview.evaluate(async (node: HTMLImageElement) =>
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer())))
expect(Buffer.from(draggedBytes)).toEqual(TINY_PNG)
expect(await input.innerText()).toBe('')
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
await openFile('large.svg')
await expect.poll(() => viewer.innerText()).toBe('Image')
await viewer.click()
@@ -1143,6 +1151,17 @@ else process.exit(1);
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0)
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
await imageScrollport.evaluate((node) => { node.scrollLeft = 0; node.scrollTop = 0 })
const panBox = await imageScrollport.boundingBox()
if (panBox === null) throw new Error('image viewport missing')
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
await page.mouse.move(panBox.x + 180, panBox.y + 180)
await page.mouse.down()
await page.mouse.move(panBox.x + 100, panBox.y + 100, { steps: 8 })
await expect.poll(() => imageScrollport.evaluate(node => [node.scrollLeft, node.scrollTop])).toEqual([80, 80])
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grabbing')
await page.mouse.up()
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
svgZoom = await revealDocumentZoom(page, preview)
await svgZoom.click()
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
@@ -1151,9 +1170,11 @@ else process.exit(1);
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
sections.push([
'## Image zoom', '',
'- Contained sidebar PNG drags into the composer as an original-byte image attachment',
'- Small PNG fit width remains at intrinsic size; 200% doubles it',
'- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false',
'- Image and Blob identities remain stable while zoom changes',
'- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor',
].join('\n'))
const releaseRead = Promise.withResolvers<undefined>()
@@ -42,11 +42,11 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 272K
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- text: 128K
- group "输入类型 1":
- text: 输入类型
@@ -49,10 +49,10 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- group "输入类型 1":
- text: 输入类型
- checkbox "文本" [checked]
@@ -49,10 +49,10 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- group "输入类型 1":
- text: 输入类型
- checkbox "文本"
+9
View File
@@ -319,6 +319,15 @@ describe('web e2e: Markdown image rendering', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
const input = page.locator('[data-composer-input]').first()
await workspaceImage.dragTo(input)
const dragged = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
await dragged.waitFor({ state: 'visible' })
expect(Buffer.from(await dragged.evaluate(async (node: HTMLImageElement) =>
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(PNG)
expect(await input.innerText()).toBe('')
expect(await page.getByRole('dialog', { name: 'Image preview', exact: true }).count()).toBe(0)
await page.getByRole('button', { name: 'Remove image valid.png', exact: true }).click()
await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) })
await page.mouse.move(0, 0)
await link.hover()
+3
View File
@@ -308,6 +308,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
await custom.getByLabel('模型 ID 1').fill('acme-large')
await custom.getByRole('button', { name: '模型选项 1' }).click()
expect(await custom.getByLabel('上下文窗口 1').getAttribute('placeholder')).toBe('1M')
expect(await custom.getByLabel('最大输出 token 数 1').getAttribute('placeholder')).toBe('128K')
expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
// The switch seeds off, low, medium, and high rows; this route serves no medium.
@@ -322,6 +324,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('acme-gateway:')
expect(document).toContain('reasoningEfforts:')
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
context: { contextWindow: 1_000_000 },
inputModalities: ['text', 'image'],
reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] },
})
+2 -2
View File
@@ -1888,13 +1888,13 @@ export interface PiAiProviderProfile {
compat?: PiAiCompatProfile
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* the installed catalog sizes (default 1,000,000). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* the installed catalog sizes (default 128,000). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
+2 -2
View File
@@ -1890,13 +1890,13 @@ export interface PiAiProviderProfile {
compat?: PiAiCompatProfile
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* the installed catalog sizes (default 1,000,000). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* the installed catalog sizes (default 128,000). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
@@ -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.1-rc.2/image-drag-labels/guide.md
/:
en: 0f508094085d53cd
zh: c4ed2bc22a35ca88
/image-drag-labels:
en: 0ff7158e0569f72f
zh: c7e157db1d84abc9
/image-drag-labels/change:
en: 82caa20a5d75c5fc
zh: 5f7b01a74c055336
/image-drag-labels/migration:
en: c355c2ce5478973a
zh: 9e29643003f1d367
@@ -0,0 +1,19 @@
---
kind: upgrade-guide
description: "Custom image UI consumers must supply a localized drag failure label."
---
# Image drag labels
English | [中文](guide.zh.md)
## Change
`MessageImageLabels` from `@deepseek-ai/dsh-client-ui-conversation/client` and `MarkdownDelegate.fileImages.labels` from `@deepseek-ai/dsh-client-ui-primitives` now require `dragFailed: string`. Custom plugins that construct either labels object must add this field to compile. The message reports an unsuccessful original-image drag and tells the user to retry.
## Migration
1. Add `dragFailed` to custom `MessageImageLabels` and `MarkdownDelegateProvider.fileImages.labels` objects.
2. Resolve the text through the plugin's locale dictionary, for example “Drag failed. Try again.” or “拖放失败,请重试”.
3. If a custom application shell renders managed image components or `MarkdownDelegateProvider.fileImages`, wrap that tree in `ImageDragProvider` from `@deepseek-ai/dsh-client-ui-primitives`. The standard application shell already supplies it; missing providers fail during rendering.
4. Rebuild the plugin and confirm that an unsuccessful image drag displays the localized message. Existing desktop, chat, and conversation consumers already supply the field.
@@ -0,0 +1,19 @@
---
kind: upgrade-guide
description: "自定义图片 UI 消费方必须提供本地化拖放失败文案。"
---
# 图片拖放文案
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-client-ui-conversation/client` 的 `MessageImageLabels` 和 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownDelegate.fileImages.labels` 现在要求 `dragFailed: string`。自定义插件若构造这两类 labels 对象,需要补充该字段才能编译。该文案报告原图拖放失败,并提示用户重试。
## 迁移
1. 为自定义 `MessageImageLabels` 和 `MarkdownDelegateProvider.fileImages.labels` 对象补充 `dragFailed`。
2. 通过插件的 locale 字典解析文案,例如“拖放失败,请重试”或“Drag failed. Try again.”。
3. 如果自定义应用外壳渲染受管理的图片组件或 `MarkdownDelegateProvider.fileImages`,用 `@deepseek-ai/dsh-client-ui-primitives` 的 `ImageDragProvider` 包裹组件树。标准应用外壳已提供它;缺少 Provider 会在渲染时报错。
4. 重新构建插件,确认图片拖放失败时显示本地化提示。现有桌面端、Chat 和 Conversation 消费方已提供该字段。
@@ -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.1-rc.2/pi-ai-default-capacities/guide.md
/:
en: 61a3259e0c1e7893
zh: 0c96fdeebcb2368f
/pi-ai-default-model-capacities:
en: cc30c7c81bcb89eb
zh: ee0b1b5f82676f9e
/pi-ai-default-model-capacities/change:
en: a47430fe02282532
zh: 4f8d65358bb36431
/pi-ai-default-model-capacities/migration:
en: ca3e5882f43f88c3
zh: 89d939f046a6ee1d
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "Undescribed pi-ai models inherit larger context and output capacities unless the provider configures explicit defaults."
---
# pi-ai default model capacities
English | [中文](guide.zh.md)
## Change
For models whose capacity is absent from both configuration and the installed catalog, `llm-pi-ai` raises `defaultContextWindow` from 262,144 to 1,000,000 tokens and `defaultMaxTokens` from 32,768 to 128,000 tokens. A larger assumed context window can delay automatic compaction. Explicit model capacities, catalog values, and provider defaults retain their precedence. The output-capacity fallback does not become a Harness request-level `maxTokens` default.
## Migration
1. Check the actual limits of custom endpoints. In the `llm-pi-ai` configuration in `cordis.patch.yml`, set `providers.<provider>.defaultContextWindow` and `providers.<provider>.defaultMaxTokens` when those limits differ. To retain the previous defaults, use `262144` and `32768` respectively.
2. For models with different limits on the same provider, set `contextWindow` and `maxTokens` on their `providers.<provider>.models` entries. An explicitly configured model `maxTokens` also supplies the Harness request default.
3. Reopen the profile configuration and confirm the saved numeric values. See the [provider configuration reference](../../../../packages/llm/llm-pi-ai/README.md).
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "配置和目录均未描述容量的 pi-ai 模型会继承更大的上下文与输出容量,除非提供方显式配置默认值。"
---
# pi-ai 默认模型容量
[English](guide.md) | 中文
## 变更
对于配置和已安装目录均未提供容量的模型,`llm-pi-ai` 将 `defaultContextWindow` 从 262,144 提高到 1,000,000 tokens,将 `defaultMaxTokens` 从 32,768 提高到 128,000 tokens。更大的假定上下文窗口可能推迟自动压缩。显式模型容量、目录值和提供方默认值仍保留原有优先级。输出容量回退值不会成为 Harness 请求级 `maxTokens` 默认值。
## 迁移
1. 核对自定义端点的实际限制。如果限制不同,在 `cordis.patch.yml` 的 `llm-pi-ai` 配置中设置 `providers.<provider>.defaultContextWindow` 和 `providers.<provider>.defaultMaxTokens`。如需保留原默认值,分别填写 `262144` 和 `32768`。
2. 同一提供方的模型限制不同时,在相应 `providers.<provider>.models` 条目上设置 `contextWindow` 和 `maxTokens`。显式配置的模型 `maxTokens` 同时提供 Harness 请求默认值。
3. 重新打开 profile 配置,确认保存的数值。参见[提供方配置参考](../../../../packages/llm/llm-pi-ai/README.zh.md)。
@@ -21,11 +21,11 @@
en: a77c8ec48e219aec
zh: 756d4fe881403006
/deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox:
en: 8b3d7729152d8060
zh: 815f932ccc9653f5
en: 38717dbf90a2520d
zh: b81915d2b06e18f8
/deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay:
en: 836a62c8f73366ac
zh: 35da7836eaf68a67
en: bf1a8ebd1a550396
zh: a4d736010ab6602e
/deepseek-ai-dsh-client-ui-attachment/understand-the-implementation:
en: 07204b7d538b7434
zh: 705cc6b0c9387cce
+5 -1
View File
@@ -37,9 +37,13 @@ In Chat, one user message presents files and images in a right-aligned wrapping
Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup.
Loaded conversation images can be dragged into the composer as image attachments or to another application as original files. Dragging does not open the lightbox. The shared image-transfer owner retains Web drag bytes until the gesture ends; Desktop uses the system file drag. A failed preparation or drag keeps the image visible and reports a localized retry message.
Internal Web image drags do not show the full-page overlay. A foreign or expired image token without actual files is ignored. Browser transfer limitations are documented by [ui-primitives](../ui-primitives/README.md#known-limitations-and-deferred-work).
### Drop overlay
While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
While an external file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
-----
+5 -1
View File
@@ -37,9 +37,13 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行
Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。
已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。
应用内 Web 图片拖放不显示全屏遮罩。没有实际文件的外来或过期图片令牌会被忽略。浏览器传递限制见 [ui-primitives](../ui-primitives/README.zh.md#known-limitations-and-deferred-work)。
### 拖放遮罩
文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
外部文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
-----
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './MessageImage.module.css'
@@ -143,7 +143,8 @@ export function MessageImage({ image, load, variant, labels }: {
{loadingThumbnail ? <IconLoadingOutlineRegular className={css.spinner} /> : labels.loading}
</span>
)
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
: <DraggableImage src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'} failureText={labels.dragFailed}
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseFillRegular, ImageDrop } from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail } from '../AttachmentRail.tsx'
import type { AttachmentRailItem } from '../AttachmentRail.tsx'
import { DropOverlay } from '../DropOverlay.tsx'
@@ -29,8 +29,8 @@ export function ComposerAttachments({
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
useEffect(() => {
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
const bindDrop = useCallback((readImageDrag: (transfer: DataTransfer) => File | undefined) => {
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readImageDrag)
}, [canAcceptDrop, onAddFiles])
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
@@ -40,6 +40,7 @@ export function ComposerAttachments({
return (
<>
<ImageDrop bind={bindDrop} />
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
@@ -1,4 +1,5 @@
/** Document drag-and-drop listeners owned by one mounted attachment view. */
import { IMAGE_DRAG_TOKEN_TYPE } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
@@ -25,6 +26,7 @@ function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]):
* @param onAddFiles - attachment intake callback.
* @param dragDepth - the view's retained nested-drag counter.
* @param setDragActive - publish whether a file drag is active.
* @param readImageDrag - resolve an original image retained by this application during a browser drag.
* @returns cleanup for exactly these listeners.
*/
export function installDocumentDropEvents(
@@ -32,10 +34,11 @@ export function installDocumentDropEvents(
onAddFiles: ComposerAttachmentsProps['onAddFiles'],
dragDepth: { current: number },
setDragActive: (active: boolean) => void,
readImageDrag: (transfer: DataTransfer) => File | undefined,
): () => void {
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
const dataTransfer = event.dataTransfer
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE))) return null
return dataTransfer
}
const reset = (): void => {
@@ -43,10 +46,11 @@ export function installDocumentDropEvents(
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (fileTransfer(event) === null) return
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
event.preventDefault()
dragDepth.current += 1
setDragActive(true)
if (!dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE)) setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
@@ -68,8 +72,9 @@ export function installDocumentDropEvents(
event.preventDefault()
reset()
if (canAcceptDrop) {
const files = [...dataTransfer.files]
onAddFiles(files, droppedDirectories(dataTransfer, files))
const image = dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE) ? readImageDrag(dataTransfer) : undefined
const files = image === undefined ? [...dataTransfer.files] : [image]
if (files.length > 0) onAddFiles(files, droppedDirectories(dataTransfer, files))
}
}
document.addEventListener('dragenter', onDragEnter)
@@ -21,6 +21,7 @@ export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLab
*/
export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels {
return {
dragFailed: t('image.dragFailed'),
image: t('image.label'),
open: t('image.openOriginal'),
openNamed: label => t('image.openOriginalLabel', { label }),
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { cleanup, fireEvent, render as renderBare } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import type {
ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -306,3 +308,27 @@ describe('ComposerAttachments file drafts', () => {
expect(view.getByTitle('.env').textContent).toContain('ENV 3B')
})
})
it('accepts only the current app image token without showing an external-file overlay', async () => {
const { DraggableImage } = await import('@deepseek-ai/dsh-client-ui-primitives')
const file = new File([Uint8Array.of(1, 2)], 'original.png', { type: 'image/png' })
function Source() {
return <DraggableImage alt="drag source" src="blob:original" name={file.name} failureText="拖放失败,请重试" original={file} />
}
const onAddFiles = vi.fn()
const view = render(<ImageDragProvider><Source /><ComposerAttachments {...props({ onAddFiles })} /></ImageDragProvider>)
const data = new Map<string, string>()
const transfer = {
types: ['application/x-dsh-image'], items: Object.assign([], { clear: vi.fn() }), files: [],
getData: (key: string) => data.get(key) ?? '', setData: (key: string, value: string) => data.set(key, value),
}
fireEvent.pointerEnter(view.getByAltText('drag source'))
fireEvent.dragStart(view.getByAltText('drag source'), { dataTransfer: transfer })
fireEvent.dragEnter(document.body, { dataTransfer: transfer })
expect(view.queryByText('文件或图片拖动到此处即可添加')).toBeNull()
fireEvent.drop(document.body, { dataTransfer: transfer })
expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([file], new Set())
data.set('application/x-dsh-image', 'foreign')
fireEvent.drop(document.body, { dataTransfer: transfer })
expect(onAddFiles).toHaveBeenCalledOnce()
})
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
/** Independent domain image positions with one shared official gallery Factory. */
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
import { render } from '@testing-library/react'
import { act, cleanup, within } from '@testing-library/react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
@@ -20,6 +22,7 @@ const SLOTS = [
async function bench() {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(<ImageDragProvider>{runtime.slots.renderSlot('root', {})}</ImageDragProvider>))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -3,7 +3,9 @@
import { useState } from 'react'
import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderBare, waitFor } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -18,6 +20,7 @@ afterEach(cleanup)
const labels: MessageImageLabels = {
image: '图片',
dragFailed: '拖放失败,请重试',
open: '查看原图',
openNamed: label => `${label},点击查看原图`,
loading: '图片加载中…',
@@ -270,6 +273,7 @@ describe('ImageGallery', () => {
it('uses complete Factory labels unchanged and restores defaults without remounting the image', async () => {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(runtime.slots.renderSlot('root', {})))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -308,6 +312,7 @@ describe('ImageGallery', () => {
it('renders the conversation slot entry with translated labels', async () => {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(runtime.slots.renderSlot('root', {})))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -86,7 +86,7 @@ export function ChatView({
const fileImages = useMemo(() => ({
resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)),
labels: {
open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
dragFailed: t('image.dragFailed'), open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
dialog: t('image.dialog'), close: t('image.close'),
},
}), [cwd, t])
@@ -67,6 +67,7 @@ export const zh = {
'view.chat': '对话',
'image.open': '查看大图',
'image.loading': '加载图片…',
'image.dragFailed': '拖放失败,请重试',
'image.failed': '图片无法预览',
'image.dialog': '图片预览',
'image.close': '关闭图片预览',
@@ -269,6 +270,7 @@ export const en = {
'view.chat': 'Chat',
'image.open': 'View full image',
'image.loading': 'Loading image…',
'image.dragFailed': 'Drag failed. Try again.',
'image.failed': 'Image preview unavailable',
'image.dialog': 'Image preview',
'image.close': 'Close image preview',
@@ -131,6 +131,8 @@ export interface MessageImageLabels {
loading: string
/** Retry-control label shown when the load fails. */
loadFailed: string
/** Failed original-image drag, with retry guidance. */
dragFailed: string
/** Lightbox strings forwarded to the opened preview. */
lightbox: ImageLightboxLabels
}
@@ -46,6 +46,7 @@ export const zh = {
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
'image.loading': '图片加载中…',
'image.dragFailed': '拖放失败,请重试',
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
@@ -426,6 +427,7 @@ export const en = {
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',
'image.loading': 'Loading image…',
'image.dragFailed': 'Drag failed. Try again.',
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
+2 -1
View File
@@ -56,7 +56,8 @@
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*"
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*"
},
"files": [
"locale/*.json",
@@ -20,6 +20,7 @@ import type {
PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
} from '@deepseek-ai/dsh-client-ui-slots'
import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocumentTitle } from './DocumentTitle.tsx'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
@@ -256,52 +257,54 @@ export function AppFrame({
const leadingMounted = darwin && sidebarCollapsed
return (
<div
ref={frameRef}
className={css.frame}
style={{
...(document.documentElement.hasAttribute('data-windows-titlebar')
? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
gridTemplateColumns:
`${cols.sidebar}px minmax(${cols.rightbar === 0 ? 0 : CENTER_MIN}px, 1fr) minmax(0px, ${rightbarMax}px)`,
}}
data-sidebar-collapsed={sidebarCollapsed || undefined}
data-rightbar-collapsed={cols.rightbar === 0 || undefined}
data-rightbar-fullscreen={layoutInfo.rightbarFullscreen || undefined}
data-rightbar-instant={layoutInfo.rightbarInstant || undefined}
data-dragging={dragging || undefined}
data-animating={animating > 0 || undefined}
>
<DocumentTitle
productTitle={productTitle}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
/>
<div className={css.sidebarCol}>
{sidebar}
</div>
<>
<CenterColumn>{main}</CenterColumn>
<RightbarColumn>
{renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
</RightbarColumn>
</>
<div className={css.bottomRow} data-shell-bottom>
{renderSlot('shell.bottom', {})}
</div>
<div className={css.overlayLayer} data-shell-overlay>
{overlays}
</div>
{leadingMounted && (
<div className={css.leadingSeat} data-shell-leading>
{leading}
<ImageDragProvider>
<div
ref={frameRef}
className={css.frame}
style={{
...(document.documentElement.hasAttribute('data-windows-titlebar')
? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
gridTemplateColumns:
`${cols.sidebar}px minmax(${cols.rightbar === 0 ? 0 : CENTER_MIN}px, 1fr) minmax(0px, ${rightbarMax}px)`,
}}
data-sidebar-collapsed={sidebarCollapsed || undefined}
data-rightbar-collapsed={cols.rightbar === 0 || undefined}
data-rightbar-fullscreen={layoutInfo.rightbarFullscreen || undefined}
data-rightbar-instant={layoutInfo.rightbarInstant || undefined}
data-dragging={dragging || undefined}
data-animating={animating > 0 || undefined}
>
<DocumentTitle
productTitle={productTitle}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
/>
<div className={css.sidebarCol}>
{sidebar}
</div>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
<DragHandle side="rightbar" left={viewport - normal.rightbar} onStart={onRightbarStart} onDrag={onRightbarDrag} onEnd={onDragEnd} />
)}
</div>
<>
<CenterColumn>{main}</CenterColumn>
<RightbarColumn>
{renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
</RightbarColumn>
</>
<div className={css.bottomRow} data-shell-bottom>
{renderSlot('shell.bottom', {})}
</div>
<div className={css.overlayLayer} data-shell-overlay>
{overlays}
</div>
{leadingMounted && (
<div className={css.leadingSeat} data-shell-leading>
{leading}
</div>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
<DragHandle side="rightbar" left={viewport - normal.rightbar} onStart={onRightbarStart} onDrag={onRightbarDrag} onEnd={onDragEnd} />
)}
</div>
</ImageDragProvider>
)
}
@@ -18,14 +18,14 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 6f5457b84fc9db09
zh: 85993f1c44bfee2c
en: bfcb301478943364
zh: 5e88e5f3592da502
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: 12fe9d9b1410edef
zh: f2dfdc01170d7cce
en: bf371fb93897a3db
zh: a63bed4089e2aac9
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
@@ -54,8 +54,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work:
en: 22fc14aa3a224552
zh: aa6309e84ad8c2a3
en: 843daa09327f1aad
zh: 434560a9da1e6ddf
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+4
View File
@@ -60,6 +60,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. |
| `DraggableImage`, `ImageDrop`, `ImageDragProvider` | Original-image gestures, drop binding, and application-owned transfer feedback. |
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
@@ -111,6 +112,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal.
`ImageDragProvider` owns transient file transfers and failure feedback for one application. `DraggableImage` and `ImageDrop` require this ancestor and fail during rendering if it is absent; feature components pass image props or a listener-installation callback without reading React context. The provider resolves the versioned Desktop bridge once. Original bytes are prepared on hover or primary pointer down, never on mount, and obsolete preparations are cancelled. Managed Web transfers carry an application-local token plus Chromium’s `DownloadURL`; only the owning application resolves the token to the original `File`. These managed transfers do not place internal URLs or alt text in `text/plain`.
### Localizing copy
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
@@ -199,6 +202,7 @@ None; this package neither assembles nor sends a provider request.
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs.
- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
@@ -60,6 +60,7 @@ kind: "package-library"
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 |
| `DraggableImage`、`ImageDrop`、`ImageDragProvider` | 原图拖放、接收监听绑定与应用持有的传递反馈。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
@@ -111,6 +112,8 @@ kind: "package-library"
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。
`ImageDragProvider` 持有单个应用的临时文件传递状态和失败提示。`DraggableImage` 与 `ImageDrop` 要求该祖先组件,缺失时在渲染阶段报错;业务组件只传图片属性或监听器安装回调,不读取 React context。Provider 只解析一次带版本的 Desktop 桥接。原图在悬停或主指针按下时准备,不在挂载时准备;过期准备会取消。受管理的 Web 拖放携带应用内令牌及 Chromium 的 `DownloadURL`,只有持有令牌的应用能将其解析为原始 `File`。这些受管理的拖放不会将内部 URL 或 alt 文本放入 `text/plain`。
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
@@ -199,6 +202,7 @@ kind: "package-library"
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。
- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
- **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
@@ -0,0 +1,24 @@
/** Original-image drag gestures owned by a presentation primitive. */
import { forwardRef, type ImgHTMLAttributes } from 'react'
import { useImageDrag } from './image-drag.ts'
/** Image presentation plus the original bytes and localized drag failure. */
export interface DraggableImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerDown' | 'onClickCapture'> {
readonly src: string
readonly name: string
readonly failureText: string
readonly original?: File | undefined
}
/**
* Render an image with original-file dragging; requires an ancestor ImageDragProvider.
* @param props - image attributes, original filename and localized failure text.
* @param ref - image element for measurement and pointer-mode bindings.
* @returns a draggable image that suppresses only the mouse click following its drag.
*/
export const DraggableImage = forwardRef<HTMLImageElement, DraggableImageProps>(function DraggableImage(
{ src, name, failureText, original, ...props }, ref,
) {
const drag = useImageDrag(src, name, failureText, original)
return <img {...props} {...drag} src={src} ref={ref} draggable />
})
@@ -0,0 +1,77 @@
/** Application-lifetime feedback for image drag operations. */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Toast } from './Toast.tsx'
import type { DesktopImageDrag } from './image-drag.ts'
/** DataTransfer type for an application-local drag token; file bytes remain in the owning mounted application. */
export const IMAGE_DRAG_TOKEN_TYPE = 'application/x-dsh-image'
interface ImageTransfer {
desktop: DesktopImageDrag | undefined
report: (text: string) => void
begin: (file: File) => string
end: () => void
read: (transfer: DataTransfer) => File | undefined
}
const ImageDragFeedback = createContext<ImageTransfer | undefined>(undefined)
/**
* Keep drag failures visible when their source image or conversation unmounts.
* @param props - application children.
* @returns children and the current localized failure toast.
*/
export function ImageDragProvider({ children }: { readonly children: ReactNode }): ReactNode {
const [desktop] = useState(() => {
const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; imageDrag?: DesktopImageDrag } }).dshDesktop
return bridge?.protocolVersion === 1 ? bridge.imageDrag : undefined
})
const active = useRef<{ token: string; file: File }>()
const sequence = useRef(0)
const [failure, setFailure] = useState<{ text: string; sequence: number }>()
const report = useCallback((text: string) => { setFailure({ text, sequence: ++sequence.current }) }, [])
const dismiss = useCallback(() => { setFailure(undefined) }, [])
const begin = useCallback((file: File): string => {
const token = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
active.current = { token, file }
return token
}, [])
const end = useCallback(() => { active.current = undefined }, [])
useEffect(() => {
window.addEventListener('dragend', end)
return () => { window.removeEventListener('dragend', end) }
}, [end])
const read = useCallback((transfer: DataTransfer): File | undefined => {
const current = active.current
return current !== undefined && current.token === transfer.getData(IMAGE_DRAG_TOKEN_TYPE) ? current.file : undefined
}, [])
const value = useMemo(() => ({ desktop, report, begin, end, read }), [desktop, report, begin, end, read])
return <ImageDragFeedback.Provider value={value}>
{children}
{failure !== undefined && <Toast key={failure.sequence} text={failure.text} onDone={dismiss} />}
</ImageDragFeedback.Provider>
}
/** Requires ImageDragProvider; @returns the mounted application's image transfer owner. */
export function useImageDragTransfer(): ImageTransfer {
const transfer = useContext(ImageDragFeedback)
if (transfer === undefined) throw new Error('Image dragging requires ImageDragProvider')
return transfer
}
/** Requires ImageDragProvider; @returns a reader for this application's active original-image drag. */
export function useImageDrop(): (transfer: DataTransfer) => File | undefined {
return useImageDragTransfer().read
}
/**
* Bind a drop target to its application's original-image reader without exposing React context.
* Requires an ancestor ImageDragProvider.
* @param props - listener installer; its cleanup runs on replacement or unmount.
* @returns no visible content.
*/
export function ImageDrop({ bind }: { readonly bind: (read: (transfer: DataTransfer) => File | undefined) => () => void }): ReactNode {
const read = useImageDrop()
useEffect(() => bind(read), [bind, read])
return null
}
@@ -0,0 +1,124 @@
/** Original image files supplied to browser and native drag sessions. */
import { useCallback, useEffect, useRef, type DragEvent, type MouseEvent, type PointerEvent } from 'react'
import { IMAGE_DRAG_TOKEN_TYPE, useImageDragTransfer } from './ImageDragProvider.tsx'
/** Application-owned native image transfer; prepared handles never expose host paths. */
export interface DesktopImageDrag {
/** @returns an opaque handle for the original file, retained until release or application exit. */
prepare: (bytes: Uint8Array, name: string, mediaType: string) => Promise<string>
/** Start a system copy drag from an already prepared handle. */
start: (handle: string) => Promise<boolean>
/** Release an unused handle; files already handed to the OS survive until application exit. */
release: (handle: string) => void
}
interface PreparedImage {
readonly file: File
readonly handle?: string
readonly release?: () => void
}
interface ImageDragHandlers {
onPointerEnter: () => void
onPointerDown: (event: PointerEvent<HTMLImageElement>) => void
onDragStart: (event: DragEvent<HTMLImageElement>) => void
onDragEnd: () => void
onClickCapture: (event: MouseEvent) => void
}
/**
* Prepare original bytes on hover and supply them synchronously when dragging begins.
* Requires an ancestor ImageDragProvider; it owns transfer bytes and feedback across source unmounts.
* @param src - authorized image URL; changing it retires pending preparation.
* @param name - suggested original filename, without a directory.
* @param failureText - localized failure message, including a retry action.
* @param original - already loaded original file; omitted sources are read from their image URL.
* @returns drag handlers; cancellation never starts a late drag.
*/
export function useImageDrag(src: string, name: string, failureText: string, original?: File): ImageDragHandlers {
const state = useRef<{
source: string
prepared?: PreparedImage | undefined
pending?: Promise<void> | undefined
abort: AbortController
}>()
const dragged = useRef(false)
const transfer = useImageDragTransfer()
const reportFailure = transfer.report
const desktop = transfer.desktop
const prepare = useCallback((): void => {
const current = state.current
if (src === '' || current === undefined || current.source !== src || current.pending !== undefined || current.prepared !== undefined) return
current.pending = (async () => {
let file = original
if (file === undefined) {
const response = await fetch(src, { signal: current.abort.signal, referrerPolicy: 'no-referrer' })
if (!response.ok) throw new Error('Image read failed')
const blob = await response.blob()
if (!blob.type.startsWith('image/')) throw new Error('Image media type unavailable')
file = new File([blob], name, { type: blob.type })
}
if (current.abort.signal.aborted) return
if (desktop === undefined) {
current.prepared = { file }
return
}
const handle = await desktop.prepare(new Uint8Array(await file.arrayBuffer()), file.name, file.type)
if (state.current !== current) {
desktop.release(handle)
return
}
current.prepared = { file, handle, release: () => { desktop.release(handle) } }
})().catch((_error: unknown) => {
// Preparation is speculative; a failed user gesture reports through the owning image.
}).finally(() => { current.pending = undefined })
}, [src, name, original, desktop])
useEffect(() => {
const current: NonNullable<typeof state.current> = { source: src, abort: new AbortController() }
state.current = current
return () => {
current.abort.abort()
current.prepared?.release?.()
state.current = undefined
}
}, [src, name, original, desktop, prepare])
const onDragStart = useCallback((event: DragEvent<HTMLImageElement>): void => {
if (event.defaultPrevented || src === '') return
dragged.current = true
const prepared = state.current?.source === src ? state.current.prepared : undefined
if (prepared === undefined) {
const url = new URL(src, location.href)
if (original === undefined && (url.protocol === 'https:' || url.protocol === 'http:') && url.origin !== location.origin) return
event.preventDefault()
prepare()
reportFailure(failureText)
return
}
event.dataTransfer.items.clear()
event.dataTransfer.effectAllowed = 'copy'
if (desktop !== undefined && prepared.handle !== undefined) {
event.preventDefault()
const failed = (): void => {
if (state.current?.prepared === prepared) state.current.prepared = undefined
reportFailure(failureText)
}
void desktop.start(prepared.handle).then((started) => { if (!started) failed() }, failed)
return
}
event.dataTransfer.setData(IMAGE_DRAG_TOKEN_TYPE, transfer.begin(prepared.file))
event.dataTransfer.setData('DownloadURL', `${prepared.file.type}:${prepared.file.name}:${src}`)
}, [src, desktop, prepare, reportFailure, failureText, transfer])
const onPointerDown = useCallback((event: PointerEvent<HTMLImageElement>): void => {
dragged.current = false
if (event.button === 0) prepare()
}, [prepare])
const onClickCapture = useCallback((event: MouseEvent): void => {
if (event.detail === 0) { dragged.current = false; return }
if (!dragged.current) return
event.preventDefault()
event.stopPropagation()
dragged.current = false
}, [])
return { onPointerEnter: prepare, onPointerDown, onDragStart, onDragEnd: transfer.end, onClickCapture }
}
@@ -67,6 +67,10 @@ export { pointerModality } from './input-modality.ts'
export { Toast } from './Toast.tsx'
export { fileSizeText } from './file-size.ts'
export { writeClipboard } from './clipboard.ts'
export { IMAGE_DRAG_TOKEN_TYPE, ImageDragProvider, ImageDrop } from './ImageDragProvider.tsx'
export { DraggableImage } from './DraggableImage.tsx'
export type { DraggableImageProps } from './DraggableImage.tsx'
export type { DesktopImageDrag } from './image-drag.ts'
export { SettingsForm } from './settings-form/SettingsForm.tsx'
export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx'
export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx'
@@ -14,7 +14,7 @@ export interface MarkdownDelegate {
/** Image previews for decoded local paths in this owner's workspace. */
readonly fileImages?: {
resolve: (path: string) => string | undefined
labels: ImageLightboxLabels & { open: string; loading: string; failed: string }
labels: ImageLightboxLabels & { open: string; loading: string; failed: string; dragFailed: string }
} | undefined
/** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
@@ -29,6 +29,7 @@ import { renderTexToReact } from './katex.tsx'
import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx'
import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
import { HoverCard } from '../HoverCard.tsx'
import { DraggableImage } from '../DraggableImage.tsx'
import { ImageLightbox } from '../ImageLightbox.tsx'
import { ImagePreview } from '../ImagePreview.tsx'
import type { PositionedBlock } from './incremental.ts'
@@ -651,9 +652,11 @@ function LoadedMarkdownImage({ src, alt, destination, preview }: {
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
if (failed) return <span className={css.imageAlt}>{preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination}</span>
const img = <img className={css.image} src={src} alt={alt}
onError={() => { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" />
if (preview === undefined) return img
const imageProps = { className: css.image, src, alt, onError: () => { setFailed(true) },
loading: 'lazy' as const, decoding: 'async' as const, referrerPolicy: 'no-referrer' as const }
if (preview === undefined) return <img {...imageProps} />
const img = <DraggableImage {...imageProps} name={destination.split('/').at(-1)?.split(/[?#]/u)[0] || 'image'}
failureText={preview.labels.dragFailed} />
return <>
<button type="button" className={css.imageButton} title={preview.labels.open}
aria-label={alt ? `${preview.labels.open}: ${alt}` : preview.labels.open}
@@ -0,0 +1,246 @@
// @vitest-environment jsdom
import { useEffect } from 'react'
import { File as NodeFile } from 'node:buffer'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { IMAGE_DRAG_TOKEN_TYPE, ImageDragProvider, useImageDrop } from '../src/ImageDragProvider.tsx'
import { DraggableImage } from '../src/DraggableImage.tsx'
import { useImageDrag, type DesktopImageDrag } from '../src/image-drag.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
function Image({ src, file, onOpen }: { src: string; file?: File; onOpen?: () => void }) {
return <button onClick={onOpen}><DraggableImage alt="original" src={src} name="original.png" failureText="Image drag failed. Try again." original={file} /></button>
}
function transfer() {
const add = vi.fn()
const values = new Map<string, string>()
return { items: { add, clear: vi.fn() }, getData: (type: string) => values.get(type) ?? '',
setData: vi.fn((type: string, value: string) => { values.set(type, value) }), effectAllowed: 'none' }
}
function native() {
const bridge: DesktopImageDrag = { prepare: vi.fn(async () => 'handle'), start: vi.fn(async () => true), release: vi.fn() }
vi.stubGlobal('dshDesktop', { protocolVersion: 1, imageDrag: bridge })
return bridge
}
function original() {
return new NodeFile([new Uint8Array([137, 80, 78, 71])], 'original.png', { type: 'image/png' }) as File
}
it('hands original bytes to the browser without alt text or an internal URI payload', async () => {
const file = original()
const open = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={file} onOpen={open} /></ImageDragProvider>)
const dataTransfer = transfer()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
await act(async () => { fireEvent.dragStart(screen.getByRole('img'), { dataTransfer }) })
expect(dataTransfer.items.add).not.toHaveBeenCalled()
expect(dataTransfer.items.clear).toHaveBeenCalledOnce()
expect(dataTransfer.setData).toHaveBeenCalledWith(IMAGE_DRAG_TOKEN_TYPE, expect.any(String))
expect(dataTransfer.effectAllowed).toBe('copy')
expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', 'image/png:original.png:blob:original')
fireEvent.click(screen.getByRole('img'), { detail: 1 })
expect(open).not.toHaveBeenCalled()
fireEvent.pointerDown(screen.getByRole('img'), { button: 0 })
fireEvent.click(screen.getByRole('img'), { detail: 1 })
expect(open).toHaveBeenCalledOnce()
})
it('prepares before the gesture, starts native file dragging and releases its handle on unmount', async () => {
const bridge = native()
const file = original()
const view = render(<ImageDragProvider><Image src="blob:original" file={file} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
expect(bridge.prepare).toHaveBeenCalledExactlyOnceWith(new Uint8Array([137, 80, 78, 71]), 'original.png', 'image/png')
const dataTransfer = transfer()
await act(async () => { expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false) })
expect(bridge.start).toHaveBeenCalledExactlyOnceWith('handle')
expect(dataTransfer.items.add).not.toHaveBeenCalled()
view.unmount()
expect(bridge.release).toHaveBeenCalledExactlyOnceWith('handle')
})
it('cancels an unready gesture and releases late preparation without starting a delayed drag', async () => {
const bridge = native()
const pending = Promise.withResolvers<string>()
vi.mocked(bridge.prepare).mockReturnValue(pending.promise)
const view = render(<ImageDragProvider><Image src="blob:first" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
view.rerender(<ImageDragProvider><span>Image closed</span></ImageDragProvider>)
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
await act(async () => { pending.resolve('late'); await pending.promise })
expect(bridge.release).toHaveBeenCalledExactlyOnceWith('late')
expect(bridge.start).not.toHaveBeenCalled()
})
it('reports native failure even after its source image closes', async () => {
const bridge = native()
const pending = Promise.withResolvers<boolean>()
vi.mocked(bridge.start).mockReturnValue(pending.promise)
const view = render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
view.rerender(<ImageDragProvider><span>Image closed</span></ImageDragProvider>)
await act(async () => { pending.resolve(false); await pending.promise })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
})
it('never exports a failed read or starts a native drag after fetch cancellation', async () => {
const bridge = native()
const pending = Promise.withResolvers<Response>()
const fetchImage = vi.fn<typeof fetch>().mockReturnValue(pending.promise)
vi.stubGlobal('fetch', fetchImage)
const view = render(<ImageDragProvider><Image src="https://example.test/image.png" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
const request = fetchImage.mock.calls[0]?.[1]
view.rerender(<ImageDragProvider><span>Closed</span></ImageDragProvider>)
expect(request?.signal?.aborted).toBe(true)
await act(async () => { pending.resolve(new Response('unavailable', { status: 404 })); await pending.promise })
expect(bridge.prepare).not.toHaveBeenCalled()
expect(bridge.start).not.toHaveBeenCalled()
})
function DropTarget({ receive }: { receive: (file: File | undefined) => void }) {
const read = useImageDrop()
return <div data-testid="drop" onDrop={(event) => { event.preventDefault(); receive(read(event.dataTransfer)) }} />
}
it('resolves only the active application drag token and clears it after dragging ends', async () => {
const file = original()
const receive = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={file} /><DropTarget receive={receive} /></ImageDragProvider>)
const dataTransfer = transfer()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenLastCalledWith(file)
fireEvent.dragEnd(screen.getByRole('img'), { dataTransfer })
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenLastCalledWith(undefined)
})
it('does not prepare on mount, ignores secondary buttons, and keeps keyboard activation after dragging', async () => {
const bridge = native()
const open = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={original()} onOpen={open} /></ImageDragProvider>)
expect(bridge.prepare).not.toHaveBeenCalled()
fireEvent.pointerDown(screen.getByRole('img'), { button: 2 })
expect(bridge.prepare).not.toHaveBeenCalled()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
fireEvent.dragEnd(screen.getByRole('img'), { dataTransfer: transfer() })
fireEvent.click(screen.getByRole('button'), { detail: 0 })
expect(open).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('img'), { detail: 0 })
expect(open).toHaveBeenCalledTimes(2)
})
it('reports rejected native starts and accepts later preparation attempts', async () => {
const bridge = native()
vi.mocked(bridge.prepare).mockRejectedValueOnce(new Error('read failed'))
vi.mocked(bridge.start).mockRejectedValueOnce(new Error('drag failed'))
render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
await act(async () => { fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() }) })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
})
it.each(['image/png', 'text/plain'])('cancels late fetched %s responses after unmount', async (type) => {
const bridge = native()
const pending = Promise.withResolvers<Response>()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockReturnValue(pending.promise))
const view = render(<ImageDragProvider><Image src="https://example.test/image.png" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
view.unmount()
await act(async () => { pending.resolve(new Response('bytes', { headers: { 'Content-Type': type } })); await pending.promise })
expect(bridge.prepare).not.toHaveBeenCalled()
})
it('fetches a readable source once and rejects a foreign drag token', async () => {
const fetchImage = vi.fn<typeof fetch>().mockResolvedValue(new Response('bytes', { headers: { 'Content-Type': 'image/png' } }))
vi.stubGlobal('fetch', fetchImage)
const receive = vi.fn()
render(<ImageDragProvider><Image src="/image.png" /><DropTarget receive={receive} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')); fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.pointerEnter(screen.getByRole('img'))
expect(fetchImage).toHaveBeenCalledOnce()
const dataTransfer = transfer()
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })
dataTransfer.setData(IMAGE_DRAG_TOKEN_TYPE, 'foreign')
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenCalledWith(undefined)
})
it.each(['https://other.test/image.png', 'http://other.test/image.png'])('leaves unreadable external %s drags native', async (src) => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('CORS')))
render(<ImageDragProvider><Image src={src} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
const dataTransfer = transfer()
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(true)
expect(dataTransfer.items.clear).not.toHaveBeenCalled()
expect(screen.queryByRole('alert')).toBeNull()
})
it('keeps disabled and already-cancelled gestures inert', () => {
const bridge = native()
const view = render(<ImageDragProvider><Image src="" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
const dataTransfer = transfer()
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(true)
view.rerender(<ImageDragProvider><div onDragStartCapture={(event) => { event.preventDefault() }}><Image src="blob:original" file={original()} /></div></ImageDragProvider>)
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false)
expect(bridge.prepare).not.toHaveBeenCalled()
expect(bridge.start).not.toHaveBeenCalled()
})
it('ignores unsupported desktop protocols and requires a provider for drop resolution', () => {
vi.stubGlobal('dshDesktop', { protocolVersion: 2 })
const receive = vi.fn()
const view = render(<ImageDragProvider><DropTarget receive={receive} /></ImageDragProvider>)
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer: transfer() })
expect(receive).toHaveBeenCalledWith(undefined)
view.unmount()
function WithoutProvider() { useImageDrop(); return null }
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<WithoutProvider />)).toThrow('Image dragging requires ImageDragProvider')
error.mockRestore()
})
it('dismisses failure feedback after its presentation lifetime', async () => {
vi.useFakeTimers()
try {
render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
expect(screen.getByRole('alert')).toBeTruthy()
await act(async () => { await vi.runAllTimersAsync() })
expect(screen.queryByRole('alert')).toBeNull()
} finally { vi.useRealTimers() }
})
it('reports an unready same-origin HTTP image instead of exporting its URL', async () => {
const pending = Promise.withResolvers<Response>()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockReturnValue(pending.promise))
render(<ImageDragProvider><Image src="/image.png" /></ImageDragProvider>)
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })).toBe(false)
expect(screen.getByRole('alert')).toBeTruthy()
await act(async () => { pending.resolve(new Response('failure', { status: 404 })); await pending.promise })
})
it('cancels a handler retained after its image unmounts', () => {
let handlers!: ReturnType<typeof useImageDrag>
function Source() {
const drag = useImageDrag('blob:original', 'original.png', 'Image drag failed')
useEffect(() => { handlers = drag }, [drag])
return null
}
const view = render(<ImageDragProvider><Source /></ImageDragProvider>)
view.rerender(<ImageDragProvider><span>Closed</span></ImageDragProvider>)
const event = { defaultPrevented: false, preventDefault: vi.fn(), dataTransfer: transfer() }
const captured: Parameters<typeof handlers.onDragStart>[0] = Object.assign({} as Parameters<typeof handlers.onDragStart>[0], event)
act(() => { handlers.onDragStart(captured) })
expect(event.preventDefault).toHaveBeenCalledOnce()
})
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderBare, screen } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { afterEach, expect, it, vi } from 'vitest'
import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path'
import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx'
@@ -7,7 +9,7 @@ import { MarkdownText } from './markdown-test-components.tsx'
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
const labels = { dragFailed: 'Image drag failed', open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels }
function mount(text: string, streaming = false) {
const openFile = vi.fn()
@@ -147,3 +149,8 @@ it.each([parseGfm, parseGfmWithMath])('uses the shared image categories for bare
}
expect(JSON.stringify(parse('![report](/work/test dir/report.pdf)'))).not.toContain('"type":"image"')
})
it('uses the fallback filename for an external image URL without a path', () => {
mount('![Remote](https://example.com/)')
expect(screen.getByRole('img').getAttribute('src')).toBe('https://example.com/')
})
@@ -15,8 +15,8 @@
en: 2843d46e95311bce
zh: ec11bdeb5d3ce818
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
en: 2bc21e57fe17eab9
zh: e441cd313f747b22
en: 4285513efdb1be68
zh: b28089509254e8d5
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
en: 8b8a7c6287bb303b
zh: 19535112531aedd5
@@ -35,7 +35,7 @@ When the page reconnects to a Host running another release, a **Service version
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs.
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs, including guidance to exit fullscreen and retry after a fullscreen-transition timeout.
Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (Desktop: `Mod+,`; Windows/macOS Web: `Mod+Alt+,`) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal.
@@ -35,7 +35,7 @@ kind: "package-reference"
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致,包括全屏切换超时后退出全屏并重试的指引。
设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(Desktop 为 `Mod+,`,Windows/macOS Web 为 `Mod+Alt+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。
@@ -37,6 +37,7 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l
'stop-failed': t('desktop.update.stopFailed'),
'tasks-changed': t('desktop.update.tasksChanged'),
'tasks-unavailable': t('desktop.update.tasksUnavailable'),
'fullscreen-timeout': t('desktop.update.fullscreenTimeout'),
}
return { label, detail: failures[state.failure ?? 'install'] }
}
@@ -19,6 +19,7 @@ export const zh = {
'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。',
'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。',
'desktop.update.installNetworkFailed': '安装更新失败,请检查网络连接后重试。',
'desktop.update.fullscreenTimeout': '无法退出全屏,请退出全屏后重试更新',
'desktop.update.stopFailed': '未能安全停止任务,更新尚未安装,请稍后重试。',
'desktop.update.tasksChanged': '有新任务开始运行,请重新确认是否停止任务并更新。',
'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。',
@@ -67,6 +68,7 @@ export const en = {
'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.',
'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.',
'desktop.update.installNetworkFailed': 'Could not install the update. Check your connection and try again.',
'desktop.update.fullscreenTimeout': 'Could not exit fullscreen. Exit fullscreen, then retry the update.',
'desktop.update.stopFailed': 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
'desktop.update.tasksChanged': 'New tasks have started. Confirm again to stop the tasks and update.',
'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.',
@@ -11,6 +11,7 @@ export type DesktopUpdateFailureKind =
| 'stop-failed'
| 'tasks-changed'
| 'tasks-unavailable'
| 'fullscreen-timeout'
/** Public semantic preload fields consumed by the settings row. */
export interface DesktopUpdatePresentation {
@@ -177,3 +177,24 @@ it.each([
expect((await screen.findByRole('tooltip')).textContent).toBe(label)
} finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) }
})
it('localizes fullscreen timeout guidance and preserves the retry action', async () => {
const f = fixture()
const copy: string[] = []
try {
await f.emit({ phase: 'error', failure: 'fullscreen-timeout', version: available.version })
for (const dictionary of [zh, en]) {
f.view.rerender(<f.Indicator dictionary={dictionary} />)
fireEvent.focus(screen.getByRole('button'))
copy.push((await screen.findByRole('tooltip')).textContent ?? '')
}
expect(copy).toMatchInlineSnapshot(`
[
"无法退出全屏,请退出全屏后重试更新",
"Could not exit fullscreen. Exit fullscreen, then retry the update.",
]
`)
fireEvent.click(screen.getByRole('button'))
expect(f.open).toHaveBeenCalledOnce()
} finally { f.view.unmount(); f.status.resolve(available) }
})
@@ -21,8 +21,8 @@
en: f4817eb2e3537ed9
zh: e589c193fa85473f
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
en: 3af73644655c8b7b
zh: d689eba772100c63
en: 2ab40f99bd12dc17
zh: 2b5fd79885cb85fb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: 9d533c65c82cd4a4
zh: 0a5da334e127c035
@@ -61,6 +61,8 @@ Expand **Customized settings → Model options** to edit each model. Both provid
A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control.
Empty pi-ai capacity fields display `1M` for the context window and `128K` for maximum output tokens. These hints use decimal K/M units; leaving the fields empty preserves adapter inheritance. Provider-level defaults can differ from the hints.
### Adding and deleting providers
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
@@ -61,6 +61,8 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。
pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token 数显示 `128K`。这些提示使用十进制 K/M 单位;保留空值会继续继承适配器配置。提供方级默认值可能与提示不同。
### 新增与删除提供商
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
@@ -112,16 +112,13 @@ type CapacityField = 'contextWindow' | 'maxTokens'
* What an empty capacity field is worth, shown as its placeholder so a row left
* blank does not read as a model with no capacity at all.
*
* The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s
* `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person
* would say them. They are a hint, not a mirror: this page counts `K` as 1000,
* so typing `256K` stores 256000 while leaving the field blank keeps the
* adapter's 262144. A deployment that overrides those defaults is not
* reflected here — nothing on this page can read them.
* These hints match llm-pi-ai's built-in `defaultContextWindow` and
* `defaultMaxTokens` in decimal K/M notation. Provider overrides are not
* available to this component and are not reflected in the hints.
*/
const CAPACITY_HINT: Readonly<Record<CapacityField, string>> = {
contextWindow: '256K',
maxTokens: '32K',
contextWindow: '1M',
maxTokens: '128K',
}
/**
@@ -310,6 +310,8 @@ describe('model list editing', () => {
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expandModel(1)
expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder).toBe('1M')
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder).toBe('128K')
fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '65536' } })
fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).getByRole('checkbox', { name: en.modelInputImage }))
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
@@ -21,8 +21,8 @@
en: 57942270e23d3a8e
zh: 6617124e652b3f67
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: b056bbe0f74fe328
zh: fd9ac88f08825afc
en: e197303166b5a86f
zh: 8d151e49321d67aa
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: b99b0e0b8efc2853
zh: 4327352d1334fc6b
@@ -59,7 +59,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. When either axis overflows the viewport, left-drag pans the image with grab/grabbing cursors. A fully contained image instead drags its original file into the composer or an external application; the gesture mode remains fixed until release. Ctrl+wheel and trackpad pinch retain the shared zoom behavior. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport.
@@ -59,7 +59,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器
开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。图片在任一方向超出视口时,按住鼠标左键拖动可平移,指针显示为抓手或抓取状态。图片完全位于视口内时,拖动会将原文件交给输入框或外部应用;一次手势的模式保持到松开为止。Ctrl+滚轮和触控板捏合沿用共享缩放行为。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。
@@ -52,3 +52,6 @@
line-height: 1.5;
white-space: normal;
}
.image[data-pan="available"] { cursor: grab; }
.image[data-pan="active"] { cursor: grabbing; }
@@ -1,5 +1,7 @@
/** Complete image bytes rendered in a shared zoom viewport. */
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives'
import { bindImagePan } from './pan.ts'
import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { DocumentPreviewProps } from '../document/contract.ts'
@@ -33,6 +35,7 @@ type ImageSource =
readonly data: Uint8Array<ArrayBuffer>
readonly mediaType: ImageMediaType
readonly url: string
readonly file: File
}
| { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly mediaType: ImageMediaType }
@@ -71,15 +74,16 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
if (data === undefined || mediaType === undefined) return
let url: string | undefined
try {
url = URL.createObjectURL(new Blob([data], { type: mediaType }))
setSource({ kind: 'ready', data, mediaType, url })
const file = new File([data], pathPartsOf(path).name, { type: mediaType })
url = URL.createObjectURL(file)
setSource({ kind: 'ready', data, mediaType, url, file })
} catch {
setSource({ kind: 'failed', data, mediaType })
}
return () => {
if (url !== undefined) URL.revokeObjectURL(url)
}
}, [data, mediaType])
}, [data, mediaType, path])
if (data === undefined || mediaType === undefined) {
return <p className={css.status} role="alert">{t('unsupported')}</p>
@@ -93,15 +97,16 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'),
fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }),
}
return <LoadedImage key={source.url} url={source.url} name={name} preference={preference}
return <LoadedImage key={source.url} url={source.url} name={name} file={source.file} preference={preference}
onPreference={setPreference} labels={labels} signal={tab.signal}
scrollportRef={props.scrollportRef} t={t} />
}
/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */
function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: {
function LoadedImage({ url, name, file, preference, onPreference, labels, signal, scrollportRef, t }: {
readonly url: string
readonly name: string
readonly file: File
readonly preference: ZoomPreference
readonly onPreference: (preference: ZoomPreference) => void
readonly labels: ZoomLabels
@@ -109,6 +114,11 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro
readonly scrollportRef: ImageBodyProps['scrollportRef']
readonly t: ImageBodyProps['t']
}): ReactNode {
const releasePan = useRef<() => void>()
const attachImage = useCallback((image: HTMLImageElement | null): void => {
releasePan.current?.()
releasePan.current = image === null ? undefined : bindImagePan(image)
}, [])
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [width, setWidth] = useState<number>()
return <ZoomViewport preference={preference} intrinsicWidth={width} horizontalInset={24}
@@ -118,12 +128,13 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro
{state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties} hidden={state !== 'ready'}>
<img
<DraggableImage
ref={attachImage}
className={css.image}
src={url}
alt={t('preview', { name })}
decoding="async"
draggable={false}
name={name} original={file} failureText={t('dragFailed')}
referrerPolicy="no-referrer"
onLoad={(event) => { setWidth(event.currentTarget.naturalWidth); setState('ready') }}
onError={() => { setState('failed') }}
@@ -4,6 +4,7 @@ import { zoomEn, zoomZh } from '../zoom/locales.ts'
export const zh = {
...zoomZh,
title: '图片',
dragFailed: '拖放失败,请重试',
preview: '图片预览:{name}',
loading: '文档渲染中...',
failed: '无法显示这张图片',
@@ -17,6 +18,7 @@ export type ImagePreviewKey = keyof typeof zh
export const en = {
...zoomEn,
title: 'Image',
dragFailed: 'Drag failed. Try again.',
preview: 'Image preview: {name}',
loading: 'Rendering document...',
failed: 'This image could not be displayed.',
@@ -0,0 +1,79 @@
/** Mouse panning for overflowing image previews; contained images keep native file dragging. */
/**
* Bind image panning to its existing scrollport without changing zoom or replacing the image.
* @param image - mounted preview image inside a document zoom scrollport.
* @returns cleanup releasing observation, pointer capture, and window listeners.
*/
export function bindImagePan(image: HTMLImageElement): () => void {
const scrollport = image.closest<HTMLElement>('[data-document-zoom-scrollport]')
if (scrollport === null) throw new Error('Image panning requires a document zoom scrollport')
let pointer: { id: number; x: number; y: number; pan: boolean } | undefined
const overflowing = (): boolean => scrollport.scrollWidth > scrollport.clientWidth
|| scrollport.scrollHeight > scrollport.clientHeight
const measure = (): void => {
if (pointer !== undefined) return
const pan = overflowing()
image.dataset.pan = pan ? 'available' : 'none'
image.draggable = !pan
}
const finish = (): void => {
const previous = pointer
pointer = undefined
if (previous !== undefined && image.hasPointerCapture(previous.id)) image.releasePointerCapture(previous.id)
measure()
}
const down = (event: PointerEvent): void => {
if (event.button !== 0 || event.pointerType !== 'mouse') return
measure()
const pan = overflowing()
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, pan }
if (!pan) return
event.preventDefault()
image.dataset.pan = 'active'
image.setPointerCapture(event.pointerId)
}
const move = (event: PointerEvent): void => {
if (pointer === undefined || !pointer.pan || event.pointerId !== pointer.id) return
if (event.buttons !== 1) { finish(); return }
event.preventDefault()
scrollport.scrollLeft = Math.max(0, Math.min(scrollport.scrollWidth - scrollport.clientWidth,
scrollport.scrollLeft + pointer.x - event.clientX))
scrollport.scrollTop = Math.max(0, Math.min(scrollport.scrollHeight - scrollport.clientHeight,
scrollport.scrollTop + pointer.y - event.clientY))
pointer.x = event.clientX
pointer.y = event.clientY
}
const up = (event: PointerEvent): void => { if (event.pointerId === pointer?.id) finish() }
const drag = (event: DragEvent): void => { if (pointer?.pan ?? overflowing()) event.preventDefault() }
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
observer?.observe(image)
observer?.observe(scrollport)
image.addEventListener('load', measure)
image.addEventListener('pointerenter', measure)
image.addEventListener('pointerdown', down)
image.addEventListener('pointermove', move)
image.addEventListener('pointerup', up)
image.addEventListener('pointercancel', up)
image.addEventListener('lostpointercapture', finish)
image.addEventListener('dragstart', drag)
image.addEventListener('dragend', finish)
window.addEventListener('pointerup', up)
window.addEventListener('blur', finish)
measure()
return () => {
observer?.disconnect()
image.removeEventListener('load', measure)
image.removeEventListener('pointerenter', measure)
image.removeEventListener('pointerdown', down)
image.removeEventListener('pointermove', move)
image.removeEventListener('pointerup', up)
image.removeEventListener('pointercancel', up)
image.removeEventListener('lostpointercapture', finish)
image.removeEventListener('dragstart', drag)
image.removeEventListener('dragend', finish)
window.removeEventListener('pointerup', up)
window.removeEventListener('blur', finish)
finish()
}
}
@@ -2,7 +2,9 @@
/** Image Blob ownership, media types, pane-contained rendering, and failure states. */
import { useSyncExternalStore } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render as renderBare, screen } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { ImageBody, imageMediaType, type ImageBodyProps } from '../src/client/image/ImageBody.tsx'
@@ -83,13 +85,13 @@ describe('ImageBody', () => {
expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
})
it('keeps the image non-draggable, then shows it after decoding succeeds', async () => {
it('allows a contained image to drag, then shows it after decoding succeeds', async () => {
render(<ImageBody {...props('photo.svg')} />)
const image = await screen.findByRole('img', { hidden: true })
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 50 })
expect(image.getAttribute('alt')).toBe('Image preview: photo.svg')
expect(image.getAttribute('decoding')).toBe('async')
expect(image.getAttribute('draggable')).toBe('false')
expect(image.getAttribute('draggable')).toBe('true')
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(image.parentElement?.hasAttribute('hidden')).toBe(true)
fireEvent.load(image)
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { bindImagePan } from '../src/client/image/pan.ts'
afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals() })
function viewport(width: number, height: number) {
const scrollport = document.createElement('div')
scrollport.dataset.documentZoomScrollport = ''
Object.defineProperties(scrollport, {
clientWidth: { value: 400 }, clientHeight: { value: 300 },
scrollWidth: { configurable: true, value: width }, scrollHeight: { configurable: true, value: height },
})
const image = document.createElement('img')
scrollport.append(image)
document.body.append(scrollport)
let captured: number | undefined
image.setPointerCapture = (id) => { captured = id }
image.hasPointerCapture = id => captured === id
image.releasePointerCapture = () => { captured = undefined }
return { scrollport, image, captured: () => captured }
}
function pointer(image: HTMLImageElement, type: string, values: Partial<PointerEventInit> = {}) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, buttons: 1, ...values })
Object.defineProperties(event, { pointerId: { value: values.pointerId ?? 1 }, pointerType: { value: values.pointerType ?? 'mouse' } })
image.dispatchEvent(event)
return event
}
it('pans a fit-width long image on its overflowing axis and leaves no pointer capture after release', () => {
vi.stubGlobal('ResizeObserver', undefined)
const { image, scrollport, captured } = viewport(400, 900)
const dispose = bindImagePan(image)
try {
expect(image.draggable).toBe(false)
expect(image.dataset.pan).toBe('available')
expect(pointer(image, 'pointerdown', { clientX: 200, clientY: 200 }).defaultPrevented).toBe(true)
pointer(image, 'pointermove', { clientX: 100, clientY: 100 })
expect(scrollport.scrollLeft).toBe(0)
expect(scrollport.scrollTop).toBe(100)
expect(captured()).toBe(1)
pointer(image, 'pointerup')
expect(captured()).toBeUndefined()
pointer(image, 'pointermove', { clientY: 0 })
expect(scrollport.scrollTop).toBe(100)
} finally { dispose() }
})
it('clamps both axes, reverses immediately at an edge, and releases on blur', () => {
const { image, scrollport, captured } = viewport(800, 600)
const dispose = bindImagePan(image)
try {
pointer(image, 'pointerdown', { clientX: 100, clientY: 100 })
pointer(image, 'pointermove', { clientX: -500, clientY: -500 })
expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([400, 300])
pointer(image, 'pointermove', { clientX: -490, clientY: -490 })
expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([390, 290])
window.dispatchEvent(new Event('blur'))
expect(captured()).toBeUndefined()
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('lets contained images drag as files, and switches mode when their viewport changes', () => {
const { image, scrollport } = viewport(400, 300)
let resize = () => {}
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback }
observe() {}
disconnect = disconnect
})
const dispose = bindImagePan(image)
expect(image.draggable).toBe(true)
expect(pointer(image, 'pointerdown').defaultPrevented).toBe(false)
Object.defineProperty(scrollport, 'scrollHeight', { value: 600 })
resize()
expect(image.draggable).toBe(true)
pointer(image, 'pointerup')
expect(image.draggable).toBe(false)
pointer(image, 'pointerdown', { pointerType: 'touch' })
expect(image.dataset.pan).toBe('available')
dispose()
expect(disconnect).toHaveBeenCalledOnce()
})
it('rejects a missing scrollport with an explicit error', () => {
expect(() => bindImagePan(document.createElement('img'))).toThrow('Image panning requires a document zoom scrollport')
})
it('ignores unrelated pointers and releases capture when the mouse button is no longer held', () => {
const { image, scrollport, captured } = viewport(800, 600)
const dispose = bindImagePan(image)
try {
pointer(image, 'pointerdown', { button: 2 })
expect(captured()).toBeUndefined()
pointer(image, 'pointerdown', { clientX: 100, clientY: 100 })
pointer(image, 'pointerup', { pointerId: 2 })
pointer(image, 'pointermove', { pointerId: 2, clientX: 0 })
expect(captured()).toBe(1)
expect(scrollport.scrollLeft).toBe(0)
pointer(image, 'pointermove', { buttons: 0 })
expect(captured()).toBeUndefined()
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('blocks native dragging only for a pan and retains the selected mode until release', () => {
const { image, scrollport } = viewport(400, 300)
const dispose = bindImagePan(image)
const drag = () => {
const event = new Event('dragstart', { cancelable: true })
image.dispatchEvent(event)
return event.defaultPrevented
}
try {
expect(drag()).toBe(false)
pointer(image, 'pointerdown')
pointer(image, 'pointermove', { clientX: 30 })
Object.defineProperty(scrollport, 'scrollWidth', { value: 800 })
expect(drag()).toBe(false)
image.dispatchEvent(new Event('dragend'))
expect(drag()).toBe(true)
pointer(image, 'pointerdown')
expect(drag()).toBe(true)
pointer(image, 'pointercancel')
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('does not end an idle pan for a foreign pointer release', () => {
const { image } = viewport(800, 600)
const dispose = bindImagePan(image)
pointer(image, 'pointerup', { pointerId: 2 })
expect(image.dataset.pan).toBe('available')
dispose()
})
@@ -607,7 +607,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
},
{
key: 'conversation.composer.bar',
@@ -649,7 +649,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
},
{
key: 'conversation.composer.dock',
@@ -709,7 +709,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
},
{
key: 'conversation.header',
@@ -747,7 +747,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:212',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
},
{
key: 'conversation.header.leading',
@@ -778,7 +778,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:241',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:243',
},
{
key: 'conversation.hero.agentPreset',
@@ -818,7 +818,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
},
{
key: 'conversation.hero.brand.mark',
@@ -849,7 +849,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
},
{
key: 'conversation.hero.options',
@@ -906,7 +906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
},
{
key: 'conversation.hero.workspace',
@@ -941,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
},
{
key: 'conversation.input.attachments',
@@ -1063,7 +1063,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286',
},
{
key: 'conversation.input.dock',
@@ -1130,7 +1130,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
},
{
key: 'conversation.input.left',
@@ -1187,7 +1187,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
},
{
key: 'conversation.input.model',
@@ -1229,7 +1229,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300',
},
{
key: 'conversation.input.model.label',
@@ -1335,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
},
{
key: 'conversation.input.permission',
@@ -1377,7 +1377,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
},
{
key: 'conversation.input.plan',
@@ -1419,7 +1419,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
},
{
key: 'conversation.input.right',
@@ -1476,7 +1476,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
},
{
key: 'conversation.plan-review.actions',
@@ -1577,7 +1577,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:208',
},
{
key: 'conversation.session.header',
@@ -1617,7 +1617,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:216',
},
{
key: 'conversation.session.header.actions',
@@ -1681,7 +1681,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231',
},
{
key: 'conversation.session.header.corner',
@@ -1723,7 +1723,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:252',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:254',
},
{
key: 'conversation.session.header.lineage',
@@ -1767,7 +1767,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:225',
},
{
key: 'conversation.session.header.utilities',
@@ -1830,7 +1830,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:235',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
},
{
key: 'conversation.trajectory.images',
@@ -1931,7 +1931,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198',
},
{
key: 'conversation.view',
@@ -1995,7 +1995,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:258',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
},
{
key: 'deliverables.file.actions',
@@ -2198,7 +2198,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
},
{
key: 'plugins.add.actions',
+2 -2
View File
@@ -21,8 +21,8 @@
en: f3b761c7c7ecb82f
zh: e10d999aa348ef86
/deepseek-ai-dsh-llm-pi-ai/use-this-package/configure-provider-routes:
en: c5d28300fa2cfc6f
zh: c162855dcb54b65b
en: 738edb4ae053740d
zh: cc6853e2bdac01ec
/deepseek-ai-dsh-llm-pi-ai/use-this-package/sign-in-to-a-provider:
en: c36f66438638ae08
zh: 066a2fc4cb97c86f
+2 -2
View File
@@ -81,8 +81,8 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie
| `models` | installed catalog | Replaces the route's catalog wholesale; each entry defaults from the installed model |
| `modelOverrides` | none | Reshapes individual installed-catalog models without replacing the rest |
| `compat` | catalog detection | Wire-compatibility switches for unrecognized endpoints |
| `defaultContextWindow` | `262,144` | Capacity fallback for undescribed models |
| `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models |
| `defaultContextWindow` | `1,000,000` | Capacity fallback for undescribed models |
| `defaultMaxTokens` | `128,000` | Output-cap fallback for undescribed models |
| `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image |
| `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion |
| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` |
+2 -2
View File
@@ -81,8 +81,8 @@ kind: "package-reference"
| `models` | 已安装目录 | 整体替换路由目录;每个条目从已安装模型取默认值 |
| `modelOverrides` | 无 | 重塑个别已安装目录模型,而不替换其余模型 |
| `compat` | 目录检测 | 无法识别端点的协议兼容开关 |
| `defaultContextWindow` | `262,144` | 未描述模型的容量回退 |
| `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 |
| `defaultContextWindow` | `1,000,000` | 未描述模型的容量回退 |
| `defaultMaxTokens` | `128,000` | 未描述模型的输出上限回退 |
| `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 |
| `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 |
| `maxRequestImageBytes` | `20 MiB` | base64 图片载荷总上限,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 |
+4 -4
View File
@@ -62,10 +62,10 @@ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
/** Context capacity assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_CONTEXT_WINDOW = 262_144
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_MAX_TOKENS = 32_768
export const DEFAULT_MAX_TOKENS = 128_000
/**
* Modalities assumed for a model neither configuration nor the catalog
@@ -127,13 +127,13 @@ export interface PiAiProviderProfile {
compat?: PiAiCompatProfile
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* the installed catalog sizes (default 1,000,000). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* the installed catalog sizes (default 128,000). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
+1 -1
View File
@@ -170,7 +170,7 @@ describe('hand-declared providers', () => {
resolved.get(route)?.piProvider?.getModels() ?? []
expect(modelsOf('acme-gateway')).toMatchObject([
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
{ id: 'bare', contextWindow: 1_000_000, maxTokens: 128_000 },
{ id: 'sized', contextWindow: 8192, maxTokens: 512 },
])
// The fallback is a guess, so a deployment whose gateway serves smaller
+3
View File
@@ -4304,6 +4304,9 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:*
version: link:../store
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:*
version: link:../ui-renderer
@@ -65,9 +65,11 @@
## Image zoom
- Contained sidebar PNG drags into the composer as an original-byte image attachment
- Small PNG fit width remains at intrinsic size; 200% doubles it
- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false
- Image and Blob identities remain stable while zoom changes
- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor
## Code paging