Merge remote-tracking branch 'origin/master' into worktree/plan-sidebar-cards
# Conflicts: # packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx # packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
This commit is contained in:
commit
8c3ec757df
214 files changed
+8991
-1928
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
|
||||
2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
|
||||
2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd
|
||||
2026-09-09-desktop-in-place-profile.md: 57dabc0d03c999b90dc18baa9d833aa90320863a
|
||||
2026-09-09-desktop-in-place-profile.zh.md: 2b57c49ce45b1937552596cf4f59e0251b2d5774
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Modify the Desktop profile in place
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-17
|
||||
|
||||
English | [中文](2026-09-09-desktop-in-place-profile.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 直接修改 Desktop profile
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-17
|
||||
|
||||
[English](2026-09-09-desktop-in-place-profile.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
|
||||
2026-09-09-content-sized-diagram-previews.md: fec86111a68ec0fbc12ed6514a60c6a243924963
|
||||
2026-09-09-content-sized-diagram-previews.zh.md: 8880f12853f9e7d2d566fca3a482fd5e17514526
|
||||
@@ -0,0 +1,26 @@
|
||||
# Agent Note: Content-sized diagram previews
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-10
|
||||
|
||||
English | [中文](2026-09-09-content-sized-diagram-previews.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser.
|
||||
|
||||
## Decision
|
||||
|
||||
Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer.
|
||||
|
||||
This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport.
|
||||
|
||||
**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content.
|
||||
|
||||
## Consequences
|
||||
|
||||
Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Agent Note: 按内容定高的图表预览
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-10
|
||||
|
||||
[English](2026-09-09-content-sized-diagram-previews.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。
|
||||
|
||||
## Decision
|
||||
|
||||
Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。
|
||||
|
||||
这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。
|
||||
|
||||
**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。
|
||||
|
||||
## Consequences
|
||||
|
||||
较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。
|
||||
@@ -364,6 +364,9 @@
|
||||
"architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
|
||||
"architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
|
||||
"architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
|
||||
"architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00",
|
||||
"architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290",
|
||||
"architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7",
|
||||
"architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8",
|
||||
"architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763",
|
||||
"architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53",
|
||||
@@ -745,6 +748,9 @@
|
||||
"bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
|
||||
"bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
|
||||
"bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: c4940942a8d5188faf1846158873c052a43e1fc1
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 011624980f6f7239a33fb2b6c6803646b7d41dd3
|
||||
+20
-20
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
|
||||
|
||||
Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
|
||||
Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
|
||||
|
||||
The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
|
||||
|
||||
@@ -24,13 +24,13 @@ Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runt
|
||||
|
||||
One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged.
|
||||
|
||||
The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
|
||||
The Desktop Host exposes the shared Web plugin manager for its reserved profile and supplies bundled pnpm through launcher facts. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before profile recovery or Host startup; later launches focus or recreate the primary window without touching profile state.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Owner | Responsibility |
|
||||
|---|---|
|
||||
| Electron shell | Window and child lifecycle, local shell pages, reserved desktop profile, plugin GUI, update coordination |
|
||||
| Electron shell | Window and child lifecycle, reserved desktop profile preparation, native recovery, update coordination |
|
||||
| Electron RunAsNode and pnpm | Execute dsh and install desktop-project dependencies using pnpm’s normal configuration |
|
||||
| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision |
|
||||
| Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
|
||||
@@ -38,7 +38,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
|
||||
| Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions |
|
||||
| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state |
|
||||
|
||||
The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy.
|
||||
The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload provides boot readiness and failure reporting, native directory selection, theme synchronization, and Windows menu and appearance adapters. It exposes no raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron menus and native dialogs use typed English or Chinese copy with English fallback; Windows follows the main document’s language. The shared Web plugin manager owns its client copy.
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
@@ -59,9 +59,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
|
||||
|
||||
## Installation and resolution
|
||||
|
||||
Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md).
|
||||
The shared Web plugin manager owns profile package operations. Electron retains startup preparation and native recovery; the [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns this separation.
|
||||
|
||||
The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered.
|
||||
The process-lifetime Electron lock is the authoritative Desktop owner. Profile preparation and native recovery use an exclusive lock recording the Electron PID; a live owner blocks a competing operation, while a stale PID can be recovered. Web package operations use the shared plugin manager’s locking.
|
||||
|
||||
Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts.
|
||||
|
||||
@@ -77,11 +77,11 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
|
||||
|
||||
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
|
||||
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
|
||||
|
||||
The [pinned osx-sign patch](../../../../patches/@[email protected]) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
|
||||
|
||||
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
|
||||
|
||||
Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification.
|
||||
|
||||
@@ -97,7 +97,7 @@ Architecture-specific builds report actual component-level compressed and instal
|
||||
|
||||
| Surface | Implementation |
|
||||
|---|---|
|
||||
| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
|
||||
| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, profile preparation, native recovery, update coordination, and electron-builder configuration. |
|
||||
| Installed runtime | Private `@deepseek-ai/dsh-desktop-host` invokes the shared profile runner and reports the authenticated Web URL to Electron. |
|
||||
| Package state | Electron RunAsNode executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. |
|
||||
| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
|
||||
@@ -112,7 +112,7 @@ Architecture-specific builds report actual component-level compressed and instal
|
||||
|
||||
**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding.
|
||||
|
||||
**Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned.
|
||||
**Maintain an Electron-only plugin installer.** A separate page, IPC API, and package service duplicate the shared Web manager. The shared service uses the reserved Desktop profile and launcher-supplied pnpm while keeping CLI profile access disabled.
|
||||
|
||||
**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback.
|
||||
|
||||
@@ -130,13 +130,13 @@ Architecture-specific builds report actual component-level compressed and instal
|
||||
|
||||
- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies.
|
||||
- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature.
|
||||
- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin.
|
||||
- Every Desktop package operation uses bundled pnpm with the user’s normal environment and profile configuration.
|
||||
- The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments.
|
||||
- The backend and browser application cannot mutate desktop packages.
|
||||
- `.dsh/profiles/desktop/node_modules` stores external plugins managed by the shared Web plugin manager.
|
||||
- Desktop package operations use launcher-supplied bundled pnpm and the shared manager’s subprocess environment and profile configuration.
|
||||
- The main application’s Plugins page sends structured package and activation requests to the shared Host service.
|
||||
- The reserved Desktop profile remains unavailable to the standalone CLI.
|
||||
- npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
|
||||
- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads.
|
||||
- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised.
|
||||
- The shared plugin manager owns package-failure handling; native recovery handles fatal Host failures.
|
||||
- One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
|
||||
- Shared `.dsh` data rejects incompatible readers before migration or mutation.
|
||||
- The Web application owns HTTP authentication and serving; the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
|
||||
@@ -151,8 +151,8 @@ Architecture-specific builds report actual component-level compressed and instal
|
||||
|---|---|
|
||||
| First launch | Check bundled release metadata and create profile links without installing core dependencies |
|
||||
| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links |
|
||||
| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
|
||||
| Activation | In-place package changes followed by actual Host startup |
|
||||
| Plugin management | Shared Web Plugins page and Host service with launcher-supplied bundled pnpm |
|
||||
| Activation | Shared manager applies configuration through HMR when enabled; otherwise changes require restart |
|
||||
| Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
|
||||
| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
|
||||
|
||||
@@ -160,14 +160,14 @@ Architecture-specific builds report actual component-level compressed and instal
|
||||
|
||||
Plugin lifecycle scripts execute third-party code. The profile’s `allowBuilds` configuration determines which builds may execute.
|
||||
|
||||
Updating the bound dsh can invalidate plugin peer dependencies or native modules. Installed plugin files remain in place; loading failures require explicit repair through the recovery UI.
|
||||
Updating the bound dsh can invalidate plugin peer dependencies or native modules. Native recovery can disable third-party bundles and restart the application so the shared Plugins page can be used for repair.
|
||||
|
||||
An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
|
||||
|
||||
Interrupted package operations retain partial changes. Recovery retries the actual Host or permits explicit plugin repair without an automatic package reinstall.
|
||||
Package-operation failure and cancellation follow the shared plugin manager’s restoration rules. Native recovery does not reinstall packages.
|
||||
|
||||
Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification.
|
||||
|
||||
## Related proposals
|
||||
|
||||
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) supersedes portless transport and private backend composition. Release identity, signing, process ownership, and Electron-only package authorization remain owned here.
|
||||
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared Web transport and plugin management. Release identity, signing, and process ownership remain owned here.
|
||||
+20
-20
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
|
||||
|
||||
profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
|
||||
插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
|
||||
|
||||
[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
|
||||
|
||||
@@ -24,13 +24,13 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
|
||||
|
||||
一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
|
||||
|
||||
浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
|
||||
Desktop Host 为保留 profile 提供共享 Web 插件管理器,并通过启动器信息提供内置 pnpm。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在 profile 恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。
|
||||
|
||||
## 归属
|
||||
|
||||
| Owner | 职责 |
|
||||
|---|---|
|
||||
| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
|
||||
| Electron 壳 | 窗口与子进程生命周期、保留 desktop profile 准备、原生恢复、更新协调 |
|
||||
| Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
|
||||
| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
|
||||
| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
|
||||
@@ -38,7 +38,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
|
||||
| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
|
||||
| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
|
||||
|
||||
渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
|
||||
渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 提供启动就绪与失败报告、原生目录选择、主题同步,以及 Windows 菜单和外观适配。它不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 菜单与原生对话框使用类型化中英文文案,并以英文回退;Windows 跟随主文档语言。共享 Web 插件管理器负责自身客户端文案。
|
||||
|
||||
## 文件系统布局
|
||||
|
||||
@@ -59,9 +59,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
|
||||
|
||||
## 安装与解析
|
||||
|
||||
Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。
|
||||
共享 Web 插件管理器负责 profile 包操作。Electron 保留启动准备和原生恢复;[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这一职责划分。
|
||||
|
||||
进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。
|
||||
Electron 进程生命周期锁是 Desktop 的权威所有者。profile 准备和原生恢复使用记录 Electron PID 的排他锁;存活的所有者阻止竞争操作,失效 PID 可被回收。Web 包操作使用共享插件管理器的锁。
|
||||
|
||||
核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。
|
||||
|
||||
@@ -77,11 +77,11 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
|
||||
|
||||
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
|
||||
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
|
||||
|
||||
[固定版本的 osx-sign 补丁](../../../../patches/@[email protected])在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
|
||||
|
||||
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。
|
||||
|
||||
Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
|
||||
|
||||
@@ -97,7 +97,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|
||||
|
||||
| 表面 | 实现 |
|
||||
|---|---|
|
||||
| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
|
||||
| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、profile 准备、原生恢复、更新协调和 electron-builder 配置。 |
|
||||
| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
|
||||
| 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
|
||||
| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
|
||||
@@ -112,7 +112,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|
||||
|
||||
**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
|
||||
|
||||
**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
|
||||
**维护 Electron 专用插件安装器。** 独立页面、IPC API 和包服务会重复共享 Web 管理器的功能。共享服务使用保留 Desktop profile 和启动器提供的 pnpm,同时继续禁止 CLI 访问该 profile。
|
||||
|
||||
**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
|
||||
|
||||
@@ -130,13 +130,13 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|
||||
|
||||
- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
|
||||
- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
|
||||
- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
|
||||
- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置。
|
||||
- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
|
||||
- 后端与浏览器应用不能修改桌面包。
|
||||
- `.dsh/profiles/desktop/node_modules` 保存由共享 Web 插件管理器管理的外部插件。
|
||||
- Desktop 包操作使用启动器提供的内置 pnpm,以及共享管理器的子进程环境与 profile 配置。
|
||||
- 主应用的“插件”页面向共享 Host 服务发送结构化包操作与激活请求。
|
||||
- 独立 CLI 仍不能访问保留 Desktop profile。
|
||||
- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
|
||||
- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
|
||||
- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
|
||||
- 共享插件管理器负责包操作失败处理;原生恢复负责 Host 致命故障。
|
||||
- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
|
||||
- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
|
||||
- Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。
|
||||
@@ -151,8 +151,8 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|
||||
|---|---|
|
||||
| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 |
|
||||
| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
|
||||
| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
|
||||
| 激活 | 直接修改包后启动实际 Host |
|
||||
| 插件管理 | 共享 Web“插件”页面与 Host 服务,使用启动器提供的内置 pnpm |
|
||||
| 激活 | 启用 HMR 时由共享管理器应用配置;否则变更需要重启 |
|
||||
| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
|
||||
| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
|
||||
|
||||
@@ -160,14 +160,14 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
|
||||
|
||||
插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行。
|
||||
|
||||
更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。
|
||||
更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。原生恢复可以禁用第三方 bundle 并重启应用,之后可通过共享“插件”页面修复。
|
||||
|
||||
通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
|
||||
|
||||
包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包。
|
||||
包操作失败与取消遵循共享插件管理器的还原规则。原生恢复不会重新安装包。
|
||||
|
||||
代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
|
||||
|
||||
## 相关提案
|
||||
|
||||
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。
|
||||
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 传输和插件管理。发布身份、签名与进程归属仍由本记录负责。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
|
||||
2026-09-05-sidebar-tab-types-and-navigation.md: 24332c844d13c41e4f966f39aa7d2fe79fd8553f
|
||||
2026-09-05-sidebar-tab-types-and-navigation.zh.md: 3aeb38e2ec9c997b9aaf040ae0cb575fa08fa235
|
||||
2026-09-05-sidebar-tab-types-and-navigation.md: 834a26e55f87e5db54d57f06fec592e4fdb9f61c
|
||||
2026-09-05-sidebar-tab-types-and-navigation.zh.md: 9884db9eef49162e8883c390dd71bdbf9d45489f
|
||||
@@ -116,6 +116,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
|
||||
## Deferred
|
||||
|
||||
- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
|
||||
- Parameters for the shipped page types, which today declare none.
|
||||
- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
|
||||
- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.
|
||||
-1
@@ -116,6 +116,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
|
||||
## Deferred
|
||||
|
||||
- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
|
||||
- 随包页类型的参数,今天未声明任何。
|
||||
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
|
||||
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a5f9266e0e7a8def3f0ff6fc271bddde3db30163
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 2cc9fd635102f08e6ca4afb4f086a0cf6b8b05f0
|
||||
+4
-4
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
|
||||
|
||||
Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
|
||||
Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
|
||||
|
||||
The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
|
||||
|
||||
@@ -40,11 +40,11 @@ First launch creates profile metadata and host links without running pnpm, prese
|
||||
|
||||
Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
|
||||
|
||||
Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
|
||||
The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime.
|
||||
|
||||
Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
|
||||
The shared Web plugin manager owns package mutations and activation; Electron retains profile preparation and native recovery. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns these responsibilities.
|
||||
|
||||
The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
|
||||
The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup. The native recovery dialog can disable third-party bundles and back up the profile patch when the Web application cannot start. Installed plugin files remain available for repair.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
|
||||
|
||||
profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
|
||||
插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
|
||||
|
||||
[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
|
||||
|
||||
@@ -40,11 +40,11 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。
|
||||
|
||||
共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
|
||||
|
||||
依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。
|
||||
共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。
|
||||
|
||||
Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。
|
||||
共享 Web 插件管理器负责包变更和激活;Electron 保留 profile 准备和原生恢复。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这些职责。
|
||||
|
||||
[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
|
||||
[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host。Web 应用无法启动时,原生恢复对话框可禁用第三方 bundle 并备份 profile patch。已安装插件文件保留以供修复。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
|
||||
2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b
|
||||
2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae
|
||||
2026-09-09-desktop-immediate-window-and-direct-start.md: e7ccf9b7456213d69795a40d9cd2a8509adfc44e
|
||||
2026-09-09-desktop-immediate-window-and-direct-start.zh.md: c59cc91f0013293cbaf2c086822085000dd3afd2
|
||||
+4
-4
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
|
||||
|
||||
Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
|
||||
Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -16,9 +16,9 @@ Electron creates the main window with the packaged Web loading page before profi
|
||||
|
||||
Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here.
|
||||
|
||||
Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
|
||||
Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host.
|
||||
|
||||
This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
|
||||
This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, and resource ownership rationale. Full runtime file verification remains a packaging operation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,4 +30,4 @@ This partially supersedes staged backend probes and waiting to create the main w
|
||||
|
||||
Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails.
|
||||
|
||||
Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests.
|
||||
Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, native recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle tests.
|
||||
+4
-4
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
|
||||
|
||||
profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
|
||||
插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -16,9 +16,9 @@ Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主
|
||||
|
||||
致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定。
|
||||
|
||||
Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
|
||||
Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪信息提供认证后的 Host URL 和启动注入。桌面壳用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为所属应用源认证直接 WebSocket 请求。此承载适配保留 Web 路由和流语义,同时允许静态 HTML 在 Host 就绪前显示。
|
||||
|
||||
本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
|
||||
本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输与资源归属的理由。完整运行时文件验证仍属于打包操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-
|
||||
|
||||
用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
|
||||
|
||||
验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。
|
||||
验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
|
||||
2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99
|
||||
2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f
|
||||
2026-09-10-desktop-web-wrapper.md: 05210e2ab85f0afe6d0275e25f31ce8addf11332
|
||||
2026-09-10-desktop-web-wrapper.zh.md: 84e72c9261250b103245f974e1a0546a4898e9ca
|
||||
@@ -14,13 +14,13 @@ Separate Desktop composition and request transport require their own configurati
|
||||
|
||||
The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
|
||||
|
||||
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
|
||||
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
|
||||
|
||||
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
|
||||
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host.
|
||||
|
||||
Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
|
||||
|
||||
App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
|
||||
The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge.
|
||||
|
||||
Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
|
||||
|
||||
@@ -38,12 +38,14 @@ Native directory selection in the local application uses a narrow preload IPC ca
|
||||
|
||||
**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
|
||||
|
||||
**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide.
|
||||
|
||||
**Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails.
|
||||
|
||||
User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the native recovery dialog rather than triggering stricter admission checks or automatic rollback.
|
||||
User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. The shared Web manager owns package-failure handling; Electron retains native recovery for fatal startup failures. The signed core runtime does not attest to user-installed plugin code.
|
||||
|
||||
Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.
|
||||
@@ -14,13 +14,13 @@ Status: implemented
|
||||
|
||||
私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
|
||||
|
||||
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
|
||||
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
|
||||
|
||||
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
|
||||
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。
|
||||
|
||||
独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
|
||||
|
||||
App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
|
||||
共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。
|
||||
|
||||
共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
|
||||
|
||||
@@ -38,12 +38,14 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
|
||||
|
||||
**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
|
||||
|
||||
**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。
|
||||
|
||||
**固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
|
||||
|
||||
## Consequences
|
||||
|
||||
Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
|
||||
|
||||
用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
|
||||
用户选择的运行时选项、包来源和允许的生命周期脚本可能影响 Host 执行、加载第三方代码或造成启动失败。共享 Web 管理器负责包操作失败处理;Electron 为致命启动失败保留原生恢复。签名核心运行时不为用户安装的插件代码背书。
|
||||
|
||||
验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
|
||||
2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e
|
||||
2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19
|
||||
@@ -10,13 +10,13 @@ A recovery document depends on the renderer and preload whose failure can preven
|
||||
|
||||
## Decision
|
||||
|
||||
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
|
||||
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
|
||||
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
|
||||
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
|
||||
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
|
||||
|
||||
This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes.
|
||||
This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared plugin management and native profile preparation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+4
-4
@@ -10,13 +10,13 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
|
||||
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
|
||||
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
|
||||
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
|
||||
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
|
||||
|
||||
这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
|
||||
本决策取代[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页面和重置行为;其立即显示与直接启动 Host 的理由仍有效。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享插件管理和原生 profile 准备。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
|
||||
2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
|
||||
2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c
|
||||
2026-09-15-desktop-profile-core-cleanup.md: da189664e2b55a9310b36ed02f0b275e2c1b1370
|
||||
2026-09-15-desktop-profile-core-cleanup.zh.md: dec12e4fe7f34beb5931961183041c3f642b44ae
|
||||
@@ -12,7 +12,7 @@ Old Desktop profiles contain installed core packages and local tarball dependenc
|
||||
|
||||
Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
|
||||
|
||||
The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
|
||||
The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [bundled runtime decision](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md); direct writes and failure recovery remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
|
||||
|
||||
实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
|
||||
实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[内置运行时决策](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
|
||||
2026-09-09-markdown-static-previews.md: cb63b0ccd02abfaf148e61ef452cb714765bfffe
|
||||
2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Static Markdown fence previews
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-09-markdown-static-previews.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Readers need an optional way to inspect Mermaid and DOT diagrams and SVG artwork directly in Assistant replies while retaining code as the primary representation. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, and `svg` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. HTML, consumers without preview labels, and streaming messages retain code.
|
||||
|
||||
`CodeBlock.preview` is a standard source-preview descriptor containing the renderer and complete localized output and control labels; callers do not pass React nodes. `MarkdownText` creates one descriptor catalog per preview-label identity, and every settled render reuses its language entries while source text, file mentions, or local-image vocabulary changes. The [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes this note's source-first default and release-on-toggle lifetime; it owns the current controls and result retention. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) owns current geometry. This note retains the independent rendering, security, and distribution decisions.
|
||||
|
||||
`SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
|
||||
|
||||
Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Intersecting previews and open lightboxes observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
|
||||
|
||||
SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
|
||||
|
||||
Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build attestation identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy.
|
||||
|
||||
Source highlighting uses the shared lazy Shiki highlighter for both streamed and settled diagram fences. SVG resolves to XML; Mermaid selects the upstream grammar's `#mermaid` body rules because CodeBlock has already removed the Markdown fence. DOT uses the MIT TextMate grammar pinned in the [third-party notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt), with patterns and scopes preserved by mechanical conversion. This keeps source coloring independent of preview rendering and avoids maintaining a separate DOT tokenizer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Show visualization by default with overlay icon actions.** The source-first decision favored authored code and avoided diagram work before an explicit request. The [interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes that default while retaining actions in the banner.
|
||||
|
||||
**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source.
|
||||
|
||||
**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
|
||||
|
||||
**Pass rendered JSX through `CodeBlock.preview`.** Caller-created elements split preview ownership between the renderer and the primitive. A standard data descriptor keeps rendering, result ownership, and controls in one component API.
|
||||
|
||||
**Preview HTML in a sandboxed frame.** HTML preview adds sanitization, iframe policy, a distinct document layout, and another browser dependency. Source display covers the code-review use case without that separate security and distribution surface.
|
||||
|
||||
**Insert rendered markup into Chat.** SVG styles and executable behavior would share the application document. Image mode disables SVG behavior while retaining the rendered result.
|
||||
|
||||
**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering.
|
||||
|
||||
## Consequences
|
||||
|
||||
The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. `SourcePreview` associates a result with both its source and renderer and publishes nothing until the matching result settles. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, HTML rendering, or interactive diagram links.
|
||||
|
||||
Component tests cover source copying, preview rendering, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, inert SVG images, real image decoding, source toggling, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper version and native source downloads so upgrades require renewed review.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: 静态 Markdown fence 预览
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-09-markdown-static-previews.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
读者需要在保留代码为主要表示的同时,按需直接查看 Assistant 回复中的 Mermaid 与 DOT 图表和 SVG 图像。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
|
||||
|
||||
## Decision
|
||||
|
||||
共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` renderer。HTML、未提供预览文案的调用方与流式消息保留代码显示。
|
||||
|
||||
`CodeBlock.preview` 是标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案;调用方不传入 React 节点。`MarkdownText` 按预览文案对象的 identity 创建一份描述目录,之后正文、文件提及或本地图片词表变化引起的每次定稿渲染都复用其中的语言条目。[CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代本文的默认源码和切换时释放结果的选择,负责当前控件与结果保留。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责当前几何行为。本文保留独立的渲染、安全与分发决策。
|
||||
|
||||
`SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
|
||||
|
||||
Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
|
||||
|
||||
SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
|
||||
|
||||
Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建证明标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本和其 DOMPurify 依赖选用的 Apache-2.0 条款。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。
|
||||
|
||||
源码高亮在流式与定稿图表 fence 中共用按需加载的 Shiki 高亮器。SVG 解析为 XML;Mermaid 选用上游语法的 `#mermaid` 正文规则,因为 CodeBlock 已去除 Markdown 围栏。DOT 使用[第三方声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)中固定的 MIT TextMate 语法,机械转换保留所有模式与 scope。这让源码着色独立于预览渲染,也避免维护单独的 DOT tokenizer。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**默认显示可视化并使用浮层图标操作。** 默认源码的决策优先展示作者代码,并避免显式请求前执行图表工作。[交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代这一默认选择,同时继续将操作放在标题栏中。
|
||||
|
||||
**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
|
||||
|
||||
**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
|
||||
|
||||
**通过 `CodeBlock.preview` 传入已渲染 JSX。** 调用方创建的元素会把预览所有权拆分到 renderer 与基础组件。标准数据描述让渲染、结果所有权与控件归入同一个组件 API。
|
||||
|
||||
**在 sandbox iframe 中预览 HTML。** HTML 预览需要清理策略、iframe 权限、独立文档布局和额外浏览器依赖。源码展示足以覆盖代码审阅场景,无需引入这组独立的安全与分发范围。
|
||||
|
||||
**把渲染标记插入 Chat。** SVG 样式和可执行行为会与应用共享文档。图片模式会禁用 SVG 行为,同时保留渲染结果。
|
||||
|
||||
**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
|
||||
|
||||
## Consequences
|
||||
|
||||
该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。`SourcePreview` 同时按源码与 renderer 关联结果,在匹配结果完成前不发布预览内容。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、HTML 渲染或交互式图表链接。
|
||||
|
||||
组件测试覆盖源码复制、预览渲染、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不可执行 SVG 图片、真实图片解码、源码切换、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层版本与原生源码下载地址,升级时须重新审查。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md
|
||||
2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10
|
||||
2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent Note: CodeBlock preview interaction and retained work
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-10-codeblock-preview-interaction.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Readers need to inspect diagrams directly and return to source without paying for the same diagram again. Toolbar state and unrelated grammar notifications can repeat source work even when its text is unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active.
|
||||
|
||||
The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
|
||||
|
||||
The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow.
|
||||
|
||||
Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block.
|
||||
|
||||
The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
|
||||
|
||||
The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener.
|
||||
|
||||
The source renderer consumes each highlight frame once. `StreamingHighlightSession.updateFrame` returns the same delta frame for repeated code and language, while refs retain completed-line groups across React render retries. The line cache therefore retains the frame identity and returns its existing body for the same frame, code, and language; memoization alone cannot prevent duplicate line insertion. StrictMode coverage checks multiple streaming chunks and settlement, and the Markdown DOM fixtures run under StrictMode without changing their expected output.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Unmount inactive content.** Releasing images on every Source visit repeats diagram generation and removes the source-view magnifier's result. Retaining one result per mounted block gives the result the same lifetime as its controls.
|
||||
|
||||
**Share a global preview cache.** Cross-block reuse adds eviction, renderer identity, and theme-key ownership while retaining conversation content beyond individual mounts. The measured repeated work occurs within one block, so its mounted lifetime is sufficient.
|
||||
|
||||
**Mount and highlight source before selection.** The retained image determines block dimensions, so hidden source adds work without improving the preview.
|
||||
|
||||
## Consequences
|
||||
|
||||
Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source.
|
||||
|
||||
Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
|
||||
|
||||
The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
|
||||
|
||||
|
||||
## Performance evidence
|
||||
|
||||
The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency.
|
||||
|
||||
| Metric | Eager previews, samples → median | Viewport previews, samples → median |
|
||||
| --- | --- | --- |
|
||||
| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
|
||||
| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
|
||||
| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample |
|
||||
| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample |
|
||||
| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
|
||||
| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample |
|
||||
|
||||
Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent Note: CodeBlock 预览交互与工作保留
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-10-codeblock-preview-interaction.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
读者需要直接查看图表,并返回源码而不必再次生成同一张图。即使文本未变,工具栏状态和无关语法通知也可能重复执行源码工作。
|
||||
|
||||
## Decision
|
||||
|
||||
支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。
|
||||
|
||||
语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
|
||||
|
||||
稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。
|
||||
|
||||
每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。
|
||||
|
||||
所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
|
||||
|
||||
大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。
|
||||
|
||||
源码渲染器只消费每个高亮帧一次。对于重复的代码与语言,`StreamingHighlightSession.updateFrame` 返回同一个增量帧,而 ref 会跨 React 渲染重试保留已完成行的分组。因此,行缓存保留帧 identity,并在帧、代码与语言相同时返回已有正文;仅靠 memo 化无法防止重复插入行。StrictMode 测试覆盖多个流式分片及定稿,Markdown DOM fixture 也在 StrictMode 下运行,预期输出保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**卸载未激活内容。** 每次查看源码都释放图片会重复生成图表,也会移除源码视图放大镜所需的结果。按已挂载代码块保留一个结果,让结果与其控件具有相同生命周期。
|
||||
|
||||
**共享全局预览缓存。** 跨代码块复用需要处理淘汰、renderer identity 和主题键归属,还会在单个组件卸载后保留对话内容。测得的重复工作发生在同一代码块内部,因此其挂载生命周期足以满足需求。
|
||||
|
||||
**在选中前就挂载并高亮源码。** 保留的图片决定代码块尺寸,隐藏源码只会增加工作,无法改善预览。
|
||||
|
||||
## Consequences
|
||||
|
||||
支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。
|
||||
|
||||
视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
|
||||
|
||||
[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
|
||||
|
||||
|
||||
## 性能证据
|
||||
|
||||
[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。
|
||||
|
||||
| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 |
|
||||
| --- | --- | --- |
|
||||
| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
|
||||
| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
|
||||
| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 |
|
||||
| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 |
|
||||
| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
|
||||
| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px |
|
||||
|
||||
主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
|
||||
2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c
|
||||
2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: Sandboxed Sidebar browser
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-sidebar-browser.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The right Sidebar can preview addressed workspace files, but it has no independent surface for visiting a Web page. Opening a page outside the application loses the Sidebar's split, float, and tab lifecycle. Treating an arbitrary page like Document Preview content also obscures a different trust model: a Web page controls a live browsing context, while a document renderer receives bytes selected for one preview.
|
||||
|
||||
A parent page cannot inspect or drive a cross-origin iframe's internal history. The supported behavior must not imply that capability.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values.
|
||||
|
||||
`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters when that type is registered, or uses the system browser otherwise; the Markdown renderer does not import the Browser feature.
|
||||
|
||||
The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface.
|
||||
|
||||
The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
|
||||
|
||||
Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component.
|
||||
|
||||
`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
|
||||
|
||||
Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities.
|
||||
|
||||
## Web navigation states
|
||||
|
||||
The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored.
|
||||
|
||||
| State | Entry | Address and controls |
|
||||
|---|---|---|
|
||||
| `empty` | The tab has no controlled target. | The address is empty. Back, Forward, Reload, and external-open are disabled. |
|
||||
| `loading` | Address submission, an application-history move, or Reload starts a new revision. | The requested URL remains authoritative. Back and Forward follow application-history bounds; Reload remains available; external-open follows the known target protocol. |
|
||||
| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. |
|
||||
| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. |
|
||||
|
||||
Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
|
||||
|
||||
## Deferred Electron carrier
|
||||
|
||||
Electron `<webview>` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`.
|
||||
|
||||
The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins.
|
||||
|
||||
The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied.
|
||||
|
||||
The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `<webview>` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization.
|
||||
|
||||
Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning.
|
||||
|
||||
**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support.
|
||||
|
||||
**Support `file:` URLs in Browser.** Rejected because local files already belong to Document Preview, while browser navigation has a different trust model. Browser refuses the protocol instead of acquiring filesystem or Workspace Files access.
|
||||
|
||||
**Proxy Web pages through the Host.** Rejected because a compatible proxy would have to rewrite URLs, CSP, cookies, modules, streams, forms, and downloads while turning the Host into a general outbound requester.
|
||||
|
||||
**Implement the Electron carrier in the initial Browser change.** Deferred so the first implementation does not enable a new Electron guest surface without packaged-app evidence for overlay stacking, target lifetime, cookie isolation, and every permission denial.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior.
|
||||
|
||||
Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: 沙箱化 Sidebar 浏览器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-sidebar-browser.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
右侧 Sidebar 可以预览已寻址的工作区文件,但没有用于访问网页的独立界面。在应用外打开会丢失 Sidebar 的分栏、浮动与 tab 生命周期。把任意网页当成 Document Preview 内容,也会混淆两种不同的信任模型:网页控制一个活跃浏览上下文,而文档 renderer 只接收为一次预览选定的字节。
|
||||
|
||||
父页面不能检查或驱动跨域 iframe 的内部 history。支持的行为不能暗示具备这种能力。
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。
|
||||
|
||||
`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider;已注册该类型时,它以 URL 作为 typed navigation 参数打开新的 `browser` tab,否则使用系统浏览器;Markdown renderer 不导入 Browser feature。
|
||||
|
||||
地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。
|
||||
|
||||
当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
|
||||
|
||||
每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。
|
||||
|
||||
`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
|
||||
|
||||
Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。
|
||||
|
||||
## Web 导航状态
|
||||
|
||||
对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。
|
||||
|
||||
| 状态 | 进入条件 | 地址与控件 |
|
||||
|---|---|---|
|
||||
| `empty` | tab 没有受控目标。 | 地址为空;后退、前进、刷新与外部打开均禁用。 |
|
||||
| `loading` | 地址提交、应用 history 移动或刷新启动新的 revision。 | 请求 URL 仍是权威地址;后退和前进遵循应用 history 范围;刷新保持可用;外部打开遵循已知目标的协议。 |
|
||||
| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 |
|
||||
| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 |
|
||||
|
||||
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
|
||||
|
||||
## Deferred Electron carrier
|
||||
|
||||
Electron `<webview>` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。
|
||||
|
||||
Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。
|
||||
|
||||
主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。
|
||||
|
||||
view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `<webview>` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。
|
||||
|
||||
每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。
|
||||
|
||||
**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。
|
||||
|
||||
**在 Browser 中支持 `file:` URL。** 不采用,因为本地文件已由 Document Preview 负责,而 browser 导航使用不同的信任模型。Browser 直接拒绝该协议,不获取文件系统或 Workspace Files 能力。
|
||||
|
||||
**通过 Host 代理网页。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。
|
||||
|
||||
**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。
|
||||
|
||||
## Verification
|
||||
|
||||
单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
|
||||
|
||||
## Consequences
|
||||
|
||||
Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
|
||||
|
||||
站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
|
||||
2026-09-16-windows-desktop-titlebar.md: 679e24b9e5b27e1f7dfce86ef35fd3d472651c37
|
||||
2026-09-16-windows-desktop-titlebar.zh.md: 6cc3c1ce35d150b8211caf5bcf350607f9b173fa
|
||||
2026-09-16-windows-desktop-titlebar.md: 17fd2aac308cff0b4357a0c39c3fe757cfc60f5b
|
||||
2026-09-16-windows-desktop-titlebar.zh.md: 05903d86960942204d7b18d4155266c144effc1a
|
||||
@@ -12,11 +12,11 @@ Windows needs a compact caption that keeps sidebar navigation available when the
|
||||
|
||||
The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame.
|
||||
|
||||
Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Ctrl+, retains the Desktop Plugins shortcut. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
|
||||
Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and Desktop package management; the runtime plugin panel cannot replace the independent Desktop Plugins window. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
|
||||
An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and editing commands. Plugin management uses the main application’s Plugins page. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
|
||||
|
||||
Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window.
|
||||
|
||||
@@ -26,4 +26,4 @@ The caption remains available above fullscreen file panels, and collapsed naviga
|
||||
|
||||
## Testing
|
||||
|
||||
Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, plugin-window opening, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.
|
||||
Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the absence of the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.
|
||||
@@ -12,11 +12,11 @@ Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保
|
||||
|
||||
Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。
|
||||
|
||||
Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。Ctrl+, 保留桌面插件快捷键。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
|
||||
Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和 Desktop 包管理;运行时插件面板不能替代独立的桌面插件窗口。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
|
||||
按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
|
||||
|
||||
自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。
|
||||
|
||||
@@ -26,4 +26,4 @@ Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root
|
||||
|
||||
## Testing
|
||||
|
||||
桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、插件窗口打开、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。
|
||||
桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md
|
||||
2026-09-14-source-sized-code-block-previews.md: 66ba419efcf4113886cfddafc4eb9f3ad2583565
|
||||
2026-09-14-source-sized-code-block-previews.zh.md: 7e8e96eced72f78f7aff852d993cf32bff22a7e4
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Diagram-sized code block previews
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-14-source-sized-code-block-previews.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Source line count does not predict diagram proportions. Sizing a preview from hidden highlighted source can clip a tall diagram, waste space around a short one, and parse a large source tree before the reader requests it. Independent Source and Preview heights move the surrounding transcript on every toggle.
|
||||
|
||||
## Decision
|
||||
|
||||
The browser sizes the retained preview image from its intrinsic aspect ratio and available width. CSS limits image height to the smaller of 60vh and 640px; the canvas adds 16px padding and a 120px minimum height. The image remains in document flow while hidden, so both views keep the same geometry through toggles and viewport resizing without JavaScript measurements or resize state.
|
||||
|
||||
Source mounts on first selection, overlays the preview area, and scrolls internally. Subsequent toggles retain the source DOM and generated image. Preview remains the default. Supported streaming fences keep the 180px placeholder and mount neither source nor the diagram renderer until settlement.
|
||||
|
||||
`CodeBlock` exposes no inline resize controls. Enlarging the image opens the existing lightbox with pan, zoom, and viewport fit.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use hidden source as the size owner.** Source length and wrapping do not describe the diagram, and eagerly highlighting it adds work to the default preview path.
|
||||
|
||||
**Give each view its own natural height or allow drag resizing.** Independent heights move transcript content; manual resizing adds state and controls where the browser can derive the size from the image.
|
||||
|
||||
## Consequences
|
||||
|
||||
Preview-only readers do not mount or highlight source. Source-only blocks retain viewport-triggered highlighting. Very tall diagrams scale down in the transcript; the lightbox provides detailed reading. Source DOM remains allocated after the reader first opens it, preserving highlight and scroll state across toggles.
|
||||
|
||||
Browser regressions cover wide and narrow viewports, short and tall images, stable toggle geometry, retained DOM, and hidden-source accessibility. Component tests verify that preview settlement performs no source highlighting before Source is selected.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 按图表定高的代码块预览
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-14-source-sized-code-block-previews.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
源码行数无法预测图表比例。根据隐藏的高亮源码确定预览尺寸,会裁切高图、在短图周围浪费空间,并在读者需要之前解析大量源码节点。源码与预览分别使用独立高度时,每次切换都会移动后续对话内容。
|
||||
|
||||
## Decision
|
||||
|
||||
浏览器根据保留的预览图片的原始宽高比与可用宽度计算尺寸。CSS 将图片高度限制为 60vh 与 640px 中的较小值;画布增加 16px 内边距与 120px 最小高度。图片隐藏时仍保留在文档流中,因此切换视图与调整视口时,两种视图保持相同几何,无需 JavaScript 测量或尺寸状态。
|
||||
|
||||
源码在首次选中时挂载,覆盖预览区域并在内部滚动。后续切换保留源码 DOM 与已生成图片。默认仍为预览。受支持的流式 fence 保留 180px 占位,在定稿前既不挂载源码,也不挂载图表 renderer。
|
||||
|
||||
`CodeBlock` 不提供行内尺寸调整控件。放大图片使用现有 lightbox,支持平移、缩放与适应视口。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用隐藏源码确定尺寸。** 源码长度与换行无法描述图表,提前高亮还会给默认预览路径增加工作。
|
||||
|
||||
**让两种视图分别使用自然高度或允许拖拽调整。** 独立高度会移动对话内容;浏览器可以根据图片推导尺寸,手动调整却增加了状态与控件。
|
||||
|
||||
## Consequences
|
||||
|
||||
只阅读预览时不挂载或高亮源码。仅源码代码块保留视口触发的高亮。特别高的图表在对话中等比缩小,读者可通过 lightbox 查看细节。首次打开后,源码 DOM 持续保留,让高亮与滚动状态跨切换保持不变。
|
||||
|
||||
浏览器回归覆盖宽窄视口、长短图片、稳定的切换几何、DOM 保留及隐藏源码的可访问性。组件测试验证预览定稿不会在选中源码前执行源码高亮。
|
||||
@@ -61,6 +61,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
|
||||
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
|
||||
| [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
|
||||
| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
|
||||
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
|
||||
| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
|
||||
| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
|
||||
@@ -89,6 +90,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
|
||||
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
|
||||
| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
|
||||
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
|
||||
| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
|
||||
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
|
||||
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
|
||||
@@ -123,6 +125,8 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`zod`](https://github.com/colinhacks/zod) | MIT |
|
||||
| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
|
||||
|
||||
The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`.
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
@@ -212,7 +216,6 @@ External packages **directly declared** for development, tests, types, or toolin
|
||||
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
|
||||
| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
|
||||
| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
|
||||
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
|
||||
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
|
||||
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
|
||||
| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 9842ceb57928223d0743e283656ee2580af0c38b
|
||||
README.zh.md: 0ce8dc8229ffd955d8b34283462e1f63bdf42532
|
||||
README.md: 227fcb57aa2093aaded0cdf6c9375e32bc4328fb
|
||||
README.zh.md: 88b5a07e1f7a8e3379cf725f37d2a01528196ba3
|
||||
@@ -34,8 +34,8 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt
|
||||
| Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
|
||||
| Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
|
||||
| State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
|
||||
| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. |
|
||||
| Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
|
||||
| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. |
|
||||
| Plugin changes | Desktop and Web need the same installation and activation behavior. | The main application uses the shared Web Plugin Manager and bundled pnpm. |
|
||||
| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
|
||||
|
||||
The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification.
|
||||
@@ -44,13 +44,13 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
|
||||
|
||||
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
|
||||
|
||||
The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Plugin management uses the Web application’s authenticated HTTP APIs; Electron exposes no plugin-management IPC or separate management document.
|
||||
|
||||
The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts, plugin-window titles, plugin-manager locale responses, and plugin-operation error text. The repository Client UI i18n gate checks desktop sources.
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Desktop Plugins, Check for Updates, and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Ctrl+, opens the separate Desktop Plugins window, which manages Desktop packages rather than the sidebar's runtime plugin panel. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
|
||||
On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
|
||||
|
||||
@@ -61,10 +61,10 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
|
||||
1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
|
||||
2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
|
||||
3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
|
||||
4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
|
||||
5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
|
||||
4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration.
|
||||
5. The shared manager owns installation errors, activation, and restart requirements. Native recovery can disable third-party bundles even when the Host cannot start.
|
||||
|
||||
CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
|
||||
The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files.
|
||||
|
||||
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart.
|
||||
|
||||
@@ -72,7 +72,7 @@ Native dialog details include at most 1,200 UTF-16 code units and eight diagnost
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
|
||||
Electron serializes profile preparation and native recovery through `$DSH_HOME/profiles/desktop/lock`. Web plugin operations use the shared manager’s package lock and lifecycle.
|
||||
|
||||
## Develop
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
|
||||
| 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
|
||||
| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
|
||||
| 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
|
||||
| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 |
|
||||
| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
|
||||
| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。 |
|
||||
| 插件变更 | Desktop 与 Web 需要一致的安装和激活行为。 | 主应用使用共享 Web 插件管理器和内置 pnpm。 |
|
||||
| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
|
||||
|
||||
[薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收。
|
||||
@@ -44,13 +44,13 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
|
||||
|
||||
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
|
||||
|
||||
应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
|
||||
应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。
|
||||
|
||||
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示、插件窗口标题、插件管理器语言响应及插件操作错误文案。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供桌面插件、检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。Ctrl+, 打开独立的桌面插件窗口,管理 Desktop 包,与侧栏的运行时插件面板不同。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
|
||||
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
@@ -61,10 +61,10 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
|
||||
1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
|
||||
2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
|
||||
3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
|
||||
4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
|
||||
5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
|
||||
4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。
|
||||
5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。
|
||||
|
||||
CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
|
||||
[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。
|
||||
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。
|
||||
|
||||
@@ -72,7 +72,7 @@ CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundl
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
|
||||
Electron 通过 `$DSH_HOME/profiles/desktop/lock` 串行执行 profile 准备和原生恢复。Web 插件操作使用共享管理器的包锁和生命周期。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -79,9 +79,8 @@ export function createElectronBuilderConfig(
|
||||
return true
|
||||
},
|
||||
files: [
|
||||
'lib/*.js',
|
||||
'lib/*.cjs',
|
||||
'renderer/**/*',
|
||||
'lib/main.js',
|
||||
'lib/preload-app.cjs',
|
||||
'package.json',
|
||||
{ from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
|
||||
// electron-builder excludes a source directory's root node_modules.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
header,
|
||||
.install-row,
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
header p {
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
form,
|
||||
section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid ButtonBorder;
|
||||
border-radius: 7px;
|
||||
background: Field;
|
||||
color: FieldText;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 9px 14px;
|
||||
border: 1px solid ButtonBorder;
|
||||
border-radius: 7px;
|
||||
background: AccentColor;
|
||||
color: AccentColorText;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.quiet,
|
||||
li button {
|
||||
background: ButtonFace;
|
||||
color: ButtonText;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
#status {
|
||||
min-height: 1.5em;
|
||||
margin-top: 16px;
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid ButtonBorder;
|
||||
}
|
||||
|
||||
.package-version {
|
||||
color: GrayText;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.package-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
|
||||
<title id="page-title"></title>
|
||||
<link rel="stylesheet" href="plugin-manager.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="title"></h1>
|
||||
<p id="description"></p>
|
||||
</div>
|
||||
<button id="refresh" class="quiet" type="button"></button>
|
||||
</header>
|
||||
<form id="install-form">
|
||||
<label id="package-label" for="package-spec"></label>
|
||||
<div class="install-row">
|
||||
<input id="package-spec" name="package-spec" autocomplete="off" placeholder="@scope/[email protected]" required>
|
||||
<button id="install" type="submit"></button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="update-form" hidden aria-labelledby="update-label">
|
||||
<label id="update-label" for="update-version"></label>
|
||||
<div class="install-row">
|
||||
<input id="update-version" name="update-version" autocomplete="off" required>
|
||||
<button id="update-submit" type="submit"></button>
|
||||
<button id="update-cancel" class="quiet" type="button"></button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="status" role="status" aria-live="polite"></p>
|
||||
<section aria-labelledby="installed-heading">
|
||||
<h2 id="installed-heading"></h2>
|
||||
<ul id="plugins"></ul>
|
||||
<p id="empty"></p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="plugin-manager.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,130 +0,0 @@
|
||||
const api = window.dshDesktop
|
||||
|
||||
async function main() {
|
||||
const locale = await api.locale()
|
||||
const messages = locale.messages
|
||||
const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder)
|
||||
document.documentElement.lang = locale.id
|
||||
document.querySelector('#page-title').textContent = messages.pluginManagerTitle
|
||||
document.querySelector('#title').textContent = messages.pluginManagerTitle
|
||||
document.querySelector('#description').textContent = messages.pluginManagerDescription
|
||||
document.querySelector('#refresh').textContent = messages.refresh
|
||||
document.querySelector('#package-label').textContent = messages.npmPackage
|
||||
document.querySelector('#install').textContent = messages.install
|
||||
document.querySelector('#installed-heading').textContent = messages.installed
|
||||
document.querySelector('#empty').textContent = messages.noPlugins
|
||||
|
||||
|
||||
const list = document.querySelector('#plugins')
|
||||
const empty = document.querySelector('#empty')
|
||||
const status = document.querySelector('#status')
|
||||
const form = document.querySelector('#install-form')
|
||||
const input = document.querySelector('#package-spec')
|
||||
const refresh = document.querySelector('#refresh')
|
||||
const updateForm = document.querySelector('#update-form')
|
||||
const updateVersion = document.querySelector('#update-version')
|
||||
let updateName = ''
|
||||
let updateTrigger
|
||||
document.querySelector('#update-submit').textContent = messages.update
|
||||
document.querySelector('#update-cancel').textContent = messages.cancel
|
||||
|
||||
function setBusy(busy, statusMessage = '') {
|
||||
for (const control of document.querySelectorAll('button, input')) control.disabled = busy
|
||||
status.textContent = statusMessage
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const plugins = await api.plugins.list()
|
||||
list.replaceChildren(...plugins.map(plugin => {
|
||||
const item = document.createElement('li')
|
||||
const identity = document.createElement('span')
|
||||
const version = document.createElement('span')
|
||||
version.className = 'package-version'
|
||||
version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}`
|
||||
identity.append(document.createTextNode(plugin.name), version)
|
||||
const remove = document.createElement('button')
|
||||
remove.type = 'button'
|
||||
remove.textContent = messages.remove
|
||||
remove.addEventListener('click', () => void run(
|
||||
() => api.plugins.remove(plugin.name),
|
||||
message('removing', { name: plugin.name }),
|
||||
))
|
||||
const update = document.createElement('button')
|
||||
update.type = 'button'
|
||||
update.textContent = messages.update
|
||||
update.addEventListener('click', () => {
|
||||
updateName = plugin.name
|
||||
updateTrigger = update
|
||||
document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name })
|
||||
updateVersion.value = plugin.version
|
||||
updateForm.hidden = false
|
||||
updateVersion.focus()
|
||||
updateVersion.select()
|
||||
})
|
||||
const actions = document.createElement('span')
|
||||
actions.className = 'package-actions'
|
||||
const toggle = document.createElement('button')
|
||||
toggle.type = 'button'
|
||||
toggle.textContent = plugin.enabled ? messages.disable : messages.enable
|
||||
toggle.addEventListener('click', () => void run(
|
||||
() => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation,
|
||||
))
|
||||
actions.append(toggle, update, remove)
|
||||
item.append(identity, actions)
|
||||
return item
|
||||
}))
|
||||
empty.hidden = plugins.length !== 0
|
||||
}
|
||||
|
||||
async function run(operation, statusMessage) {
|
||||
setBusy(true, statusMessage)
|
||||
try {
|
||||
await operation()
|
||||
await render()
|
||||
status.textContent = messages.operationComplete
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
setBusy(false, status.textContent)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(statusMessage, success) {
|
||||
setBusy(true, statusMessage)
|
||||
try {
|
||||
await render()
|
||||
status.textContent = success
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
setBusy(false, status.textContent)
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
const spec = input.value.trim()
|
||||
if (spec === '') return
|
||||
void run(async () => {
|
||||
await api.plugins.add(spec)
|
||||
input.value = ''
|
||||
}, message('installing', { spec }))
|
||||
})
|
||||
updateForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
const version = updateVersion.value.trim()
|
||||
if (version === '') return
|
||||
const name = updateName
|
||||
updateForm.hidden = true
|
||||
void run(() => api.plugins.update(name, version), message('updating', { name }))
|
||||
})
|
||||
document.querySelector('#update-cancel').addEventListener('click', () => {
|
||||
updateForm.hidden = true
|
||||
updateTrigger.focus()
|
||||
})
|
||||
refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
|
||||
|
||||
await load(messages.loadingPlugins, '')
|
||||
}
|
||||
|
||||
void main()
|
||||
+1
-37
@@ -1,54 +1,18 @@
|
||||
/** Typed preload operations exposed only by the Electron shell. */
|
||||
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopPluginRecord } from './project-manager.ts'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
/** IPC channel names kept private to the desktop application bundle. */
|
||||
export const DESKTOP_IPC = {
|
||||
localeGet: 'dsh-desktop:locale-get',
|
||||
boot: 'dsh-desktop:boot',
|
||||
bootFailed: 'dsh-desktop:boot-failed',
|
||||
directoryPick: 'dsh-desktop:directory-pick',
|
||||
pluginsList: 'dsh-desktop:plugins-list',
|
||||
pluginsAdd: 'dsh-desktop:plugins-add',
|
||||
pluginsRemove: 'dsh-desktop:plugins-remove',
|
||||
pluginsUpdate: 'dsh-desktop:plugins-update',
|
||||
pluginsToggle: 'dsh-desktop:plugins-toggle',
|
||||
updatesCheck: 'dsh-desktop:updates-check',
|
||||
updatesInstall: 'dsh-desktop:updates-install',
|
||||
updatesState: 'dsh-desktop:updates-state',
|
||||
nativeThemeSet: 'dsh-desktop:native-theme-set',
|
||||
windowsAppearance: 'dsh-desktop:windows-appearance',
|
||||
windowsMenu: 'dsh-desktop:windows-menu',
|
||||
} as const
|
||||
|
||||
/** Desktop release update state rendered by desktop-owned UI. */
|
||||
export interface DesktopUpdateState {
|
||||
readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error'
|
||||
readonly version?: string
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Narrow bridge exposed through context isolation. */
|
||||
export interface DshDesktopApi {
|
||||
readonly protocolVersion: 1
|
||||
locale(): Promise<DesktopLocale>
|
||||
readonly plugins: {
|
||||
list(): Promise<readonly DesktopPluginRecord[]>
|
||||
add(spec: string): Promise<void>
|
||||
remove(name: string): Promise<void>
|
||||
update(name: string, version: string): Promise<void>
|
||||
toggle(name: string, enabled: boolean): Promise<void>
|
||||
}
|
||||
readonly updates: {
|
||||
check(): Promise<DesktopUpdateState>
|
||||
install(): Promise<void>
|
||||
subscribe(listener: (state: DesktopUpdateState) => void): () => void
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheme of Desktop-owned application and shell documents. */
|
||||
/** Scheme of Desktop-owned application documents. */
|
||||
export const SCHEME = 'dsh-app'
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,6 @@ export const en = {
|
||||
restartApplication: 'Restart',
|
||||
recoveryOperationFailed: 'The recovery operation failed',
|
||||
disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
|
||||
pluginsMenu: 'Desktop Plugins…',
|
||||
checkUpdatesMenu: 'Check for Updates…',
|
||||
updateCheckFailedTitle: 'Update Check Failed',
|
||||
unknownError: 'Unknown error',
|
||||
@@ -32,29 +31,6 @@ export const en = {
|
||||
installAndRestart: 'Install and Restart',
|
||||
later: 'Later',
|
||||
updateFailedTitle: 'Update Failed',
|
||||
pluginManagerTitle: 'Desktop Plugins',
|
||||
pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins',
|
||||
pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.',
|
||||
refresh: 'Refresh',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
disabled: 'Disabled',
|
||||
changingActivation: 'Changing plugin activation…',
|
||||
npmPackage: 'npm package',
|
||||
install: 'Install',
|
||||
installed: 'Installed',
|
||||
noPlugins: 'No Desktop plugins are installed.',
|
||||
remove: 'Remove',
|
||||
update: 'Update',
|
||||
cancel: 'Cancel',
|
||||
targetVersion: 'Enter the target version for {name}',
|
||||
removing: 'Removing {name}…',
|
||||
updating: 'Updating {name}…',
|
||||
installing: 'Installing {spec}…',
|
||||
operationComplete: 'Done. The Desktop backend has restarted.',
|
||||
refreshing: 'Refreshing…',
|
||||
refreshed: 'Plugin list refreshed.',
|
||||
loadingPlugins: 'Reading Desktop plugins…',
|
||||
} as const
|
||||
|
||||
/** Every Desktop locale supplies the complete English key set. */
|
||||
@@ -80,7 +56,6 @@ export const zh = {
|
||||
restartApplication: '重启',
|
||||
recoveryOperationFailed: '恢复操作失败',
|
||||
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
|
||||
pluginsMenu: '桌面插件…',
|
||||
checkUpdatesMenu: '检查更新…',
|
||||
updateCheckFailedTitle: '更新检查失败',
|
||||
unknownError: '未知错误',
|
||||
@@ -92,42 +67,13 @@ export const zh = {
|
||||
installAndRestart: '安装并重启',
|
||||
later: '稍后',
|
||||
updateFailedTitle: '更新失败',
|
||||
pluginManagerTitle: '桌面插件',
|
||||
pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
|
||||
pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
|
||||
refresh: '刷新',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
disabled: '已禁用',
|
||||
changingActivation: '正在更改插件启用状态…',
|
||||
npmPackage: 'npm 包',
|
||||
install: '安装',
|
||||
installed: '已安装',
|
||||
noPlugins: '还没有安装桌面插件。',
|
||||
remove: '移除',
|
||||
update: '更新',
|
||||
cancel: '取消',
|
||||
targetVersion: '输入 {name} 的目标版本',
|
||||
removing: '正在移除 {name}…',
|
||||
updating: '正在更新 {name}…',
|
||||
installing: '正在安装 {spec}…',
|
||||
operationComplete: '操作完成,桌面后端已重新启动。',
|
||||
refreshing: '正在刷新…',
|
||||
refreshed: '插件列表已刷新。',
|
||||
loadingPlugins: '正在读取桌面插件…',
|
||||
} as const satisfies DesktopMessages
|
||||
|
||||
/** Locale payload exposed to the Desktop-owned renderer. */
|
||||
export interface DesktopLocale {
|
||||
readonly id: 'en' | 'zh-CN'
|
||||
readonly messages: DesktopMessages
|
||||
}
|
||||
|
||||
/** Resolve Electron's locale to one shipped Desktop dictionary. */
|
||||
export function resolveDesktopLocale(locale: string): DesktopLocale {
|
||||
export function resolveDesktopLocale(locale: string): DesktopMessages {
|
||||
return locale.toLowerCase().startsWith('zh')
|
||||
? { id: 'zh-CN', messages: zh }
|
||||
: { id: 'en', messages: en }
|
||||
? zh
|
||||
: en
|
||||
}
|
||||
|
||||
/** Replace named placeholders in one locale-owned message. */
|
||||
|
||||
+31
-154
@@ -1,8 +1,8 @@
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { extname, join, normalize, resolve, sep } from 'node:path'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
app,
|
||||
@@ -14,15 +14,14 @@ import {
|
||||
protocol,
|
||||
session,
|
||||
shell,
|
||||
type IpcMainInvokeEvent,
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron'
|
||||
import { resolveDesktopPaths } from './paths.ts'
|
||||
import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
|
||||
import { DesktopProjectManager } from './project-manager.ts'
|
||||
import { DesktopHostProcess } from './host-process.ts'
|
||||
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
||||
import { DesktopBackendController } from './backend-controller.ts'
|
||||
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
||||
import { DESKTOP_IPC, SCHEME, assertDesktopSender } from './ipc.ts'
|
||||
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
|
||||
import { claimDesktopSingleInstance } from './single-instance.ts'
|
||||
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
||||
@@ -38,7 +37,7 @@ function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
|
||||
return resolveDesktopLocale(windowsLanguage ?? app.getLocale())
|
||||
}
|
||||
const recovery = new DesktopFatalRecovery({
|
||||
messages: () => currentDesktopLocale().messages,
|
||||
messages: () => currentDesktopLocale(),
|
||||
show: options => dialog.showMessageBox(options),
|
||||
stop: () => { shuttingDown = true; return stopForRecovery() },
|
||||
disablePlugins: async () => {
|
||||
@@ -68,13 +67,6 @@ protocol.registerSchemesAsPrivileged([{
|
||||
},
|
||||
}])
|
||||
|
||||
const MIME: Readonly<Record<string, string>> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
interface RuntimeResources {
|
||||
readonly nodeBin: string
|
||||
readonly node: string
|
||||
@@ -104,14 +96,14 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
|
||||
return port
|
||||
}
|
||||
|
||||
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
|
||||
function createWindow(): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 880,
|
||||
minHeight: 600,
|
||||
show,
|
||||
...(process.platform === 'win32' && primary ? {
|
||||
show: true,
|
||||
...(process.platform === 'win32' ? {
|
||||
titleBarStyle: 'hidden' as const,
|
||||
titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb',
|
||||
symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' },
|
||||
@@ -128,7 +120,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
backgroundColor: '#00000000',
|
||||
} : {}),
|
||||
webPreferences: {
|
||||
preload,
|
||||
preload: fileURLToPath(new URL('./preload-app.cjs', import.meta.url)),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
@@ -157,7 +149,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
}
|
||||
// Empty accelerators suppress Electron's default shortcut labels for native roles.
|
||||
if (items.length > 0) {
|
||||
const messages = currentDesktopLocale().messages
|
||||
const messages = currentDesktopLocale()
|
||||
Menu.buildFromTemplate(items.map(item => ({
|
||||
...item,
|
||||
...(process.platform === 'win32' && item.role !== undefined && item.role in messages
|
||||
@@ -178,26 +170,6 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
return window
|
||||
}
|
||||
|
||||
async function serveShellAsset(request: Request): Promise<Response> {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
|
||||
const root = resolve(app.getAppPath(), 'renderer')
|
||||
const url = new URL(request.url)
|
||||
let pathname: string
|
||||
try {
|
||||
pathname = decodeURIComponent(url.pathname)
|
||||
} catch {
|
||||
return new Response(null, { status: 400 })
|
||||
}
|
||||
const target = resolve(normalize(join(root, pathname)))
|
||||
if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 })
|
||||
try {
|
||||
const body = request.method === 'HEAD' ? null : await readFile(target)
|
||||
return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } })
|
||||
} catch {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const resources = runtimeResources()
|
||||
const paths = resolveDesktopPaths()
|
||||
@@ -207,11 +179,7 @@ async function main(): Promise<void> {
|
||||
let quitting = false
|
||||
let startup: Promise<void> | undefined
|
||||
let mainWindow: BrowserWindow | undefined
|
||||
let pluginWindow: BrowserWindow | undefined
|
||||
let shellInstallerOwnsQuit = false
|
||||
let updateState: DesktopUpdateState = { phase: 'idle' }
|
||||
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
|
||||
const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
|
||||
const applicationUrl = `${SCHEME}://app/`
|
||||
let hostUrl: string | undefined
|
||||
let hostCookie: string | undefined
|
||||
@@ -252,19 +220,6 @@ async function main(): Promise<void> {
|
||||
if (state.phase === 'error') reportFatal(new Error(state.message))
|
||||
})
|
||||
|
||||
const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
|
||||
updateState = state
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send(DESKTOP_IPC.updatesState, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
const hooks: DesktopProjectHooks = {
|
||||
beforeChange: () => backend.stop(),
|
||||
afterChange: () => backend.start(async () => {}),
|
||||
}
|
||||
|
||||
stopForRecovery = () => backend.close()
|
||||
|
||||
const reconcileBackend = (): Promise<void> => {
|
||||
@@ -282,7 +237,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const updates = new DesktopUpdateCoordinator(
|
||||
publishUpdate,
|
||||
async () => {
|
||||
shellInstallerOwnsQuit = true
|
||||
await backend.stop()
|
||||
@@ -301,7 +255,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
return forwardWebRequest(request, hostUrl, hostCookie)
|
||||
}
|
||||
if (url.hostname === 'shell') return serveShellAsset(request)
|
||||
return Promise.resolve(new Response(null, { status: 404 }))
|
||||
})
|
||||
|
||||
@@ -336,68 +289,19 @@ async function main(): Promise<void> {
|
||||
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
|
||||
})
|
||||
|
||||
const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
await startup?.catch(() => undefined)
|
||||
if (recovery.active) throw new Error(currentDesktopLocale().messages.fatalSummary)
|
||||
try {
|
||||
await manager.mutate(mutation, hooks)
|
||||
} finally {
|
||||
if (backend.state.phase === 'ready') {
|
||||
navigation = undefined
|
||||
await navigateMain(applicationUrl).catch(reportFatal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
return currentDesktopLocale()
|
||||
})
|
||||
// Only the main window may synchronize its palette with the native material.
|
||||
ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
|
||||
if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
|
||||
if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
return manager.listPlugins()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => {
|
||||
if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string')
|
||||
return mutate(event, { type: 'plugin-add', spec })
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => {
|
||||
if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string')
|
||||
return mutate(event, { type: 'plugin-remove', name })
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => {
|
||||
if (typeof name !== 'string' || typeof version !== 'string') {
|
||||
throw new Error('dsh desktop: plugin name and version must be strings')
|
||||
}
|
||||
return mutate(event, { type: 'plugin-update', name, version })
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => {
|
||||
if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
|
||||
return mutate(event, { type: 'plugin-toggle', name, enabled })
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
return updates.check()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
await updates.install()
|
||||
})
|
||||
|
||||
const checkAndPrompt = async (manual: boolean): Promise<void> => {
|
||||
const state = await updates.check()
|
||||
if (state.phase === 'error') {
|
||||
if (manual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: currentDesktopLocale().messages.updateCheckFailedTitle,
|
||||
message: state.message ?? currentDesktopLocale().messages.unknownError,
|
||||
title: currentDesktopLocale().updateCheckFailedTitle,
|
||||
message: state.message ?? currentDesktopLocale().unknownError,
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -406,18 +310,18 @@ async function main(): Promise<void> {
|
||||
if (manual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: currentDesktopLocale().messages.updateCheckTitle,
|
||||
message: state.message ?? currentDesktopLocale().messages.updateCurrent,
|
||||
title: currentDesktopLocale().updateCheckTitle,
|
||||
message: state.message ?? currentDesktopLocale().updateCurrent,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: currentDesktopLocale().messages.updateTitle,
|
||||
message: currentDesktopLocale().messages.updateAvailable,
|
||||
detail: formatDesktopMessage(currentDesktopLocale().messages.updateDetail, { version: state.version ?? '' }),
|
||||
buttons: [currentDesktopLocale().messages.installAndRestart, currentDesktopLocale().messages.later],
|
||||
title: currentDesktopLocale().updateTitle,
|
||||
message: currentDesktopLocale().updateAvailable,
|
||||
detail: formatDesktopMessage(currentDesktopLocale().updateDetail, { version: state.version ?? '' }),
|
||||
buttons: [currentDesktopLocale().installAndRestart, currentDesktopLocale().later],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
@@ -426,25 +330,12 @@ async function main(): Promise<void> {
|
||||
if (installed.phase === 'error') {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: currentDesktopLocale().messages.updateFailedTitle,
|
||||
message: installed.message ?? currentDesktopLocale().messages.unknownError,
|
||||
title: currentDesktopLocale().updateFailedTitle,
|
||||
message: installed.message ?? currentDesktopLocale().unknownError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openPluginWindow = (): void => {
|
||||
if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
|
||||
pluginWindow.focus()
|
||||
return
|
||||
}
|
||||
pluginWindow = createWindow(managementPreload)
|
||||
pluginWindow.setSize(900, 620)
|
||||
pluginWindow.setTitle(currentDesktopLocale().messages.pluginWindowTitle)
|
||||
pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
|
||||
pluginWindow.once('closed', () => { pluginWindow = undefined })
|
||||
void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
|
||||
}
|
||||
|
||||
// A custom application menu replaces Electron's default menu, so macOS needs
|
||||
// its standard menus and application hide commands declared explicitly.
|
||||
const darwin = process.platform === 'darwin'
|
||||
@@ -455,18 +346,13 @@ async function main(): Promise<void> {
|
||||
? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }]
|
||||
: []
|
||||
const applicationItems = (): MenuItemConstructorOptions[] => [
|
||||
{
|
||||
label: currentDesktopLocale().messages.pluginsMenu,
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: openPluginWindow,
|
||||
},
|
||||
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
|
||||
{ label: currentDesktopLocale().checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
|
||||
{ type: 'separator' },
|
||||
...hideCommands,
|
||||
{ role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
|
||||
{ role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().exitApplication } : {}) },
|
||||
]
|
||||
Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
|
||||
label: process.platform === 'darwin' ? app.name : currentDesktopLocale().messages.application,
|
||||
label: process.platform === 'darwin' ? app.name : currentDesktopLocale().application,
|
||||
submenu: applicationItems(),
|
||||
}, ...platformMenus]))
|
||||
|
||||
@@ -490,15 +376,15 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
|
||||
editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'),
|
||||
editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'),
|
||||
editItem(currentDesktopLocale().undo, 'Z', ['control'], 'Ctrl+Z'),
|
||||
editItem(currentDesktopLocale().redo, 'Y', ['control'], 'Ctrl+Y'),
|
||||
{ type: 'separator' },
|
||||
editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'),
|
||||
editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'),
|
||||
editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'),
|
||||
editItem(currentDesktopLocale().messages.delete, 'Delete', []),
|
||||
editItem(currentDesktopLocale().cut, 'X', ['control'], 'Ctrl+X'),
|
||||
editItem(currentDesktopLocale().copy, 'C', ['control'], 'Ctrl+C'),
|
||||
editItem(currentDesktopLocale().paste, 'V', ['control'], 'Ctrl+V'),
|
||||
editItem(currentDesktopLocale().delete, 'Delete', []),
|
||||
{ type: 'separator' },
|
||||
editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'),
|
||||
editItem(currentDesktopLocale().selectAll, 'A', ['control'], 'Ctrl+A'),
|
||||
]
|
||||
const zoom = mainWindow.webContents.getZoomFactor()
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -520,16 +406,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const createMainWindow = (): BrowserWindow => {
|
||||
const window = createWindow(appPreload, true, true)
|
||||
const window = createWindow()
|
||||
mainWindow = window
|
||||
if (process.platform === 'win32') {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type === 'keyDown' && input.control && !input.alt && !input.shift && input.key === ',') {
|
||||
event.preventDefault()
|
||||
openPluginWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
|
||||
window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
|
||||
if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
|
||||
@@ -582,7 +460,6 @@ async function main(): Promise<void> {
|
||||
if (mainWindow !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
publishUpdate(updateState)
|
||||
setTimeout(() => { void checkAndPrompt(false) }, 10_000)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function installWindowsMenu(): { update(): void; dispose(): void } {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
mount()
|
||||
const update = (): void => {
|
||||
const { messages } = resolveDesktopLocale(document.documentElement.lang)
|
||||
const messages = resolveDesktopLocale(document.documentElement.lang)
|
||||
bar.setAttribute('aria-label', messages.menuBar)
|
||||
buttons[0].textContent = messages.application
|
||||
buttons[1].textContent = messages.edit
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/** Context-isolated renderer bridge for desktop package and update operations. */
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
|
||||
import { markDocumentPlatform } from './preload-platform.ts'
|
||||
|
||||
const api: DshDesktopApi = {
|
||||
protocolVersion: 1,
|
||||
locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise<ReturnType<DshDesktopApi['locale']> extends Promise<infer T> ? T : never>,
|
||||
plugins: {
|
||||
list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise<ReturnType<DshDesktopApi['plugins']['list']> extends Promise<infer T> ? T : never>,
|
||||
add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise<void>,
|
||||
remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise<void>,
|
||||
toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise<void>,
|
||||
update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
|
||||
},
|
||||
updates: {
|
||||
check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
|
||||
install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) }
|
||||
ipcRenderer.on(DESKTOP_IPC.updatesState, handle)
|
||||
return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
markDocumentPlatform()
|
||||
contextBridge.exposeInMainWorld('dshDesktop', api)
|
||||
@@ -1,10 +1,8 @@
|
||||
/** In-place owner of the desktop profile and its pnpm operations. */
|
||||
/** Desktop profile initialization and native recovery. */
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
ftruncateSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
writeSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { desktopNodeEnvironment } from './node-environment.ts'
|
||||
import {
|
||||
DESKTOP_HOST_PACKAGE,
|
||||
desktopCorePackageOverrides,
|
||||
@@ -23,56 +20,18 @@ import {
|
||||
} from './core-package-set.ts'
|
||||
import type { DesktopPaths } from './paths.ts'
|
||||
import type { DesktopRelease } from './release.ts'
|
||||
import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import {
|
||||
initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
|
||||
unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
|
||||
initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { migrateDesktopProfileLinks } from './profile-packages.ts'
|
||||
import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
|
||||
|
||||
/** Desktop plugin record derived from the installed profile. */
|
||||
export interface DesktopPluginRecord {
|
||||
readonly name: string
|
||||
/** Installed version, or the dependency spec when installed metadata is unavailable. */
|
||||
readonly version: string
|
||||
readonly enabled: boolean
|
||||
}
|
||||
|
||||
/** Exact executables the desktop shell bundles. */
|
||||
export interface DesktopRuntimeExecutables {
|
||||
readonly node: string
|
||||
readonly nodeBin?: string
|
||||
readonly pnpm: string
|
||||
readonly dsh: string
|
||||
}
|
||||
|
||||
/** Hooks that stop the backend before profile writes and restart it after each attempted change. */
|
||||
export interface DesktopProjectHooks {
|
||||
/** Stop the active backend and await process exit before modifying its files. */
|
||||
beforeChange(): Promise<void>
|
||||
/** Start the current profile even when package preparation failed with partial writes. */
|
||||
afterChange(): Promise<void>
|
||||
}
|
||||
|
||||
/** Supported dependency mutation. */
|
||||
export type DesktopProjectMutation =
|
||||
| { readonly type: 'plugin-add'; readonly spec: string }
|
||||
| { readonly type: 'plugin-remove'; readonly name: string }
|
||||
| { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
|
||||
| { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
|
||||
|
||||
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
|
||||
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local'
|
||||
const WEB_PROFILE = PROFILE_TEMPLATES.web as ProfileTemplate
|
||||
const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\n'
|
||||
const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024
|
||||
|
||||
function errorOf(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback)
|
||||
}
|
||||
|
||||
function writeJson(path: string, value: unknown): void {
|
||||
writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
@@ -99,28 +58,17 @@ function migrateProfileSettings(projectDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop npm project manager with direct writes and no rollback. */
|
||||
/** Initializes the Desktop profile and disables third-party bundles during recovery. */
|
||||
export class DesktopProjectManager {
|
||||
private lockDescriptor: number | undefined
|
||||
private descriptor: DesktopRuntimeDescriptor | undefined
|
||||
|
||||
/**
|
||||
* @param paths - Electron-owned package state and reserved desktop profile paths.
|
||||
* @param runtime - absolute Electron and pnpm entry paths.
|
||||
* @param runtime - location of the bundled application runtime.
|
||||
*/
|
||||
constructor(
|
||||
readonly paths: DesktopPaths,
|
||||
readonly runtime: DesktopRuntimeExecutables,
|
||||
readonly runtime: { readonly dsh: string },
|
||||
) {}
|
||||
|
||||
/** Read the active desktop plugin inventory. */
|
||||
listPlugins(): readonly DesktopPluginRecord[] {
|
||||
if (!existsSync(this.paths.profile)) return []
|
||||
return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies
|
||||
.map(({ name, version, enabled }) => ({ name, version, enabled }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Back up the profile patch and disable third-party bundles without loading application resources.
|
||||
* The caller must stop the Host first.
|
||||
@@ -130,145 +78,20 @@ export class DesktopProjectManager {
|
||||
return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
|
||||
}
|
||||
|
||||
/** Read the dsh version supplied by this application's verified resources. */
|
||||
dshVersion(): string {
|
||||
return this.currentRuntime().release.version
|
||||
}
|
||||
|
||||
private currentRuntime(): DesktopRuntimeDescriptor {
|
||||
if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
|
||||
return this.descriptor
|
||||
}
|
||||
|
||||
private readRuntime(): DesktopRuntimeDescriptor {
|
||||
this.descriptor = undefined
|
||||
return readDesktopRuntime(this.runtime.dsh)
|
||||
}
|
||||
|
||||
private pluginLocation(profileDir: string) {
|
||||
return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load application metadata and prepare the external plugin profile without installing packages.
|
||||
* @param production - Remove application-owned profile packages before packaged Host startup.
|
||||
*/
|
||||
async applyRelease(production = false): Promise<void> {
|
||||
await this.withLock(() => {
|
||||
this.descriptor = this.readRuntime()
|
||||
cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production)
|
||||
const descriptor = readDesktopRuntime(this.runtime.dsh)
|
||||
cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production)
|
||||
migrateProfileSettings(this.paths.profile)
|
||||
migrateDesktopProfileLinks(this.paths.profile)
|
||||
createPluginProfile(this.paths.profile)
|
||||
})
|
||||
}
|
||||
|
||||
/** Modify the current profile while its backend is stopped; failures retain partial changes. */
|
||||
async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
|
||||
await this.withLock(async () => {
|
||||
this.currentRuntime()
|
||||
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
|
||||
await hooks.beforeChange()
|
||||
try {
|
||||
await this.applyMutation(this.paths.profile, mutation)
|
||||
} catch (error) {
|
||||
try { await hooks.afterChange() } catch (restartError) {
|
||||
throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await hooks.afterChange()
|
||||
})
|
||||
}
|
||||
|
||||
private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise<void> {
|
||||
const location = this.pluginLocation(projectDir)
|
||||
const before = readProfilePlugins(location)
|
||||
const bundles = before.manifest.dsh?.profile?.bundles ?? []
|
||||
switch (mutation.type) {
|
||||
case 'plugin-add':
|
||||
case 'plugin-update': {
|
||||
const spec = mutation.type === 'plugin-add' ? mutation.spec : `${mutation.name}@${mutation.version}`
|
||||
unlinkProfileModuleFallback(projectDir)
|
||||
await this.runPnpm(projectDir, ['add', '--', spec])
|
||||
reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
|
||||
return
|
||||
}
|
||||
case 'plugin-remove':
|
||||
unlinkProfileModuleFallback(projectDir)
|
||||
await this.runPnpm(projectDir, ['remove', '--', mutation.name])
|
||||
reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
|
||||
return
|
||||
case 'plugin-toggle':
|
||||
writeProfileBundles(projectDir, before.manifest, mutation.enabled
|
||||
? bundles.includes(mutation.name) ? bundles : [...bundles, mutation.name]
|
||||
: bundles.filter(name => name !== mutation.name))
|
||||
return
|
||||
default:
|
||||
mutation satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
|
||||
await new Promise<void>((settle, reject) => {
|
||||
const child = spawn(this.runtime.node, ['--expose-internals', this.runtime.pnpm, ...args], {
|
||||
cwd: projectDir,
|
||||
env: desktopNodeEnvironment(this.runtime.node, this.runtime.nodeBin, process.env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let failure: Error | undefined
|
||||
let diagnostics = ''
|
||||
let completed = false
|
||||
const appendDiagnostics = (chunk: string): void => {
|
||||
diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES)
|
||||
}
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', appendDiagnostics)
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', appendDiagnostics)
|
||||
const complete = (settleChild: () => void): void => {
|
||||
if (completed) return
|
||||
completed = true
|
||||
try {
|
||||
this.writeLockOwner(process.pid)
|
||||
} catch (error) {
|
||||
reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron'))
|
||||
return
|
||||
}
|
||||
settleChild()
|
||||
}
|
||||
child.once('error', (error) => { failure = error })
|
||||
child.once('close', (code, signal) => {
|
||||
complete(() => {
|
||||
if (failure !== undefined) { reject(failure); return }
|
||||
if (code === 0) {
|
||||
settle()
|
||||
return
|
||||
}
|
||||
reject(new Error(
|
||||
`desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
|
||||
))
|
||||
})
|
||||
})
|
||||
try {
|
||||
if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id')
|
||||
this.writeLockOwner(child.pid)
|
||||
} catch (error) {
|
||||
failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private writeLockOwner(pid: number): void {
|
||||
const descriptor = this.lockDescriptor
|
||||
if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock')
|
||||
const content = Buffer.from(`${String(pid)}\n`)
|
||||
ftruncateSync(descriptor, 0)
|
||||
writeSync(descriptor, content, 0, content.byteLength, 0)
|
||||
fsyncSync(descriptor)
|
||||
}
|
||||
|
||||
private async withLock<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 })
|
||||
let descriptor: number
|
||||
@@ -278,7 +101,7 @@ export class DesktopProjectManager {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const lock = lstatSync(this.paths.lock)
|
||||
if (lock.isSymbolicLink() || !lock.isFile()) {
|
||||
throw new Error('desktop project: package transaction lock is not a regular file')
|
||||
throw new Error('desktop project: profile lock is not a regular file')
|
||||
}
|
||||
const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10)
|
||||
let active = !Number.isSafeInteger(owner) || owner <= 0
|
||||
@@ -290,7 +113,7 @@ export class DesktopProjectManager {
|
||||
active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
if (active) throw new Error('desktop project: another package transaction is active')
|
||||
if (active) throw new Error('desktop project: another profile operation is active')
|
||||
unlinkSync(this.paths.lock)
|
||||
descriptor = openSync(this.paths.lock, 'wx', 0o600)
|
||||
} else {
|
||||
@@ -298,11 +121,10 @@ export class DesktopProjectManager {
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.lockDescriptor = descriptor
|
||||
this.writeLockOwner(process.pid)
|
||||
writeSync(descriptor, `${String(process.pid)}\n`)
|
||||
fsyncSync(descriptor)
|
||||
return await operation()
|
||||
} finally {
|
||||
this.lockDescriptor = undefined
|
||||
closeSync(descriptor)
|
||||
unlinkSync(this.paths.lock)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import electronUpdater, { type AppUpdater } from 'electron-updater'
|
||||
import type { DesktopUpdateState } from './ipc.ts'
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
/** Result of checking or installing a Desktop release. */
|
||||
export interface DesktopUpdateState {
|
||||
readonly phase: 'idle' | 'available' | 'ready' | 'error'
|
||||
readonly version?: string
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Checks, downloads, and installs one complete Desktop release. */
|
||||
export class DesktopUpdateCoordinator {
|
||||
private availableVersion: string | undefined
|
||||
@@ -14,13 +20,11 @@ export class DesktopUpdateCoordinator {
|
||||
private installOperation: Promise<DesktopUpdateState> | undefined
|
||||
|
||||
/**
|
||||
* @param publish - state sink for every desktop window.
|
||||
* @param beforeRestart - stop application-owned processes before replacement.
|
||||
* @param updater - Electron artifact updater; replaceable for tests.
|
||||
* @param enabled - whether this packaged process carries updater configuration.
|
||||
*/
|
||||
constructor(
|
||||
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
||||
private readonly beforeRestart: () => Promise<void> = async () => {},
|
||||
private readonly updater: AppUpdater = autoUpdater,
|
||||
private readonly enabled: () => boolean = () => (
|
||||
@@ -50,24 +54,23 @@ export class DesktopUpdateCoordinator {
|
||||
}
|
||||
|
||||
private async doCheck(): Promise<DesktopUpdateState> {
|
||||
this.publish({ phase: 'checking' })
|
||||
try {
|
||||
if (!this.enabled()) {
|
||||
this.availableVersion = undefined
|
||||
return this.publish({ phase: 'idle' })
|
||||
return { phase: 'idle' }
|
||||
}
|
||||
const result = await this.updater.checkForUpdates()
|
||||
const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined
|
||||
this.availableVersion = version
|
||||
return version === undefined
|
||||
? this.publish({ phase: 'idle' })
|
||||
: this.publish({ phase: 'available', version })
|
||||
? { phase: 'idle' }
|
||||
: { phase: 'available', version }
|
||||
} catch (error) {
|
||||
this.availableVersion = undefined
|
||||
return this.publish({
|
||||
return {
|
||||
phase: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,20 +79,18 @@ export class DesktopUpdateCoordinator {
|
||||
if (version === undefined) {
|
||||
throw new Error('desktop update: no verified update is available')
|
||||
}
|
||||
this.publish({ phase: 'installing', version })
|
||||
try {
|
||||
await this.updater.downloadUpdate()
|
||||
this.availableVersion = undefined
|
||||
const ready = this.publish({ phase: 'ready', version })
|
||||
await this.beforeRestart()
|
||||
this.updater.quitAndInstall(false, true)
|
||||
return ready
|
||||
return { phase: 'ready', version }
|
||||
} catch (error) {
|
||||
return this.publish({
|
||||
return {
|
||||
phase: 'error',
|
||||
version,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { prepareDevelopmentProject } from '../scripts/development-project.ts'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import { DesktopProjectManager } from '../src/project-manager.ts'
|
||||
import { resolveDesktopPaths } from '../src/paths.ts'
|
||||
import { writePackage } from './runtime-fixture.ts'
|
||||
import type { DesktopRelease } from '../src/release.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -69,16 +68,9 @@ describe('desktop development project', () => {
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), {
|
||||
node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh: project,
|
||||
dsh: project,
|
||||
})
|
||||
const hooks = { beforeChange: async () => {}, afterChange: async () => {} }
|
||||
await manager.applyRelease()
|
||||
const local = writePackage(join(root, 'packages'), 'development-plugin', { dsh: { bundle: { patch: 'bundle.yml' } } })
|
||||
writeFileSync(join(local, 'bundle.yml'), '[]\n')
|
||||
await manager.mutate({ type: 'plugin-add', spec: `file:${local}` }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'development-plugin', version: '1.0.0', enabled: true }])
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'development-plugin' }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
await manager.disableAllPlugins()
|
||||
expect(readFileSync(join(cli, 'package.json'), 'utf8')).toBe('{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n')
|
||||
expect(readFileSync(join(host, 'lib', 'index.js'), 'utf8')).toBe('')
|
||||
|
||||
@@ -7,7 +7,7 @@ function fixture(locale = 'en') {
|
||||
const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>()
|
||||
const stopped = Promise.withResolvers<undefined>()
|
||||
const operations = {
|
||||
messages: () => resolveDesktopLocale(locale).messages,
|
||||
messages: () => resolveDesktopLocale(locale),
|
||||
show: vi.fn((_options: MessageBoxOptions) => choice.promise),
|
||||
stop: vi.fn(() => stopped.promise),
|
||||
disablePlugins: vi.fn(async () => {}),
|
||||
|
||||
@@ -27,6 +27,9 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
try {
|
||||
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
|
||||
const config = createElectronBuilderConfig(env, platform, 'x64')
|
||||
expect(config.files.filter(file => typeof file === 'string')).toEqual([
|
||||
'lib/main.js', 'lib/preload-app.cjs', 'package.json',
|
||||
])
|
||||
const packager = new Packager({ projectDir: tmpdir() })
|
||||
// A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs.
|
||||
Object.defineProperties(packager, {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts'
|
||||
|
||||
describe('desktop locale dictionaries', () => {
|
||||
it('ships the same key set in English and Chinese', () => {
|
||||
expect(Object.keys(zh)).toEqual(Object.keys(en))
|
||||
expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh })
|
||||
expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en })
|
||||
expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en })
|
||||
expect(resolveDesktopLocale('zh-Hans-CN')).toEqual(zh)
|
||||
expect(resolveDesktopLocale('en-US')).toEqual(en)
|
||||
expect(resolveDesktopLocale('fr-FR')).toEqual(en)
|
||||
})
|
||||
|
||||
it('formats named values without consuming unknown placeholders', () => {
|
||||
@@ -15,9 +14,4 @@ describe('desktop locale dictionaries', () => {
|
||||
.toBe('[email protected] {missing}')
|
||||
})
|
||||
|
||||
it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => {
|
||||
const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8')
|
||||
const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*)</gu)].map(match => match[1]?.trim())
|
||||
expect(staticText).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,7 @@ const harness = await vi.hoisted(async () => {
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost,
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
popup,
|
||||
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Promise<Response>) => void>(),
|
||||
socketHeaders: vi.fn(),
|
||||
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
||||
menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })) },
|
||||
@@ -106,7 +107,6 @@ const harness = await vi.hoisted(async () => {
|
||||
},
|
||||
openExternal: vi.fn(),
|
||||
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
||||
mutateFailure: vi.fn<() => void>(),
|
||||
disableAllPlugins: vi.fn(async () => {
|
||||
pluginsEnabled = false
|
||||
return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
|
||||
@@ -115,7 +115,6 @@ const harness = await vi.hoisted(async () => {
|
||||
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
||||
get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
|
||||
nextNavigation() { navigated = deferred(); return navigated.promise },
|
||||
nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
|
||||
get pluginsEnabled() { return pluginsEnabled },
|
||||
set pluginsEnabled(value: boolean) { pluginsEnabled = value },
|
||||
reset() {
|
||||
@@ -141,26 +140,13 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
Menu: harness.menu,
|
||||
session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
|
||||
}))
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
|
||||
vi.mock('../src/project-manager.ts', () => ({
|
||||
DesktopProjectManager: class {
|
||||
readonly applyRelease = harness.applyRelease
|
||||
disableAllPlugins = harness.disableAllPlugins
|
||||
async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
|
||||
await hooks.beforeChange()
|
||||
try {
|
||||
harness.mutateFailure()
|
||||
harness.pluginsEnabled = false
|
||||
} catch (error) {
|
||||
try { await hooks.afterChange() } catch (restartError) {
|
||||
throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await hooks.afterChange()
|
||||
}
|
||||
|
||||
},
|
||||
}))
|
||||
@@ -170,7 +156,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn
|
||||
function invoke(channel: string, ...args: unknown[]): unknown {
|
||||
const handler = harness.handlers.get(channel)
|
||||
if (handler === undefined) throw new Error(`missing handler ${channel}`)
|
||||
return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } }, ...args)
|
||||
return handler({ senderFrame: { url: 'dsh-app://app/' } }, ...args)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -200,6 +186,18 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop main startup', () => {
|
||||
it.each(['darwin', 'win32', 'linux'] as const)('exposes application IPC and rejects the removed shell document on %s', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
expect([...harness.handlers.keys()].sort()).toEqual([
|
||||
DESKTOP_IPC.boot, DESKTOP_IPC.bootFailed, DESKTOP_IPC.directoryPick,
|
||||
...(platform === 'win32' ? [DESKTOP_IPC.windowsMenu] : []),
|
||||
].sort())
|
||||
const handler = harness.protocolHandle.mock.calls[0]![1]
|
||||
expect((await handler(new Request('dsh-app://shell/plugin-manager.html'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'] as const)('uses platform-owned titlebar styling on %s', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
@@ -262,7 +260,7 @@ describe('desktop main startup', () => {
|
||||
expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
|
||||
const application = handler(event, 'application', 48, 34)
|
||||
expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
|
||||
'桌面插件…', '检查更新…', 'separator', '退出',
|
||||
'检查更新…', 'separator', '退出',
|
||||
])
|
||||
expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
|
||||
expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
|
||||
@@ -284,9 +282,8 @@ describe('desktop main startup', () => {
|
||||
await edit
|
||||
const preventDefault = vi.fn()
|
||||
window.webContents.emit('before-input-event', { preventDefault }, { type: 'keyDown', control: true, key: ',' })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.windows[1]!.urls).toEqual(['dsh-app://shell/plugin-manager.html'])
|
||||
expect(harness.windows[1]!.options).not.toHaveProperty('titleBarStyle')
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
|
||||
@@ -304,8 +301,8 @@ describe('desktop main startup', () => {
|
||||
: ['Application', 'editMenu'])
|
||||
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
||||
expect(application.map(describeItem)).toEqual(platform === 'darwin'
|
||||
? [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
: [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit'])
|
||||
? [en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
: [en.checkUpdatesMenu, 'separator', 'quit'])
|
||||
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -455,46 +452,6 @@ describe('desktop main startup', () => {
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load')
|
||||
})
|
||||
|
||||
it('returns package-operation failures locally without opening fatal recovery', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
const host = harness.hosts[0]!
|
||||
host.ready.resolve()
|
||||
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
||||
harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
|
||||
host.exited.resolve()
|
||||
const nextStarted = harness.nextHostStart()
|
||||
const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('package write failed')
|
||||
await nextStarted
|
||||
harness.hosts[1]!.ready.resolve()
|
||||
await failure
|
||||
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/'])
|
||||
})
|
||||
|
||||
it('opens fatal recovery when the Host cannot restart after a package change', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
const host = harness.hosts[0]!
|
||||
host.ready.resolve()
|
||||
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
||||
host.exited.resolve()
|
||||
const nextStarted = harness.nextHostStart()
|
||||
const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('new Host failed')
|
||||
await nextStarted
|
||||
const replacement = harness.hosts[1]!
|
||||
replacement.exited.resolve()
|
||||
replacement.ready.reject(new Error('new Host failed'))
|
||||
await failure
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('new Host failed')
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
it('offers all recovery choices when resources fail before the Host starts', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
@@ -625,7 +582,6 @@ describe('desktop main startup', () => {
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
||||
expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false)
|
||||
await expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.')
|
||||
expect(harness.hosts).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInContext } from 'node:vm'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
|
||||
it('keeps disabled packages visible without an in-page recovery panel', async () => {
|
||||
const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
|
||||
let enabled = false
|
||||
const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
|
||||
const api = {
|
||||
locale: async () => resolveDesktopLocale('en'),
|
||||
plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], toggle },
|
||||
}
|
||||
Object.defineProperty(dom.window, 'dshDesktop', { value: api })
|
||||
try {
|
||||
runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
|
||||
const document = dom.window.document
|
||||
await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
|
||||
expect(document.querySelector('#recovery')).toBeNull()
|
||||
expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"')
|
||||
document.querySelector<HTMLButtonElement>('#plugins li button')?.click()
|
||||
await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]])
|
||||
await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove')
|
||||
} finally { dom.window.close() }
|
||||
})
|
||||
|
||||
it.each(['en', 'zh-CN'])('updates through the inline form, including the installed version (%s)', async (language) => {
|
||||
const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
|
||||
const locale = resolveDesktopLocale(language)
|
||||
const update = vi.fn(async () => {})
|
||||
Object.defineProperty(dom.window, 'dshDesktop', { value: {
|
||||
locale: async () => locale,
|
||||
plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update },
|
||||
} })
|
||||
try {
|
||||
runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
|
||||
const document = dom.window.document
|
||||
await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
|
||||
const updateButton = () => document.querySelectorAll<HTMLButtonElement>('#plugins li button')[1]!
|
||||
const form = document.querySelector<HTMLFormElement>('#update-form')!
|
||||
const version = document.querySelector<HTMLInputElement>('#update-version')!
|
||||
updateButton().click()
|
||||
expect(form.hidden).toBe(false)
|
||||
expect(version.value).toBe('1.0.0')
|
||||
expect(document.activeElement).toBe(version)
|
||||
expect(document.querySelector('#update-label')?.textContent).toBe(locale.messages.targetVersion.replace('{name}', 'example-plugin'))
|
||||
expect(document.querySelector('#update-cancel')?.textContent).toBe(locale.messages.cancel)
|
||||
if (language === 'en') {
|
||||
expect({ label: document.querySelector('#update-label')?.textContent,
|
||||
actions: [...form.querySelectorAll('button')].map(button => button.textContent) }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"actions": [
|
||||
"Update",
|
||||
"Cancel",
|
||||
],
|
||||
"label": "Enter the target version for example-plugin",
|
||||
}
|
||||
`)
|
||||
}
|
||||
document.querySelector<HTMLButtonElement>('#update-cancel')!.click()
|
||||
expect(form.hidden).toBe(true)
|
||||
expect(document.activeElement).toBe(updateButton())
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
for (const target of ['1.0.0', '^2.0.0']) {
|
||||
updateButton().click()
|
||||
version.value = target
|
||||
form.requestSubmit()
|
||||
await expect.poll(() => document.querySelector('#status')?.textContent).toBe(locale.messages.operationComplete)
|
||||
expect(update).toHaveBeenLastCalledWith('example-plugin', target)
|
||||
expect(form.hidden).toBe(true)
|
||||
}
|
||||
expect(update).toHaveBeenCalledTimes(2)
|
||||
} finally { dom.window.close() }
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { c } from 'tar'
|
||||
import { expect, it } from 'vitest'
|
||||
import { healIsolatedProfileModuleFallback, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
|
||||
import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
|
||||
import { resolveDesktopPaths } from '../src/paths.ts'
|
||||
import { runtimeFixture, writePackage } from './runtime-fixture.ts'
|
||||
|
||||
it('installs a real pnpm graph and executes scripts approved by user configuration', async () => {
|
||||
// pnpm resolves its cwd natively; use the same spelling for Windows 8.3 temp paths.
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-')))
|
||||
const server = createServer()
|
||||
const archives = new Map<string, Buffer>()
|
||||
try {
|
||||
for (const name of ['fixture-plugin', 'fixture-script-dependency']) {
|
||||
const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin'
|
||||
? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } }
|
||||
: { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"')
|
||||
writeFileSync(join(path, 'bundle.yml'), '[]\n')
|
||||
writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath}))')
|
||||
const tarball = join(root, `${name}.tgz`)
|
||||
await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name])
|
||||
archives.set(name, readFileSync(tarball))
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address')
|
||||
const origin = `http://127.0.0.1:${address.port}`
|
||||
server.on('request', (request, response) => {
|
||||
const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? ''
|
||||
const archive = archives.get(name)
|
||||
if (archive === undefined) { response.writeHead(404); response.end(); return }
|
||||
if (request.url?.endsWith('.tgz')) { response.end(archive); return }
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {
|
||||
name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` },
|
||||
...(name === 'fixture-plugin' ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : { scripts: { install: 'node install.cjs' } }),
|
||||
} }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } }))
|
||||
})
|
||||
const dsh = join(root, 'dsh')
|
||||
runtimeFixture(dsh)
|
||||
const pnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
|
||||
const installAnchor = join(dsh, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
|
||||
const hooks: DesktopProjectHooks = {
|
||||
beforeChange: async () => {},
|
||||
afterChange: async () => {
|
||||
const profile = loadProfileDirectory('desktop test', manager.paths.profile, installAnchor)
|
||||
healIsolatedProfileModuleFallback({ installAnchor, profile })
|
||||
},
|
||||
}
|
||||
await manager.applyRelease()
|
||||
writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`)
|
||||
writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n fixture-script-dependency: true\n`)
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])
|
||||
const builtPath = join(manager.paths.profile, 'node_modules/fixture-script-dependency/built.json')
|
||||
if (!existsSync(builtPath)) {
|
||||
const config = (name: string): string => execFileSync(process.execPath, [pnpm, 'config', 'get', name], {
|
||||
cwd: manager.paths.profile, encoding: 'utf8',
|
||||
}).trim()
|
||||
throw new Error(`pnpm install script produced no output: ignoreScripts=${config('ignoreScripts')}, allowBuilds=${config('allowBuilds')}`)
|
||||
}
|
||||
const built = JSON.parse(readFileSync(builtPath, 'utf8')) as { node: string }
|
||||
expect(realpathSync(built.node)).toBe(realpathSync(process.execPath))
|
||||
const entry = join(dsh, 'identity.mjs')
|
||||
writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`)
|
||||
expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true')
|
||||
await manager.mutate({ type: 'plugin-update', name: 'fixture-plugin', version: '^1.0.0' }, hooks)
|
||||
expect(manager.listPlugins()[0]?.version).toBe('1.0.0')
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
if (server.listening) await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error !== undefined) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it.each(['directory', 'file', 'tarball', 'plain'] as const)('installs a %s source through pnpm', async (source) => {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-local-pnpm-')))
|
||||
try {
|
||||
const dsh = join(root, 'dsh')
|
||||
runtimeFixture(dsh)
|
||||
const packageDir = writePackage(join(root, 'local packages'), 'local-plugin', {
|
||||
...(source === 'plain' ? {} : { dsh: { bundle: { patch: 'missing.yml' } } }),
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^999.0.0' },
|
||||
})
|
||||
const tarball = join(root, 'local-plugin.tgz')
|
||||
await c({ file: tarball, cwd: join(packageDir, '..'), gzip: true }, ['local-plugin'])
|
||||
const spec = source === 'directory' ? packageDir : source === 'file' ? `file:${packageDir}` : tarball
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), {
|
||||
node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh,
|
||||
})
|
||||
const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} }
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'local-plugin', version: '1.0.0', enabled: source !== 'plain' }])
|
||||
expect(existsSync(join(manager.paths.profile, 'node_modules/local-plugin/missing.yml'))).toBe(false)
|
||||
await manager.disableAllPlugins()
|
||||
expect(manager.listPlugins()[0]?.enabled).toBe(false)
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'local-plugin' }, hooks)
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
expect(existsSync(join(packageDir, 'package.json'))).toBe(true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -13,7 +13,7 @@ vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
|
||||
|
||||
it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => {
|
||||
it.each(['dsh-app://app/index.html', 'dsh-app://unowned/index.html'])('exposes the carrier marker to %s', async (url) => {
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
await import('../src/preload-app.ts')
|
||||
expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
|
||||
@@ -41,7 +41,7 @@ it('exposes a directory picker only to the local application document', async ()
|
||||
electron.ipcRenderer.invoke.mockResolvedValue('/workspace')
|
||||
await expect(api.pick()).resolves.toBe('/workspace')
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick)
|
||||
for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) {
|
||||
for (const url of ['dsh-app://unowned/index.html', 'https://example.com/']) {
|
||||
vi.resetModules()
|
||||
electron.contextBridge.exposeInMainWorld.mockClear()
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
|
||||
@@ -1,73 +1,45 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveDesktopPaths } from '../src/paths.ts'
|
||||
import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
|
||||
import { DesktopProjectManager } from '../src/project-manager.ts'
|
||||
import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runtimeFixture } from './runtime-fixture.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const releaseWorkers: Array<() => Promise<void>> = []
|
||||
function temporaryRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
function writeFakePnpm(root: string): string {
|
||||
const path = join(root, 'pnpm.mjs')
|
||||
writeFileSync(path, `
|
||||
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
const args = process.argv.slice(2)
|
||||
const project = process.cwd()
|
||||
const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value))
|
||||
appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY, userconfig: process.env.NPM_CONFIG_USERCONFIG, pnpmHome: process.env.PNPM_HOME, configHome: process.env.XDG_CONFIG_HOME}) + '\\n')
|
||||
if (command !== 'rebuild') {
|
||||
const manifestPath = join(project, 'package.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
if (command === 'add') {
|
||||
const spec = args.includes('--') ? args[args.indexOf('--') + 1] : args[args.indexOf(command) + 1]
|
||||
const index = spec.lastIndexOf('@')
|
||||
const name = index > 0 ? spec.slice(0, index) : spec
|
||||
manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0'
|
||||
function seedPlugin(manager: DesktopProjectManager): void {
|
||||
const path = join(manager.paths.profile, 'package.json')
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
}
|
||||
if (command === 'remove') delete manifest.dependencies[args[args.indexOf('--') + 1]]
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest))
|
||||
rmSync(join(project, 'node_modules'), { recursive: true, force: true })
|
||||
for (const [name, version] of Object.entries(manifest.dependencies)) {
|
||||
const packageRoot = join(project, 'node_modules', name)
|
||||
mkdirSync(packageRoot, { recursive: true })
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version,
|
||||
peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}}))
|
||||
writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n')
|
||||
}
|
||||
writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies))
|
||||
manifest.dependencies.plugin = '1.0.0'
|
||||
manifest.dsh.profile.bundles.push('plugin')
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
const directory = join(manager.paths.profile, 'node_modules/plugin')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } }))
|
||||
writeFileSync(join(directory, 'bundle.yml'), '[]\n')
|
||||
}
|
||||
`)
|
||||
return path
|
||||
}
|
||||
function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
|
||||
return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
|
||||
function plugins(manager: DesktopProjectManager) {
|
||||
return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile,
|
||||
installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies
|
||||
.map(({ name, version, enabled }) => ({ name, version, enabled }))
|
||||
}
|
||||
function setup(): { root: string; manager: DesktopProjectManager } {
|
||||
const root = temporaryRoot()
|
||||
const dsh = join(root, 'resources', 'dsh')
|
||||
runtimeFixture(dsh)
|
||||
return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) }
|
||||
return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) }
|
||||
}
|
||||
function calls(root: string): { args: string[]; registry: string }[] {
|
||||
const path = join(root, 'pnpm-log.jsonl')
|
||||
return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : []
|
||||
}
|
||||
afterEach(async () => {
|
||||
const cleanups = releaseWorkers.splice(0)
|
||||
const directories = roots.splice(0)
|
||||
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
||||
for (const root of directories) rmSync(root, { recursive: true, force: true })
|
||||
vi.unstubAllEnvs()
|
||||
const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed')
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('desktop external plugin profile', () => {
|
||||
@@ -85,7 +57,7 @@ describe('desktop external plugin profile', () => {
|
||||
it('reuses plugin files without scanning manifests and can disable them', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
|
||||
writeFileSync(manifest, '{broken')
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
@@ -95,9 +67,9 @@ describe('desktop external plugin profile', () => {
|
||||
})
|
||||
|
||||
it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
|
||||
const { root, manager } = setup()
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
|
||||
unlinkSync(patch)
|
||||
await manager.disableAllPlugins()
|
||||
@@ -105,14 +77,13 @@ describe('desktop external plugin profile', () => {
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
}).dsh.profile.bundles).not.toContain('plugin')
|
||||
expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
|
||||
expect(calls(root)).toHaveLength(1)
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
const patch = join(manager.paths.profile, 'cordis.patch.yml')
|
||||
writeFileSync(patch, ': broken')
|
||||
const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
|
||||
@@ -151,47 +122,6 @@ describe('desktop external plugin profile', () => {
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
const path = join(manager.paths.profile, 'node_modules/plugin/package.json')
|
||||
if (damage === 'missing') unlinkSync(path)
|
||||
else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}')
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
||||
await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks())
|
||||
expect(manager.listPlugins()[0]?.enabled).toBe(false)
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves custom profile metadata and bundle order during plugin changes', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
const path = join(manager.paths.profile, 'package.json')
|
||||
writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true,
|
||||
dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }))
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true,
|
||||
dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } })
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
||||
expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({
|
||||
dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })
|
||||
})
|
||||
|
||||
it('passes user registry and pnpm configuration through to the bundled executable', async () => {
|
||||
const { root, manager } = setup()
|
||||
vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/')
|
||||
vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc'))
|
||||
vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm'))
|
||||
vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config'))
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks())
|
||||
expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/',
|
||||
userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'),
|
||||
args: ['add', '--', 'plugin@next'] })
|
||||
})
|
||||
|
||||
it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
@@ -215,8 +145,6 @@ describe('desktop external plugin profile', () => {
|
||||
writeFileSync(path, custom)
|
||||
await manager.applyRelease()
|
||||
expect(readFileSync(path, 'utf8')).toBe(custom)
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
expect(manager.listPlugins()[0]?.name).toBe('plugin')
|
||||
})
|
||||
|
||||
it('reports damaged application metadata as a reinstall failure', async () => {
|
||||
@@ -238,11 +166,10 @@ describe('desktop external plugin profile', () => {
|
||||
})
|
||||
|
||||
it('initializes and restarts offline without executing pnpm', async () => {
|
||||
const { root, manager } = setup()
|
||||
const { manager } = setup()
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
expect(calls(root)).toEqual([])
|
||||
expect(plugins(manager)).toEqual([])
|
||||
expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
|
||||
})
|
||||
|
||||
@@ -254,7 +181,7 @@ describe('desktop external plugin profile', () => {
|
||||
})
|
||||
|
||||
it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
|
||||
const { root, manager } = setup()
|
||||
const { manager } = setup()
|
||||
if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
|
||||
if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
|
||||
if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
|
||||
@@ -263,173 +190,101 @@ describe('desktop external plugin profile', () => {
|
||||
const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
|
||||
await expect(relaunched.applyRelease()).resolves.toBeUndefined()
|
||||
expect(existsSync(manager.paths.profile)).toBe(true)
|
||||
expect(calls(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('installs plugins with pnpm script settings', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '@scope/[email protected]' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }])
|
||||
expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
|
||||
['add', '--', '@scope/[email protected]'],
|
||||
])
|
||||
expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
expect(calls(root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retains pnpm-installed host package copies instead of rejecting their dependency placement', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/[email protected]' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }])
|
||||
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
||||
await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks())
|
||||
})
|
||||
|
||||
it('retains disabled plugin versions through updates and enables them explicitly', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
await manager.disableAllPlugins()
|
||||
expect(calls(root)).toHaveLength(1)
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
||||
await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }])
|
||||
await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks())
|
||||
expect(manager.listPlugins()[0]?.enabled).toBe(true)
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps plugin files and patches through a compatible release and application relocation', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
|
||||
const nextRoot = join(root, 'relocated', 'dsh')
|
||||
runtimeFixture(nextRoot, '1.1.0')
|
||||
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
|
||||
await expect(next.applyRelease()).resolves.toBeUndefined()
|
||||
expect(next.listPlugins()).toEqual(manager.listPlugins())
|
||||
expect(next.dshVersion()).toBe('1.1.0')
|
||||
expect(plugins(next)).toEqual(plugins(manager))
|
||||
expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
|
||||
expect(calls(root)).toHaveLength(1)
|
||||
expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
|
||||
})
|
||||
|
||||
it('preserves plugin files without running pnpm when bundled Node changes', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
const dsh = join(root, 'new-node')
|
||||
runtimeFixture(dsh, '1.1.0', '24.18.0')
|
||||
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
|
||||
await next.applyRelease()
|
||||
expect(calls(root)).toHaveLength(1)
|
||||
expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
||||
expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
||||
})
|
||||
|
||||
it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
seedPlugin(manager)
|
||||
const dsh = join(root, 'next-major')
|
||||
runtimeFixture(dsh, '2.0.0')
|
||||
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
|
||||
await expect(next.applyRelease()).resolves.toBeUndefined()
|
||||
expect(next.dshVersion()).toBe('2.0.0')
|
||||
await next.disableAllPlugins()
|
||||
expect(next.dshVersion()).toBe('2.0.0')
|
||||
expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
||||
expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
|
||||
describe.each(['applyRelease', 'disableAllPlugins'] as const)('desktop profile lock during %s', (operation) => {
|
||||
it('preserves a live owner lock and leaves the profile untouched', async () => {
|
||||
const { manager } = setup()
|
||||
await manager.applyRelease()
|
||||
let starts = 0
|
||||
await expect(manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks({
|
||||
beforeChange: async () => {
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
if (phase === 'before') throw new Error('before failed')
|
||||
},
|
||||
afterChange: async () => { starts++; throw new Error('after failed') },
|
||||
}))).rejects.toThrow(`${phase} failed`)
|
||||
expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
|
||||
expect(starts).toBe(phase === 'before' ? 0 : 1)
|
||||
mkdirSync(manager.paths.profile, { recursive: true })
|
||||
const owner = `${String(process.pid)}\n`
|
||||
writeFileSync(manager.paths.lock, owner)
|
||||
await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
|
||||
expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
|
||||
expect(readdirSync(manager.paths.profile)).toEqual(['lock'])
|
||||
})
|
||||
|
||||
it('keeps partial package changes available for recovery after pnpm fails', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
const failingPnpm = join(root, 'failing.mjs')
|
||||
writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`)
|
||||
const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
|
||||
await worker.applyRelease()
|
||||
let starts = 0
|
||||
await expect(worker.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks({
|
||||
afterChange: async () => { starts++ },
|
||||
}))).rejects.toThrow(/pnpm exited with 1/u)
|
||||
expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
||||
expect(starts).toBe(1)
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
|
||||
await manager.applyRelease()
|
||||
await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } }))
|
||||
expect(starts).toBe(2)
|
||||
await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
||||
expect(manager.listPlugins()).toEqual([])
|
||||
it('reclaims a stale owner lock and releases it after the operation', async () => {
|
||||
const { manager } = setup()
|
||||
mkdirSync(manager.paths.profile, { recursive: true })
|
||||
writeFileSync(manager.paths.lock, `${String(process.pid)}\n`)
|
||||
const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('process absent'), { code: 'ESRCH' })
|
||||
})
|
||||
try {
|
||||
await expect(manager[operation]()).resolves.toBeUndefined()
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith(process.pid, 0)
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
} finally {
|
||||
probe.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['invalid', '0', '-1', '9007199254740992'])('preserves a lock with invalid owner %s', async (owner) => {
|
||||
const { manager } = setup()
|
||||
mkdirSync(manager.paths.profile, { recursive: true })
|
||||
writeFileSync(manager.paths.lock, owner)
|
||||
await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
|
||||
expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
|
||||
})
|
||||
|
||||
it('retains both package and restart errors when the changed profile cannot start', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
const failingPnpm = join(root, 'failing-restart.mjs')
|
||||
writeFileSync(failingPnpm, 'process.exitCode = 1')
|
||||
const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
|
||||
await worker.applyRelease()
|
||||
const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') })
|
||||
const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors.map((error: Error) => error.message))
|
||||
.toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed'])
|
||||
expect(afterChange).toHaveBeenCalledOnce()
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
it('rejects a directory at the lock path', async () => {
|
||||
const { manager } = setup()
|
||||
mkdirSync(manager.paths.lock, { recursive: true })
|
||||
await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
|
||||
expect(lstatSync(manager.paths.lock).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
|
||||
it('rejects a linked lock without touching its target', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
const ready = join(root, 'ready')
|
||||
const release = join(root, 'release')
|
||||
const blocker = join(root, 'blocking.mjs')
|
||||
writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`)
|
||||
const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker })
|
||||
await worker.applyRelease()
|
||||
const pending = worker.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
|
||||
// Teardown observes failures even if the runner has abandoned the test body.
|
||||
const completed = pending.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
releaseWorkers.push(async () => {
|
||||
writeFileSync(release, 'continue')
|
||||
const outcome = await completed
|
||||
if ('error' in outcome) throw outcome.error
|
||||
})
|
||||
mkdirSync(manager.paths.profile, { recursive: true })
|
||||
const target = join(root, 'lock-target')
|
||||
mkdirSync(target)
|
||||
writeFileSync(join(target, 'sentinel'), 'retain')
|
||||
symlinkSync(target, manager.paths.lock, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
try {
|
||||
// Child startup shares the test budget; an aborted poll must not resume ownership assertions.
|
||||
await expect.poll(() => {
|
||||
signal.throwIfAborted()
|
||||
return existsSync(ready)
|
||||
}, { timeout: task.timeout }).toBe(true)
|
||||
signal.throwIfAborted()
|
||||
expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
|
||||
await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
|
||||
await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
|
||||
expect(lstatSync(manager.paths.lock).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('retain')
|
||||
} finally {
|
||||
writeFileSync(release, 'continue')
|
||||
await pending
|
||||
unlinkSync(manager.paths.lock)
|
||||
}
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppUpdater } from 'electron-updater'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import { parseDesktopRelease } from '../src/release.ts'
|
||||
import type { DesktopUpdateState } from '../src/ipc.ts'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
vi.mock('electron-updater', () => ({
|
||||
@@ -43,7 +42,6 @@ describe('desktop release metadata', () => {
|
||||
|
||||
describe('desktop update coordinator', () => {
|
||||
it('installs one Electron release and restarts after download', async () => {
|
||||
const states: DesktopUpdateState[] = []
|
||||
const downloadUpdate = vi.fn(async () => [])
|
||||
const quitAndInstall = vi.fn()
|
||||
const beforeRestart = vi.fn(async () => {})
|
||||
@@ -58,10 +56,6 @@ describe('desktop update coordinator', () => {
|
||||
quitAndInstall,
|
||||
} as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(
|
||||
(state) => {
|
||||
states.push(state)
|
||||
return state
|
||||
},
|
||||
beforeRestart,
|
||||
updater,
|
||||
() => true,
|
||||
@@ -72,7 +66,6 @@ describe('desktop update coordinator', () => {
|
||||
expect(downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(beforeRestart).toHaveBeenCalledOnce()
|
||||
expect(quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready'])
|
||||
})
|
||||
|
||||
it('queues install behind an in-flight check instead of returning the check result', async () => {
|
||||
@@ -88,7 +81,7 @@ describe('desktop update coordinator', () => {
|
||||
downloadUpdate,
|
||||
quitAndInstall: vi.fn(),
|
||||
} as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true)
|
||||
const coordinator = new DesktopUpdateCoordinator(async () => {}, updater, () => true)
|
||||
|
||||
const checking = coordinator.check()
|
||||
const installing = coordinator.install()
|
||||
|
||||
@@ -12,9 +12,9 @@ export default defineConfig([
|
||||
clean: false,
|
||||
deps: { neverBundle: ['electron'] },
|
||||
},
|
||||
...(['preload', 'preload-app'] as const).map(name => ({
|
||||
{
|
||||
// Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
|
||||
entry: { [name]: `lib/types/${name}.js` },
|
||||
entry: { 'preload-app': 'lib/types/preload-app.js' },
|
||||
outDir: 'lib',
|
||||
format: ['cjs'] as const,
|
||||
platform: 'node' as const,
|
||||
@@ -23,5 +23,5 @@ export default defineConfig([
|
||||
dts: false,
|
||||
clean: false,
|
||||
deps: { neverBundle: ['electron'] },
|
||||
})),
|
||||
},
|
||||
])
|
||||
@@ -50,6 +50,7 @@ const DONE = 'LINK_GALLERY_DONE'
|
||||
const GALLERY_TIME = Date.UTC(2026, 0, 15, 12)
|
||||
|
||||
const GUIDE_URL = 'https://docs.example.test/guide'
|
||||
const HTTP_URL = 'http://docs.example.test/plain'
|
||||
const API_URL = 'https://docs.example.test/api'
|
||||
const RELEASES_URL = 'https://docs.example.test/releases'
|
||||
const MAILTO_URL = 'mailto:[email protected]'
|
||||
@@ -260,6 +261,8 @@ function galleryFixture(imageUrl: string): string {
|
||||
`Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
|
||||
+ `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
|
||||
'',
|
||||
`Preview: [plain HTTP](${HTTP_URL}).`,
|
||||
'',
|
||||
`Upstream: [the repository](${REPO_URL}).`,
|
||||
'',
|
||||
`Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
|
||||
@@ -315,6 +318,10 @@ describe('web e2e: clickable links gallery', () => {
|
||||
await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<h1>${new URL(route.request().url()).pathname}</h1>`,
|
||||
}))
|
||||
await page.clock.setFixedTime(GALLERY_TIME + 60_000)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -342,6 +349,7 @@ describe('web e2e: clickable links gallery', () => {
|
||||
const markdown = page.locator('[class*="markdown"]')
|
||||
await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1)
|
||||
expect(await markdown.locator(`a[href="${HTTP_URL}"]`).count()).toBe(1)
|
||||
expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1)
|
||||
const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`)
|
||||
expect(await inlineCodeLink.count()).toBe(1)
|
||||
@@ -439,5 +447,12 @@ describe('web e2e: clickable links gallery', () => {
|
||||
expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
|
||||
// The excluded grey affordance: tool-row file links keep their own color.
|
||||
expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
|
||||
|
||||
// Ordinary message HTTP(S) links delegate to the right Sidebar Browser.
|
||||
await guideLink.click()
|
||||
const browserAddress = page.locator('[data-rightbar-col]').getByRole('textbox', { name: 'Enter an HTTP(S) address' })
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL)
|
||||
await markdown.locator(`a[href="${HTTP_URL}"]`).click()
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
/** Manual built-Web diagnostic for offscreen diagrams and visible theme refreshes; no timing budgets. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { arch, cpus, platform, release } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
import type { WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, REPO_ROOT } from './support.ts'
|
||||
|
||||
const SAMPLE_COUNT = 3
|
||||
const DIAGRAM_COUNT = 20
|
||||
const TAIL_PARAGRAPHS = 40
|
||||
const OBSERVATION_MS = 3_000
|
||||
const SESSION_ID = 'diagram-preview-performance'
|
||||
const TITLE = 'DIAGRAM_PREVIEW_PERF'
|
||||
const TAIL = 'DIAGRAM_PREVIEW_TAIL'
|
||||
const PREVIEW = '[data-code-block-preview]'
|
||||
const PROBE_KEY = '__dshDiagramPreviewPerf'
|
||||
|
||||
interface LongTask {
|
||||
readonly startTime: number
|
||||
readonly duration: number
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
readonly elapsedMs: number
|
||||
readonly height: number
|
||||
readonly placeholder: boolean
|
||||
}
|
||||
|
||||
interface Probe {
|
||||
start?: number
|
||||
opportunity?: number
|
||||
oldSrc?: string
|
||||
trustedClick?: boolean
|
||||
frames: Frame[]
|
||||
longTasks: LongTask[]
|
||||
observer: PerformanceObserver
|
||||
raf: number
|
||||
}
|
||||
|
||||
function fixture(): string {
|
||||
const session = Session.create(SessionId(SESSION_ID))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Inspect this synthetic diagram history.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const diagrams = Array.from({ length: DIAGRAM_COUNT }, (_, index) => [
|
||||
`Diagram ${index + 1}`,
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
` A[Request ${index + 1}] --> B[Read history]`,
|
||||
' B --> C{Cached}',
|
||||
' C --> D[Load source]',
|
||||
' C --> E[Reuse source]',
|
||||
' D --> F[Parse]',
|
||||
' E --> F',
|
||||
' F --> G[Layout]',
|
||||
' G --> H[Draw]',
|
||||
' H --> I{Loaded}',
|
||||
' I --> J[Display]',
|
||||
' I --> D',
|
||||
' E --> G',
|
||||
'```',
|
||||
].join('\n'))
|
||||
const paragraphs = Array.from({ length: TAIL_PARAGRAPHS }, (_, index) =>
|
||||
`History note ${index + 1}. This ordinary paragraph keeps the diagrams above the initial reading position. `
|
||||
+ 'The reader can inspect the completed discussion without opening a chart.')
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: [...diagrams, ...paragraphs, TAIL].join('\n\n') }],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: { inputTokens: 20, outputTokens: 2_000 },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return [JSON.stringify({
|
||||
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
|
||||
createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
|
||||
}), ...session.snapshotEvents().map(event => JSON.stringify(event)), ''].join('\n')
|
||||
}
|
||||
|
||||
async function installProbe(phase: 'open' | 'theme', target: Locator): Promise<void> {
|
||||
await target.evaluate((node, options) => {
|
||||
const longTasks: LongTask[] = []
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration })
|
||||
})
|
||||
observer.observe({ type: 'longtask' })
|
||||
const probe: Probe = { frames: [], longTasks, observer, raf: 0 }
|
||||
Reflect.set(globalThis, options.key, probe)
|
||||
const visible = (element: Element): boolean => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scroll = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden'
|
||||
&& rect.bottom > (scroll?.top ?? 0) && rect.top < (scroll?.bottom ?? innerHeight)
|
||||
}
|
||||
const frame = (): void => {
|
||||
if (probe.start === undefined) return
|
||||
let ready = false
|
||||
if (options.phase === 'open') {
|
||||
const tail = Array.from(document.querySelectorAll('[data-conversation-scroll] p'))
|
||||
.find(element => element.textContent === options.tail)
|
||||
ready = tail !== undefined && visible(tail)
|
||||
} else {
|
||||
const preview = document.querySelectorAll(options.preview)[options.diagramCount - 1]
|
||||
if (preview === undefined) throw new Error('theme probe has no last diagram')
|
||||
probe.frames.push({
|
||||
elapsedMs: performance.now() - probe.start,
|
||||
height: preview.getBoundingClientRect().height,
|
||||
placeholder: Array.from(preview.querySelectorAll('[data-preview-placeholder]')).some(visible),
|
||||
})
|
||||
ready = Array.from(preview.querySelectorAll('img')).some(image =>
|
||||
image.src !== probe.oldSrc && image.complete && image.naturalWidth > 0 && visible(image))
|
||||
}
|
||||
if (ready && probe.opportunity === undefined) {
|
||||
// The second callback is a rendering opportunity, not hardware presentation.
|
||||
probe.opportunity = -1
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { probe.opportunity = performance.now() })
|
||||
})
|
||||
}
|
||||
probe.raf = requestAnimationFrame(frame)
|
||||
}
|
||||
if (options.phase === 'open') {
|
||||
node.addEventListener('click', (event) => {
|
||||
probe.trustedClick = event.isTrusted
|
||||
probe.start = performance.now()
|
||||
probe.raf = requestAnimationFrame(frame)
|
||||
}, { capture: true, once: true })
|
||||
} else {
|
||||
const image = node.querySelector('img')
|
||||
if (image === null) throw new Error('theme probe has no loaded image')
|
||||
probe.oldSrc = image.src
|
||||
probe.start = performance.now()
|
||||
probe.raf = requestAnimationFrame(frame)
|
||||
}
|
||||
}, { phase, key: PROBE_KEY, preview: PREVIEW, diagramCount: DIAGRAM_COUNT, tail: TAIL })
|
||||
}
|
||||
|
||||
async function collectProbe(page: Page) {
|
||||
try {
|
||||
await page.waitForFunction(({ key, windowMs }) => {
|
||||
const probe = Reflect.get(globalThis, key) as Probe
|
||||
return probe.start !== undefined && (probe.opportunity ?? -1) > 0
|
||||
&& performance.now() - probe.start >= windowMs
|
||||
}, { key: PROBE_KEY, windowMs: OBSERVATION_MS }, { timeout: 30_000 })
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(({ key, previewSelector }) => {
|
||||
const probe = Reflect.get(globalThis, key) as Probe
|
||||
const preview = Array.from(document.querySelectorAll(previewSelector)).at(-1)
|
||||
const bounds = (element: Element | null | undefined) => {
|
||||
const rect = element?.getBoundingClientRect()
|
||||
return rect === undefined ? null : { top: rect.top, bottom: rect.bottom, height: rect.height }
|
||||
}
|
||||
return {
|
||||
opportunity: probe.opportunity,
|
||||
frames: probe.frames.slice(-5),
|
||||
theme: getComputedStyle(document.documentElement).colorScheme,
|
||||
scroll: bounds(document.querySelector('[data-conversation-scroll]')),
|
||||
preview: bounds(preview),
|
||||
images: Array.from(preview?.querySelectorAll('img') ?? []).map(image => ({
|
||||
sameSource: image.src === probe.oldSrc, complete: image.complete, naturalWidth: image.naturalWidth,
|
||||
hidden: image.hidden, rect: bounds(image),
|
||||
})),
|
||||
}
|
||||
}, { key: PROBE_KEY, previewSelector: PREVIEW })
|
||||
throw new Error(`diagram probe endpoint unavailable: ${JSON.stringify(diagnostic)}`, { cause: error })
|
||||
}
|
||||
return await page.evaluate(({ key, windowMs, previewSelector }) => {
|
||||
const probe = Reflect.get(globalThis, key) as Probe
|
||||
probe.observer.disconnect()
|
||||
cancelAnimationFrame(probe.raf)
|
||||
Reflect.deleteProperty(globalThis, key)
|
||||
if (probe.start === undefined || probe.opportunity === undefined) throw new Error('incomplete diagram probe')
|
||||
const start = probe.start
|
||||
const stop = start + windowMs
|
||||
const tasks = probe.longTasks.filter(task => task.startTime < stop && task.startTime + task.duration > start)
|
||||
const scripts = performance.getEntriesByType('resource').filter((entry): entry is PerformanceResourceTiming =>
|
||||
entry instanceof PerformanceResourceTiming && new URL(entry.name).pathname.endsWith('.js')
|
||||
&& entry.startTime >= start && entry.startTime < stop)
|
||||
.map(entry => ({
|
||||
path: new URL(entry.name).pathname,
|
||||
transferBytes: entry.transferSize,
|
||||
encodedBytes: entry.encodedBodySize,
|
||||
decodedBytes: entry.decodedBodySize,
|
||||
durationMs: entry.duration,
|
||||
}))
|
||||
const previews = Array.from(document.querySelectorAll(previewSelector))
|
||||
const scrollRect = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
|
||||
const intersectingPreviews = previews.filter((preview) => {
|
||||
const rect = preview.getBoundingClientRect()
|
||||
return rect.bottom > (scrollRect?.top ?? 0) && rect.top < (scrollRect?.bottom ?? innerHeight)
|
||||
}).length
|
||||
const frames = probe.frames.filter(frame => frame.elapsedMs < windowMs)
|
||||
return {
|
||||
opportunityMs: probe.opportunity - start,
|
||||
observedMs: performance.now() - start,
|
||||
trustedClick: probe.trustedClick,
|
||||
longTaskMs: tasks.reduce((sum, task) => sum + Math.max(0,
|
||||
Math.min(stop, task.startTime + task.duration) - Math.max(start, task.startTime)), 0),
|
||||
longTasks: tasks.map(task => ({ startMs: task.startTime - start, durationMs: task.duration })),
|
||||
loadedDiagramImages: previews.flatMap(preview => Array.from(preview.querySelectorAll('img')))
|
||||
.filter(image => image.complete && image.naturalWidth > 0).length,
|
||||
previewCount: previews.length,
|
||||
intersectingPreviews,
|
||||
scriptTransferBytes: scripts.reduce((sum, script) => sum + script.transferBytes, 0),
|
||||
scriptEncodedBytes: scripts.reduce((sum, script) => sum + script.encodedBytes, 0),
|
||||
scripts,
|
||||
frames,
|
||||
heightMin: frames.length === 0 ? null : Math.min(...frames.map(frame => frame.height)),
|
||||
heightMax: frames.length === 0 ? null : Math.max(...frames.map(frame => frame.height)),
|
||||
placeholderFrames: frames.filter(frame => frame.placeholder).length,
|
||||
}
|
||||
}, { key: PROBE_KEY, windowMs: OBSERVATION_MS, previewSelector: PREVIEW })
|
||||
}
|
||||
|
||||
async function sample() {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
await seedSession(scaffold, fixture(), SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const group = page.getByRole('treeitem').first()
|
||||
await group.waitFor({ timeout: 30_000 })
|
||||
await group.click()
|
||||
const session = page.getByRole('treeitem').nth(1)
|
||||
await session.waitFor({ timeout: 30_000 })
|
||||
const bootScriptBytes = await page.evaluate(() => performance.getEntriesByType('resource')
|
||||
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming
|
||||
&& new URL(entry.name).pathname.endsWith('.js'))
|
||||
.reduce((sum, entry) => sum + entry.transferSize, 0))
|
||||
await installProbe('open', session)
|
||||
await session.click()
|
||||
const open = await collectProbe(page)
|
||||
expect(open.trustedClick).toBe(true)
|
||||
expect(open.previewCount).toBe(DIAGRAM_COUNT)
|
||||
expect(open.intersectingPreviews).toBe(0)
|
||||
const last = page.locator(PREVIEW).last()
|
||||
await last.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => last.locator('img').evaluateAll(images => images.some((node) => {
|
||||
const image = node as HTMLImageElement
|
||||
return image.complete && image.naturalWidth > 0 && image.getBoundingClientRect().height > 0
|
||||
}))).toBe(true)
|
||||
// Earlier cold previews may grow after the placeholder was scrolled into view.
|
||||
await last.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => last.evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const scroll = document.querySelector('[data-conversation-scroll]')!.getBoundingClientRect()
|
||||
return rect.bottom > scroll.top && rect.top < scroll.bottom
|
||||
}), { timeout: 30_000 }).toBe(true)
|
||||
await installProbe('theme', last)
|
||||
await page.emulateMedia({ colorScheme: 'dark' })
|
||||
const theme = await collectProbe(page)
|
||||
expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
return { browser: browser.version(), bootScriptBytes, open, theme, consoleWarnings: tripwire.warnings }
|
||||
} finally {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!
|
||||
}
|
||||
|
||||
describe('manual web performance: diagram previews', () => {
|
||||
it('reports cold offscreen opening and a visible theme refresh', async () => {
|
||||
if (webSnapshotMode() !== 'replay') throw new Error('diagram performance requires keyless replay mode')
|
||||
const directory = join(REPO_ROOT, '.playwright-mcp/preview-perf', new Date().toISOString().replaceAll(':', '-'))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const samples: Awaited<ReturnType<typeof sample>>[] = []
|
||||
for (let index = 0; index < SAMPLE_COUNT; index++) {
|
||||
const result = await sample()
|
||||
samples.push(result)
|
||||
await writeFile(join(directory, `sample-${index + 1}.json`), `${JSON.stringify(result, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ sample: index + 1, openMs: result.open.opportunityMs, themeMs: result.theme.opportunityMs }))
|
||||
}
|
||||
const summary = {
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(),
|
||||
runtime: { node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model },
|
||||
workload: {
|
||||
diagrams: DIAGRAM_COUNT, nodesPerDiagram: 10, edgesPerDiagram: 12,
|
||||
tailParagraphs: TAIL_PARAGRAPHS, viewport: [1680, 1000], observationMs: OBSERVATION_MS,
|
||||
},
|
||||
semantics: 'Fresh Chromium and private shipped-composition scaffold per sample; built Client with source-resolved test Host. Opening starts at trusted session click; theme starts immediately before media emulation. Endpoints require visible output and two animation-frame opportunities. Long tasks are clipped to the fixed observation window. Samples include local transport, no model call. No memory or hardware-presentation measurement and no timing verdict.',
|
||||
medians: Object.fromEntries((['open', 'theme'] as const).map(phase => [phase, {
|
||||
opportunityMs: median(samples.map(sample => sample[phase].opportunityMs)),
|
||||
longTaskMs: median(samples.map(sample => sample[phase].longTaskMs)),
|
||||
scriptTransferBytes: median(samples.map(sample => sample[phase].scriptTransferBytes)),
|
||||
loadedDiagramImages: median(samples.map(sample => sample[phase].loadedDiagramImages)),
|
||||
placeholderFrames: median(samples.map(sample => sample[phase].placeholderFrames)),
|
||||
}])),
|
||||
samples,
|
||||
}
|
||||
await writeFile(join(directory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ directory, medians: summary.medians }))
|
||||
})
|
||||
})
|
||||
@@ -142,6 +142,11 @@
|
||||
- text: ", and check the fine print"
|
||||
- superscript: "1"
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Preview:"
|
||||
- link "plain HTTP":
|
||||
- /url: http://docs.example.test/plain
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Upstream:"
|
||||
- link "the repository":
|
||||
@@ -167,7 +172,9 @@
|
||||
- paragraph:
|
||||
- img "Token preview"
|
||||
- text: css
|
||||
- button "Line numbers"
|
||||
- button "Copy"
|
||||
- text: Source
|
||||
- code: "--inline-code: #F5F5F5;"
|
||||
- paragraph: LINK_GALLERY_DONE
|
||||
- heading "Footnotes" [level=2]
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
- tab "Host" [selected]
|
||||
- tabpanel "Host":
|
||||
- text: javascript
|
||||
- button "Line numbers"
|
||||
- button "Copy"
|
||||
- text: Source
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
|
||||
- button "Inspect"
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
- button "Inline code links" [disabled]
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- dialog "Mermaid diagram":
|
||||
- img "Mermaid diagram"
|
||||
- text: Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit
|
||||
- button "Close preview"
|
||||
@@ -0,0 +1,6 @@
|
||||
- text: mermaid
|
||||
- button "Enlarge preview" [disabled]
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- status: Generating diagram…
|
||||
@@ -0,0 +1,427 @@
|
||||
[
|
||||
{
|
||||
"language": "mermaid",
|
||||
"lines": [
|
||||
[
|
||||
{
|
||||
"text": "flowchart",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": " LR",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": " A",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "[",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": "输入",
|
||||
"style": "color: var(--shiki-token-string);"
|
||||
},
|
||||
{
|
||||
"text": "]",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": " --",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": "> B[共享渲染器] ",
|
||||
"style": "color: var(--shiki-token-string);"
|
||||
},
|
||||
{
|
||||
"text": "-->",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": " C",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "[",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": "图形预览",
|
||||
"style": "color: var(--shiki-token-string);"
|
||||
},
|
||||
{
|
||||
"text": "]",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "dot",
|
||||
"lines": [
|
||||
[
|
||||
{
|
||||
"text": "digraph",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": " { ",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "rankdir",
|
||||
"style": "color: var(--shiki-token-constant);"
|
||||
},
|
||||
{
|
||||
"text": "=LR; Input ",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "->",
|
||||
"style": "color: var(--shiki-token-keyword);"
|
||||
},
|
||||
{
|
||||
"text": " Preview }",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "svg",
|
||||
"lines": [
|
||||
[
|
||||
{
|
||||
"text": "<",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "svg",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " xmlns",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"http://www.w3.org/2000/svg\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " width",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"460\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " height",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"100\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " onload",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"parent.document.body.dataset.previewEscaped='yes'\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": "<",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "rect",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " width",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"460\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " height",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"100\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " fill",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"lightblue\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "/><",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "text",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " x",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"20\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " y",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"55\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">SVG preview</",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "text",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": "<",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "script",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "script",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": "<",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "image",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " href",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"https://preview.invalid/svg-image\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " width",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"1\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " height",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"1\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "/>",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": "<",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "foreignObject",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " width",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"1\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " height",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"1\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "><",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "div",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": " xmlns",
|
||||
"style": "color: var(--shiki-token-function);"
|
||||
},
|
||||
{
|
||||
"text": "=",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "\"http://www.w3.org/1999/xhtml\"",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "><",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "script",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">alert('unsafe HTML')</",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "script",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "></",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "div",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": "></",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "foreignObject",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"text": "</",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
},
|
||||
{
|
||||
"text": "svg",
|
||||
"style": "color: var(--shiki-token-string-expression);"
|
||||
},
|
||||
{
|
||||
"text": ">",
|
||||
"style": "color: var(--shiki-foreground);"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
- heading "Mermaid previews" [level=1]
|
||||
- text: mermaid
|
||||
- button "Enlarge preview"
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- img "Mermaid diagram"
|
||||
- text: mermaid
|
||||
- button "Enlarge preview"
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- img "Mermaid diagram"
|
||||
- text: mermaid
|
||||
- button "Enlarge preview" [disabled]
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- status: Unable to render this diagram. Select Source to inspect the code.
|
||||
- text: mermaid
|
||||
- button "Enlarge preview"
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- img "Mermaid diagram"
|
||||
- text: dot
|
||||
- button "Enlarge preview"
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- img "Graphviz diagram"
|
||||
- text: svg
|
||||
- button "Enlarge preview"
|
||||
- button "Copy"
|
||||
- button "Source"
|
||||
- button "Preview" [pressed]
|
||||
- img "SVG preview"
|
||||
- text: html
|
||||
- button "Line numbers"
|
||||
- button "Copy"
|
||||
- text: Source
|
||||
- code: <h2>Static HTML</h2>
|
||||
@@ -0,0 +1,4 @@
|
||||
- dialog "Mermaid 图表":
|
||||
- img "Mermaid 图表"
|
||||
- text: 拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕
|
||||
- button "关闭预览"
|
||||
@@ -0,0 +1,42 @@
|
||||
- heading "Mermaid previews" [level=1]
|
||||
- text: mermaid
|
||||
- button "放大预览"
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- img "Mermaid 图表"
|
||||
- text: mermaid
|
||||
- button "放大预览"
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- img "Mermaid 图表"
|
||||
- text: mermaid
|
||||
- button "放大预览" [disabled]
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- status: 无法渲染此图表,可切换到源码查看。
|
||||
- text: mermaid
|
||||
- button "放大预览"
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- img "Mermaid 图表"
|
||||
- text: dot
|
||||
- button "放大预览"
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- img "Graphviz 图表"
|
||||
- text: svg
|
||||
- button "放大预览"
|
||||
- button "复制"
|
||||
- button "源码"
|
||||
- button "预览" [pressed]
|
||||
- img "SVG 预览"
|
||||
- text: html
|
||||
- button "行号"
|
||||
- button "复制"
|
||||
- text: 源码
|
||||
- code: <h2>Static HTML</h2>
|
||||
@@ -216,6 +216,8 @@
|
||||
- text: .
|
||||
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
|
||||
- text: typescript
|
||||
- button "Line numbers"
|
||||
- button "Copy"
|
||||
- text: Source
|
||||
- code: const value = "reasoning code"
|
||||
- paragraph: {{longToken}}
|
||||
@@ -22,6 +22,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-inline-code-links
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-inline-code-links-web-e2e'
|
||||
const DONE = 'INLINE_CODE_LINK_DONE'
|
||||
const LINK_URL = 'http://127.0.0.1:3199/?demo=1'
|
||||
|
||||
/** Build a settled assistant reply with linkable URL code and inert code controls. */
|
||||
function markdownFixture(linkUrl: string): string {
|
||||
@@ -88,15 +89,17 @@ describe('web e2e: Markdown inline-code links', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let linkUrl: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString()
|
||||
await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID)
|
||||
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.route('http://127.0.0.1:3199/**', async route => route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<h1>Inline-code preview</h1>',
|
||||
}))
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -119,24 +122,22 @@ describe('web e2e: Markdown inline-code links', () => {
|
||||
|
||||
const inlineCodeLink = page.locator('[class*="markdown"] code a')
|
||||
await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl)
|
||||
expect(await inlineCodeLink.getAttribute('href')).toBe(LINK_URL)
|
||||
expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
|
||||
expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
await inlineCodeLink.focus()
|
||||
expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await inlineCodeLink.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForURL(linkUrl, { timeout: 15_000 })
|
||||
expect(popup.url()).toBe(linkUrl)
|
||||
await popup.close()
|
||||
const browserAddress = page.locator('[data-rightbar-col]')
|
||||
.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(LINK_URL)
|
||||
|
||||
expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0)
|
||||
expect(await page.getByText(`curl ${LINK_URL}`, { exact: true }).locator('a').count()).toBe(0)
|
||||
expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
.split(linkUrl).join('{{linkUrl}}')
|
||||
.split(LINK_URL).join('{{linkUrl}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-mermaid-web-e2e'
|
||||
const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)'
|
||||
const FLOW = 'flowchart LR\n A[输入] --> B[共享渲染器] --> C[图形预览]'
|
||||
const SEQUENCE = 'sequenceDiagram\n participant U as User\n participant R as Renderer\n U->>R: Mermaid source\n R-->>U: Diagram'
|
||||
const INVALID = 'flowchart LR\n A[unfinished'
|
||||
const UNTRUSTED = [
|
||||
'%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%',
|
||||
'flowchart LR',
|
||||
' A["<img src=x onerror=alert(1)>"] --> B[Safe]',
|
||||
' click B "javascript:alert(1)"',
|
||||
].join('\n')
|
||||
|
||||
const DOT = 'digraph { rankdir=LR; Input -> Preview }'
|
||||
const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100" onload="parent.document.body.dataset.previewEscaped='yes'">
|
||||
<rect width="460" height="100" fill="lightblue"/><text x="20" y="55">SVG preview</text>
|
||||
<script>parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</script>
|
||||
<image href="https://preview.invalid/svg-image" width="1" height="1"/>
|
||||
<foreignObject width="1" height="1"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert('unsafe HTML')</script></div></foreignObject>
|
||||
</svg>`
|
||||
const HTML = '<h2>Static HTML</h2>'
|
||||
|
||||
const CPU = `flowchart LR
|
||||
subgraph CPU["CPU 中央处理器"]
|
||||
direction TB
|
||||
CU["控制器 Control Unit"]
|
||||
ALU["运算器 ALU"]
|
||||
REG["寄存器 Registers"]
|
||||
end
|
||||
|
||||
MEM["存储器 Memory(指令 + 数据)"]
|
||||
IN["输入设备 Input"]
|
||||
OUT["输出设备 Output"]
|
||||
|
||||
IN -->|"数据 / 指令"| MEM
|
||||
MEM -->|"指令"| CU
|
||||
MEM -->|"数据"| REG
|
||||
REG -->|"数据"| MEM
|
||||
CU -->|"控制信号"| ALU
|
||||
CU -->|"控制信号"| MEM
|
||||
CU -->|"地址总线"| MEM
|
||||
ALU -->|"结果"| REG
|
||||
MEM -->|"数据"| OUT
|
||||
CU -->|"控制信号"| OUT`
|
||||
|
||||
function fixture(diagramSources?: string[], trailingText = ''): string {
|
||||
const session = Session.create(SessionId('markdown-mermaid-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
stream: [], turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: diagramSources === undefined ? [
|
||||
'# Mermaid previews',
|
||||
...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
|
||||
...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``),
|
||||
].join('\n\n') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``), trailingText].join('\n\n') }],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const noon = new Date().setHours(12, 0, 0, 0)
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
|
||||
cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function openConversation(page: Page, scaffold: WebScaffold): Promise<void> {
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('treeitem').first().click({ timeout: 30_000 })
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor()
|
||||
}
|
||||
|
||||
async function settleBox(locator: Locator): Promise<{ width: number; height: number }> {
|
||||
return await locator.evaluate(async (node) => {
|
||||
let previous = { width: -1, height: -1 }
|
||||
let stableFrames = 0
|
||||
for (let frame = 0; frame < 180; frame += 1) {
|
||||
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
|
||||
const { width, height } = node.getBoundingClientRect()
|
||||
const current = { width, height }
|
||||
stableFrames = Math.abs(width - previous.width) < 0.25 && Math.abs(height - previous.height) < 0.25
|
||||
? stableFrames + 1
|
||||
: 0
|
||||
if (stableFrames === 5) return current
|
||||
previous = current
|
||||
}
|
||||
throw new Error('layout did not settle within 180 animation frames')
|
||||
})
|
||||
}
|
||||
|
||||
async function showAllPreviews(page: Page, label = 'Preview'): Promise<void> {
|
||||
const blocks = page.locator('.md-code-block')
|
||||
for (let index = 0; index < await blocks.count(); index += 1) {
|
||||
const button = blocks.nth(index).getByRole('button', { name: label, exact: true })
|
||||
if (await button.count() !== 1) continue
|
||||
await button.click()
|
||||
await expect.poll(() => blocks.nth(index).locator('[data-preview-placeholder]').count()).toBe(0)
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: Mermaid chat previews', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
}, 120_000)
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps preview and source at the same height across viewport widths', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
let cpuScaffold: WebScaffold | undefined
|
||||
try {
|
||||
cpuScaffold = await launchWebScaffold({})
|
||||
await seedSession(cpuScaffold, fixture([CPU]), SEED_ID)
|
||||
await openConversation(page, cpuScaffold)
|
||||
const block = page.locator('.md-code-block')
|
||||
await block.scrollIntoViewIfNeeded()
|
||||
const image = block.getByRole('img', { name: 'Mermaid diagram', exact: true })
|
||||
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
expect(await block.locator('pre').count()).toBe(0)
|
||||
const heights: number[] = []
|
||||
for (const width of [1680, 360]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await settleBox(page.locator('[data-chat-flow]'))
|
||||
await image.waitFor({ state: 'visible' })
|
||||
const previewBlock = await settleBox(block)
|
||||
const previewGeometry = await block.evaluate((node) => {
|
||||
const body = node.querySelector<HTMLElement>('[data-code-block-content]')!
|
||||
const source = node.querySelector<HTMLElement>('[data-code-block-source-view]')!
|
||||
const preview = node.querySelector<HTMLElement>('[data-code-block-preview]')!
|
||||
return {
|
||||
block: node.getBoundingClientRect().height,
|
||||
body: body.getBoundingClientRect().height,
|
||||
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
|
||||
preview: preview.getBoundingClientRect().height,
|
||||
sourceVisibility: getComputedStyle(source).visibility,
|
||||
sourceAriaHidden: source.getAttribute('aria-hidden'),
|
||||
}
|
||||
})
|
||||
expect(previewGeometry.sourceVisibility).toBe('hidden')
|
||||
expect(previewGeometry.sourceAriaHidden).toBe('true')
|
||||
expect(previewGeometry.preview).toBeCloseTo(previewGeometry.source, 0)
|
||||
expect(previewGeometry.body).toBeCloseTo(previewGeometry.source, 0)
|
||||
expect(await block.locator('pre:visible').count()).toBe(0)
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
await block.locator('pre:visible').waitFor()
|
||||
const sourceBlock = await settleBox(block)
|
||||
expect(sourceBlock.height).toBeCloseTo(previewBlock.height, 0)
|
||||
expect(sourceBlock.width).toBeCloseTo(previewBlock.width, 0)
|
||||
await block.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
await image.waitFor({ state: 'visible' })
|
||||
const restoredBlock = await settleBox(block)
|
||||
expect(restoredBlock.height).toBeCloseTo(previewBlock.height, 0)
|
||||
expect(restoredBlock.width).toBeCloseTo(previewBlock.width, 0)
|
||||
heights.push(previewGeometry.source)
|
||||
}
|
||||
expect(heights.every(height => height > 0 && height <= 632)).toBe(true)
|
||||
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
|
||||
expect(await block.getByRole('button', { name: 'Reset size', exact: true }).count()).toBe(0)
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
expect(await block.locator('pre code').textContent()).toBe(CPU)
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'web-e2e-cpu-preview')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
await cpuScaffold?.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('retains both equal-height views and keeps toolbar geometry stable', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await openConversation(page, scaffold)
|
||||
const block = page.locator('.md-code-block').nth(5)
|
||||
await block.scrollIntoViewIfNeeded()
|
||||
const image = block.getByRole('img', { name: 'SVG preview', exact: true })
|
||||
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
const imgNode = await image.elementHandle()
|
||||
const sourceView = block.locator('[data-code-block-source-view]')
|
||||
const previewLayer = block.locator('[data-code-block-preview]')
|
||||
expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(await block.locator('pre').count()).toBe(0)
|
||||
const previewHeight = (await previewLayer.boundingBox())!.height
|
||||
expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
|
||||
const sourceButton = block.getByRole('button', { name: 'Source', exact: true })
|
||||
await sourceButton.click()
|
||||
await block.locator('pre.shiki:visible').waitFor()
|
||||
const source = await block.locator('pre.shiki').elementHandle()
|
||||
expect(await block.locator('pre code').textContent()).toBe(SVG)
|
||||
expect(await block.getByRole('button', { name: 'Enlarge preview', exact: true }).count()).toBe(0)
|
||||
expect(await sourceView.getAttribute('aria-hidden')).toBeNull()
|
||||
const numbers = block.getByRole('button', { name: 'Line numbers', exact: true })
|
||||
await numbers.click()
|
||||
expect(await numbers.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(await block.getAttribute('data-line-numbers')).toBe('true')
|
||||
expect(await source!.evaluate(node => node.isConnected)).toBe(true)
|
||||
const copy = block.getByRole('button', { name: 'Copy', exact: true })
|
||||
const selectionGeometry = () => sourceButton.evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { width: rect.width, height: rect.height, right: node.closest('.md-code-block')!.getBoundingClientRect().right - rect.right }
|
||||
})
|
||||
const toolbarBefore = await selectionGeometry()
|
||||
await copy.click()
|
||||
await block.getByRole('button', { name: 'Copied', exact: true }).waitFor()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(SVG)
|
||||
expect(await selectionGeometry()).toEqual(toolbarBefore)
|
||||
const sourceHeight = (await block.locator('[data-code-block-content]').boundingBox())!.height
|
||||
expect(sourceHeight).toBeCloseTo(previewHeight, 0)
|
||||
await block.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
expect(await source!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
|
||||
expect((await previewLayer.boundingBox())!.height).toBeCloseTo(sourceHeight, 0)
|
||||
expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await image.waitFor({ state: 'visible' })
|
||||
expect((await block.boundingBox())!.width).toBeLessThanOrEqual(360)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('fades only preview toolbar opacity and reveals it for hover or keyboard focus', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
await openConversation(page, scaffold)
|
||||
const block = page.locator('.md-code-block').first()
|
||||
await block.scrollIntoViewIfNeeded()
|
||||
await block.getByRole('img', { name: 'Mermaid diagram', exact: true }).evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
const toolbar = block.locator('[data-code-block-banner]').locator('..')
|
||||
await block.hover()
|
||||
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
|
||||
const shown = await block.boundingBox()
|
||||
const toolbarBox = await toolbar.boundingBox()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('0')
|
||||
expect(await block.boundingBox()).toEqual(shown)
|
||||
expect(await toolbar.boundingBox()).toEqual(toolbarBox)
|
||||
expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0.16s')
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
await page.getByRole('heading', { name: 'Mermaid previews' }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
|
||||
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0s')
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('highlights diagram source with theme tokens', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
await openConversation(page, scaffold)
|
||||
const trees = []
|
||||
for (const [index, language, source] of [[0, 'mermaid', FLOW], [4, 'dot', DOT], [5, 'svg', SVG]] as const) {
|
||||
const block = page.locator('.md-code-block').nth(index)
|
||||
await block.scrollIntoViewIfNeeded()
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
await block.locator('pre.shiki').waitFor()
|
||||
expect(await block.locator('pre code').textContent()).toBe(source)
|
||||
const lines = await block.locator('pre.shiki .line').evaluateAll(nodes => nodes.map(line =>
|
||||
[...line.querySelectorAll('span')].map(span => ({ text: span.textContent, style: span.style.cssText })),
|
||||
))
|
||||
expect(new Set(lines.flat().map(span => span.style)).size).toBeGreaterThan(1)
|
||||
trees.push({ language, lines })
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'source-highlight.expected.md'), JSON.stringify(trees, null, 2), MODE)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
|
||||
'fits diagram previews to image dimensions within viewport limits in %s mode', async (scheme) => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
await page.emulateMedia({ colorScheme: scheme })
|
||||
await openConversation(page, scaffold)
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme)
|
||||
await showAllPreviews(page)
|
||||
|
||||
|
||||
for (const title of ['Mermaid diagram', 'Graphviz diagram', 'SVG preview']) {
|
||||
const image = page.getByRole('img', { name: title, exact: true }).first()
|
||||
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
const geometry = await image.evaluate((node) => {
|
||||
const block = node.closest('.md-code-block')!
|
||||
const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
|
||||
const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
|
||||
return {
|
||||
image: node.getBoundingClientRect().height,
|
||||
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
|
||||
preview: preview.getBoundingClientRect().height,
|
||||
client: preview.clientHeight,
|
||||
scroll: preview.scrollHeight,
|
||||
}
|
||||
})
|
||||
expect(geometry.image).toBeGreaterThan(0)
|
||||
expect(geometry.preview).toBeCloseTo(geometry.source, 0)
|
||||
expect(geometry.scroll).toBeGreaterThanOrEqual(geometry.client)
|
||||
expect(geometry.image).toBeLessThanOrEqual(600)
|
||||
expect(geometry.preview).toBeCloseTo(Math.max(120, geometry.image + 32), 0)
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
const svg = page.getByRole('img', { name: 'SVG preview', exact: true })
|
||||
await expect.poll(() => svg.evaluate((node) => {
|
||||
const { width, height } = node.getBoundingClientRect()
|
||||
return width > 0 && height > 0 && width < 460
|
||||
})).toBe(true)
|
||||
const compact = await svg.evaluate((node: HTMLImageElement) => ({
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
sourceHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-content]')!.getBoundingClientRect().height,
|
||||
previewHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-preview]')!.getBoundingClientRect().height,
|
||||
ratio: node.naturalWidth / node.naturalHeight,
|
||||
}))
|
||||
expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
|
||||
expect(compact.previewHeight).toBeCloseTo(compact.sourceHeight, 0)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
await openConversation(page, scaffold)
|
||||
await page.locator('.md-code-block').first().getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
const image = page.getByRole('img', { name: 'Mermaid diagram' }).first()
|
||||
await image.waitFor()
|
||||
let previous = await image.getAttribute('src')
|
||||
for (const scheme of ['dark', 'light'] as const) {
|
||||
await page.evaluate((value) => {
|
||||
document.documentElement.style.colorScheme = value
|
||||
document.body.toggleAttribute('data-ds-dark-theme', value === 'dark')
|
||||
}, scheme)
|
||||
await expect.poll(() => image.getAttribute('src')).not.toBe(previous)
|
||||
await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
|
||||
const colors = await image.evaluate((node) => {
|
||||
const reference = document.createElement('span')
|
||||
reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)'
|
||||
document.body.append(reference)
|
||||
try {
|
||||
return {
|
||||
canvas: getComputedStyle(node.parentElement!).backgroundColor,
|
||||
expected: getComputedStyle(reference).backgroundColor,
|
||||
}
|
||||
} finally {
|
||||
reference.remove()
|
||||
}
|
||||
})
|
||||
expect(colors.canvas).toBe(colors.expected)
|
||||
previous = await image.getAttribute('src')
|
||||
}
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid'))
|
||||
const tripwire = watchConsole(page)
|
||||
const dialogs: string[] = []
|
||||
page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await openConversation(page, scaffold)
|
||||
const first = page.locator('.md-code-block').first()
|
||||
const invalid = page.locator('.md-code-block').nth(2)
|
||||
await invalid.scrollIntoViewIfNeeded()
|
||||
await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor()
|
||||
await invalid.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0)
|
||||
await invalid.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
expect(await first.locator('pre').count()).toBe(0)
|
||||
expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
|
||||
expect(await first.getByText('mermaid', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(6)
|
||||
const html = page.locator('.md-code-block').nth(6)
|
||||
expect(await html.locator('pre code').textContent()).toBe(HTML)
|
||||
expect(await html.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(0)
|
||||
await first.scrollIntoViewIfNeeded()
|
||||
const image = first.getByRole('img', { name: 'Mermaid diagram', exact: true })
|
||||
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
const diagram = await image.elementHandle()
|
||||
const layout = await first.evaluate((block) => {
|
||||
const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
|
||||
const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
|
||||
return {
|
||||
block: block.getBoundingClientRect().height,
|
||||
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
|
||||
preview: preview.getBoundingClientRect().height,
|
||||
width: block.getBoundingClientRect().width,
|
||||
}
|
||||
})
|
||||
expect(layout.preview).toBeCloseTo(layout.source, 0)
|
||||
await first.getByRole('button', { name: 'Enlarge preview', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Mermaid diagram', exact: true })
|
||||
const enlarged = dialog.getByRole('img', { name: 'Mermaid diagram', exact: true })
|
||||
const enlargedGeometry = await enlarged.evaluate((node: HTMLImageElement) => ({
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
ratio: node.naturalWidth / node.naturalHeight,
|
||||
}))
|
||||
const viewport = page.viewportSize()!
|
||||
expect(enlargedGeometry.width).toBeGreaterThan(layout.width)
|
||||
expect(enlargedGeometry.width / enlargedGeometry.height).toBeCloseTo(enlargedGeometry.ratio, 1)
|
||||
expect(Math.max(enlargedGeometry.width / (viewport.width * 0.88), enlargedGeometry.height / (viewport.height * 0.84))).toBeCloseTo(1, 2)
|
||||
const fitted = (await enlarged.boundingBox())!
|
||||
expect(fitted.x).toBeGreaterThanOrEqual(viewport.width * 0.06 - 1)
|
||||
expect(fitted.y).toBeGreaterThanOrEqual(viewport.height * 0.08 - 1)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
|
||||
const anchor = { x: fitted.x + fitted.width * 0.6, y: fitted.y + fitted.height * 0.6 }
|
||||
await page.mouse.move(anchor.x, anchor.y)
|
||||
await page.mouse.wheel(0, -360)
|
||||
await expect.poll(async () => (await enlarged.boundingBox())!.width).toBeGreaterThan(fitted.width * 1.5)
|
||||
const zoomed = (await enlarged.boundingBox())!
|
||||
expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo(0.6, 2)
|
||||
expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo(0.6, 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(anchor.x + 100, anchor.y + 60, { steps: 15 })
|
||||
await page.mouse.up()
|
||||
const panned = (await enlarged.boundingBox())!
|
||||
expect(panned.x - zoomed.x).toBeCloseTo(100, 0)
|
||||
expect(panned.y - zoomed.y).toBeCloseTo(60, 0)
|
||||
expect(panned.width).toBeCloseTo(zoomed.width, 2)
|
||||
await page.mouse.dblclick(viewport.width / 2, viewport.height / 2)
|
||||
await expect.poll(async () => (await enlarged.boundingBox())!.width).toBe(fitted.width)
|
||||
expect((await enlarged.boundingBox())!.x).toBe(fitted.x)
|
||||
expect(await image.getAttribute('src')).toBe(await enlarged.getAttribute('src'))
|
||||
await dialog.getByRole('button', { name: 'Close preview', exact: true }).click()
|
||||
await showAllPreviews(page)
|
||||
const images = page.getByRole('img', { name: 'Mermaid diagram' })
|
||||
await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3)
|
||||
await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true)
|
||||
expect(await images.evaluateAll(nodes => nodes.every(node =>
|
||||
node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true)
|
||||
expect(await page.getByRole('status').filter({ hasText: 'Unable to render this diagram. Select Source to inspect the code.' }).count()).toBe(1)
|
||||
const failed = page.locator('.md-code-block').filter({ has: page.locator('[role="status"]') })
|
||||
await failed.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
expect(await failed.locator('pre code').textContent()).toBe(INVALID)
|
||||
await failed.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
|
||||
await first.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
|
||||
await first.getByRole('button', { name: 'Source', exact: true }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
expect(await first.getByRole('button', { name: 'Source', exact: true })
|
||||
.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
expect(await first.locator('pre code').textContent()).toBe(FLOW)
|
||||
expect(await diagram!.evaluate(node => node.isConnected && getComputedStyle(node).visibility === 'hidden')).toBe(true)
|
||||
await first.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
await first.getByRole('img', { name: 'Mermaid diagram' }).waitFor()
|
||||
await first.getByRole('button', { name: 'Copy', exact: true }).waitFor()
|
||||
expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none')
|
||||
expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0)
|
||||
expect(dialogs).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await page.getByRole('heading', { name: 'Mermaid previews' }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE)
|
||||
await page.close()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true })
|
||||
await openConversation(page, scaffold)
|
||||
expect(await page.getByRole('button', { name: '预览', exact: true }).count()).toBe(6)
|
||||
const first = page.locator('.md-code-block').first()
|
||||
expect(await first.locator('pre').count()).toBe(0)
|
||||
expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
|
||||
await first.getByRole('button', { name: '预览', exact: true }).click()
|
||||
const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first()
|
||||
await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
|
||||
await first.getByRole('button', { name: '放大预览', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: 'Mermaid 图表' }).waitFor()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh-lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await first.getByRole('button', { name: '源码', exact: true }).textContent()).toBe('源码')
|
||||
expect(await first.getByRole('button', { name: '复制', exact: true }).getAttribute('aria-label')).toBe('复制')
|
||||
await showAllPreviews(page, '预览')
|
||||
await page.getByRole('status').filter({ hasText: '无法渲染此图表,可切换到源码查看。' }).waitFor()
|
||||
const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md', 'offscreen.expected.md'])
|
||||
await page.close()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('defers offscreen Mermaid and Graphviz runtimes until a preview enters the viewport', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
let history: WebScaffold | undefined
|
||||
const runtimes: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (/\/(mermaid\.core|viz)-[^/]+\.js$/.test(path)) runtimes.push(path)
|
||||
})
|
||||
try {
|
||||
history = await launchWebScaffold({})
|
||||
const tail = ['```dot', DOT, '```', ...Array.from({ length: 40 }, (_, index) => `Closing note ${index + 1}.`), 'History complete.'].join('\n\n')
|
||||
await seedSession(history, fixture(Array.from({ length: 20 }, (_, index) => index === 0 ? FLOW : SEQUENCE), tail), SEED_ID)
|
||||
await openConversation(page, history)
|
||||
await page.getByText('History complete.', { exact: true }).waitFor()
|
||||
await page.evaluate(async () => {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
|
||||
})
|
||||
const blocks = page.locator('.md-code-block')
|
||||
expect(await blocks.count()).toBe(21)
|
||||
expect(await blocks.locator('img').count()).toBe(0)
|
||||
expect(runtimes).toEqual([])
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'offscreen.expected.md'),
|
||||
await captureStableAria(page, '.md-code-block', history.workspaceCwd), MODE)
|
||||
await blocks.first().scrollIntoViewIfNeeded()
|
||||
await blocks.first().getByRole('img', { name: 'Mermaid diagram', exact: true })
|
||||
.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
expect(runtimes.some(path => path.includes('/mermaid.core-'))).toBe(true)
|
||||
expect(runtimes.some(path => path.includes('/viz-'))).toBe(false)
|
||||
await blocks.last().scrollIntoViewIfNeeded()
|
||||
await blocks.last().getByRole('img', { name: 'Graphviz diagram', exact: true })
|
||||
.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
expect(runtimes.some(path => path.includes('/viz-'))).toBe(true)
|
||||
} finally {
|
||||
await page.close()
|
||||
await history?.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
let imageScaffold: WebScaffold | undefined
|
||||
const requests: string[] = []
|
||||
try {
|
||||
await page.route('https://preview.invalid/**', async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
imageScaffold = await launchWebScaffold({})
|
||||
await seedSession(imageScaffold, fixture([
|
||||
'flowchart LR\n A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW,
|
||||
]), SEED_ID)
|
||||
await openConversation(page, imageScaffold)
|
||||
await page.locator('.md-code-block').first().scrollIntoViewIfNeeded()
|
||||
await page.locator('.md-code-block').first().getByRole('status')
|
||||
.filter({ hasText: 'Unable to render this diagram' }).waitFor()
|
||||
await page.locator('.md-code-block').nth(1).scrollIntoViewIfNeeded()
|
||||
const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true })
|
||||
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
|
||||
expect(requests).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
await imageScaffold?.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('previews inert DOT and SVG images', async () => {
|
||||
const page = await newEnglishPage(browser)
|
||||
const requests: string[] = []
|
||||
const dialogs: string[] = []
|
||||
// A data-URL SVG image must not fetch source-authored external resources.
|
||||
await page.route('https://preview.invalid/**', async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await openConversation(page, scaffold)
|
||||
for (const [index, title, code] of [[4, 'Graphviz diagram', DOT], [5, 'SVG preview', SVG]] as const) {
|
||||
const block = page.locator('.md-code-block').nth(index)
|
||||
await block.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
const preview = block.getByRole('img', { name: title, exact: true })
|
||||
await preview.waitFor()
|
||||
await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
|
||||
await block.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code)
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
expect(await block.locator('pre code').textContent()).toBe(code)
|
||||
await block.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
await preview.waitFor()
|
||||
}
|
||||
expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull()
|
||||
expect(dialogs).toEqual([])
|
||||
expect(requests).toEqual([])
|
||||
const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href)
|
||||
expect(notices.ok()).toBe(true)
|
||||
expect(await notices.text()).toContain('Eclipse Public License - v 2.0')
|
||||
await page.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Keyless Browser-tab coverage through the shipped Web composition. */
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-browser', import.meta.url))
|
||||
const EXPECTED = join(SNAPSHOT_DIR, 'browser.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.route('https://browser.test/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.slice(1) || 'one'
|
||||
await route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<h1>${name}</h1><a href="/inside">Inside navigation</a><p>isolated HTTPS fixture</p>`,
|
||||
})
|
||||
})
|
||||
await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<h1>loopback</h1>',
|
||||
}))
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('navigates sandboxed HTTP(S) with application-known history', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser'))
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.fill(PROMPT)
|
||||
await composer.press('Enter')
|
||||
await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
|
||||
const input = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
|
||||
await input.fill('https://browser.test/one')
|
||||
await input.press('Enter')
|
||||
const frame = column.locator('[data-sidebar-browser-frame]')
|
||||
await frame.waitFor({ state: 'visible' })
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
|
||||
expect(await frame.getAttribute('allow')).toBeNull()
|
||||
await column.getByRole('button', { name: 'Disable sandbox restrictions' }).click()
|
||||
await expect.poll(() => frame.getAttribute('sandbox')).toBeNull()
|
||||
await column.getByText('Sandbox restrictions are disabled; the page can navigate the top-level app and use downloads, modal dialogs, and input locks.', { exact: true }).waitFor()
|
||||
await column.getByRole('button', { name: 'Restore sandbox restrictions' }).click()
|
||||
await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click()
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor()
|
||||
await column.getByText('URL changed', { exact: true }).waitFor()
|
||||
expect(await column.getByRole('button', { name: 'Back', exact: true }).isDisabled()).toBe(true)
|
||||
expect(await column.getByRole('button', { name: 'Forward', exact: true }).isDisabled()).toBe(true)
|
||||
expect(await column.getByRole('button', { name: 'Open in system browser', exact: true }).isDisabled()).toBe(true)
|
||||
await column.getByRole('button', { name: 'Reload' }).click()
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
|
||||
await input.fill('https://browser.test/two')
|
||||
await input.press('Enter')
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
|
||||
await column.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
|
||||
await column.getByRole('button', { name: 'Forward', exact: true }).click()
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
|
||||
|
||||
await input.fill('http://127.0.0.1:3080/preview')
|
||||
await input.press('Enter')
|
||||
await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'loopback' }).waitFor()
|
||||
expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
|
||||
|
||||
await input.fill('file:///work/index.html')
|
||||
await input.press('Enter')
|
||||
const blocked = await column.getByRole('alert').innerText()
|
||||
expect(blocked).toBe('Only HTTP and HTTPS addresses are supported; use Document Preview for local files.')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await mkdir(SNAPSHOT_DIR, { recursive: true })
|
||||
await compareOrRefreshGolden(EXPECTED, [
|
||||
'# Sidebar Browser', '',
|
||||
'- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
|
||||
'- Permissions Policy: browser defaults',
|
||||
'- Sandbox toggle: per-tab and temporary',
|
||||
'- Unknown navigation: marker shown; Back, Forward, and external-open disabled',
|
||||
'- HTTPS history: one -> two -> one -> two',
|
||||
'- Loopback HTTP: loaded under the default sandbox',
|
||||
`- Invalid protocol: ${blocked}`,
|
||||
].join('\n'), MODE)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -407,7 +407,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
}
|
||||
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3)
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
|
||||
// A manual guide is closable beside Files and suppresses another add
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- text: ts
|
||||
- button "Line numbers"
|
||||
- button "Copy"
|
||||
- text: Source
|
||||
- code: "const first: number = 1 const second = \"two\" let tail"
|
||||
- status: Deep diving...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Stream an SVG diagram." [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "System prompt":
|
||||
- img
|
||||
- img
|
||||
- text: System prompt
|
||||
- text: Stream an SVG diagram. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- text: svg
|
||||
- status: Generating diagram…
|
||||
- status: Deep diving...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current streaming-fence-highlight-test/streaming-fence":
|
||||
- text: streaming-fence-highlight-test/streaming-fence
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -58,17 +58,21 @@ class StreamingFenceAdapter extends LlmAdapter {
|
||||
this.finish()
|
||||
}
|
||||
|
||||
constructor(private readonly first = FIRST_REPLY, private readonly open = OPEN_REPLY, private readonly reply = REPLY) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
|
||||
yield { type: 'text-delta', index: 0, text: this.first }
|
||||
this.resolveFirstPaused()
|
||||
await this.firstContinuation
|
||||
yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
|
||||
yield { type: 'text-delta', index: 0, text: this.open.slice(this.first.length) }
|
||||
this.resolveSecondPaused()
|
||||
await this.secondContinuation
|
||||
if (options.signal?.aborted === true) throw options.signal.reason
|
||||
yield { type: 'text-delta', index: 0, text: '\n```' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
|
||||
yield { type: 'text-delta', index: 0, text: this.reply.slice(this.open.length) }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
@@ -129,6 +133,62 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('holds a stable image placeholder while a diagram streams, then restores preview controls', async () => {
|
||||
const first = '```svg\n<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100">\n'
|
||||
const open = `${first}<rect width="460" height="100" fill="lightblue"/>\n`
|
||||
const response = `${open}</svg>\n\`\`\``
|
||||
const pending = new StreamingFenceAdapter(first, open, response)
|
||||
const local = await launchWebScaffold()
|
||||
let previewPage: Page | undefined
|
||||
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
||||
try {
|
||||
local.ctx.effect(() => local.ctx.llm.registerAdapter([PROVIDER], pending), 'streaming preview adapter')
|
||||
await local.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
|
||||
previewPage = await newEnglishPage(browser)
|
||||
await previewPage.goto(local.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(previewPage, local.workspaceCwd)
|
||||
const input = previewPage.locator('[data-composer-input]').first()
|
||||
await writeComposerDraft(previewPage, input, 'Stream an SVG diagram.')
|
||||
await input.press('Enter')
|
||||
await pending.firstPaused
|
||||
settled = local.whenTurnSettled(30_000)
|
||||
const block = previewPage.locator('.md-code-block')
|
||||
const placeholder = block.locator('[data-preview-placeholder]')
|
||||
await placeholder.waitFor()
|
||||
const height = (await block.boundingBox())!.height
|
||||
expect((await placeholder.boundingBox())!.height).toBe(180)
|
||||
expect(await block.locator('pre, button').count()).toBe(0)
|
||||
const glyph = placeholder.locator('svg')
|
||||
expect(await glyph.evaluate(node => getComputedStyle(node).animationDuration)).toBe('1.8s')
|
||||
pending.grow()
|
||||
await pending.secondPaused
|
||||
expect((await block.boundingBox())!.height).toBe(height)
|
||||
expect(await block.locator('pre, button').count()).toBe(0)
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./snapshots/streaming-fence-highlight/preview-placeholder.expected.md', import.meta.url)),
|
||||
await captureStableAria(previewPage, '[class*="centerCol"]', local.workspaceCwd), MODE,
|
||||
)
|
||||
await previewPage.emulateMedia({ reducedMotion: 'reduce' })
|
||||
expect(await glyph.evaluate(node => getComputedStyle(node).animationName)).toBe('none')
|
||||
pending.finish()
|
||||
await settled
|
||||
await block.getByRole('img', { name: 'SVG preview', exact: true }).waitFor()
|
||||
expect(await placeholder.count()).toBe(0)
|
||||
expect(await block.locator('pre').count()).toBe(0)
|
||||
expect(await block.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
|
||||
await block.getByRole('button', { name: 'Source', exact: true }).click()
|
||||
expect(await block.locator('pre code').textContent()).toBe(response.slice(7, -4))
|
||||
} finally {
|
||||
pending.continue()
|
||||
try {
|
||||
await settled
|
||||
} finally {
|
||||
await previewPage?.close()
|
||||
await local.close()
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
@@ -180,6 +240,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
|
||||
}))).toEqual({ block: 'true', pre: 'true', line: 'true' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md', 'preview-placeholder.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -46,6 +46,7 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/document-preview.e2e.ts",
|
||||
"tests/office-fixture.ts",
|
||||
"tests/sidebar-browser.e2e.ts",
|
||||
"tests/plugin-config.e2e.ts",
|
||||
"tests/plugin-manager.e2e.ts",
|
||||
"tests/plugin-install-cancel.e2e.ts",
|
||||
@@ -94,6 +95,7 @@
|
||||
"tests/math-rendering.e2e.ts",
|
||||
"tests/markdown-cjk-strong.e2e.ts",
|
||||
"tests/markdown-inline-code-links.e2e.ts",
|
||||
"tests/markdown-mermaid.e2e.ts",
|
||||
"tests/clickable-links-gallery.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/cold-blank-session.e2e.ts",
|
||||
@@ -132,6 +134,7 @@
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/composer-tab-geometry.e2e.ts",
|
||||
"tests/complex-history.perf.ts",
|
||||
"tests/diagram-preview.perf.ts",
|
||||
"tests/pwsh-terminal.e2e.ts",
|
||||
"tests/workflow-run.e2e.ts"
|
||||
],
|
||||
|
||||
+15
-1
@@ -29,6 +29,20 @@ function clientDocumentTitle(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve preview licenses and Graphviz source availability in every browser distribution. */
|
||||
function previewNotices(): Plugin {
|
||||
return {
|
||||
name: 'dsh-preview-notices',
|
||||
async generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'preview-third-party-notices.txt',
|
||||
source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
return {
|
||||
@@ -156,7 +170,7 @@ export default defineConfig({
|
||||
// directory, and the served index resolves identically from the site root.
|
||||
base: './',
|
||||
plugins: [
|
||||
rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
|
||||
previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
|
||||
productWebBundleIsolation(src('../..'), src('.')),
|
||||
],
|
||||
build: {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: eeeaff21533f7d39934a5dd1b7bad24c6966456f
|
||||
config-catalog.zh.md: db3022cae52790f7e02e35e2fc6a755d0f325fd2
|
||||
config-catalog.md: 80d9f42e0c4957bd22730b2f577c029027916abb
|
||||
config-catalog.zh.md: 52be394871e4717c857f52c188f80805692ce4fa
|
||||
@@ -3812,6 +3812,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
|
||||
|
||||
@@ -3814,6 +3814,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: b89bb220fa37e70b83fe879adee71f8d042b0c9a
|
||||
module-graph.zh.md: 7eff2d9faeea8e49d675ad1894b481cd0e1d0ade
|
||||
module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870
|
||||
module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03
|
||||
@@ -182,6 +182,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
|
||||
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
|
||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
|
||||
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
|
||||
pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
|
||||
pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
|
||||
@@ -1363,6 +1364,7 @@ flowchart TD
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
|
||||
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
|
||||
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
|
||||
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
|
||||
| [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
|
||||
| [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
|
||||
|
||||
@@ -184,6 +184,7 @@ flowchart TD
|
||||
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
|
||||
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
|
||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
|
||||
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
|
||||
pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
|
||||
pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
|
||||
@@ -1365,6 +1366,7 @@ flowchart TD
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
|
||||
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
|
||||
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
|
||||
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
|
||||
| [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
|
||||
| [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
|
||||
sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806
|
||||
sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47
|
||||
sidebar-right.md: 470b72118ee680f84489d13668e74c0eede652b0
|
||||
sidebar-right.zh.md: 62481c4024e6647a7e620a9c8e72ed6d0c969a60
|
||||
@@ -19,7 +19,7 @@ A tab type is two registrations that share the definition's `id`: a static defin
|
||||
| [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` |
|
||||
| [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider |
|
||||
| [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` |
|
||||
| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
|
||||
| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md), [`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.md) | The shipped `text`, `files`, and `browser` types |
|
||||
|
||||
## Addresses
|
||||
|
||||
@@ -72,7 +72,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
## Navigation: `ctx.sidebarRight`
|
||||
|
||||
Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
|
||||
Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
@@ -135,6 +135,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
|
||||
- **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
|
||||
- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
|
||||
- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
|
||||
- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)).
|
||||
|
||||
<a id="not-built"></a>
|
||||
## Not built
|
||||
@@ -147,4 +148,4 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
|
||||
- Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar.
|
||||
- An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate.
|
||||
- Navigation addresses beyond the Sidebar's own `sidebar://<kind>` bookkeeping; their grammar waits for the navigation controller as a whole.
|
||||
- A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).
|
||||
- A user-facing undo and general cross-type content navigation stack ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)); Browser owns only its own page history.
|
||||
@@ -19,7 +19,7 @@
|
||||
| [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` |
|
||||
| [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 |
|
||||
| [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` |
|
||||
| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
|
||||
| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md)、[`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.zh.md) | 内置的 `text`、`files` 与 `browser` 类型 |
|
||||
|
||||
## 地址
|
||||
|
||||
@@ -72,7 +72,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
## 导航:`ctx.sidebarRight`
|
||||
|
||||
两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
|
||||
两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
|
||||
|
||||
| 选项 | 含义 |
|
||||
|---|---|
|
||||
@@ -135,6 +135,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
|
||||
- **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
|
||||
- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
|
||||
- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
|
||||
- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。
|
||||
|
||||
<a id="not-built"></a>
|
||||
## 不做
|
||||
@@ -147,4 +148,4 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
|
||||
- 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。
|
||||
- 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。
|
||||
- Sidebar 自身 `sidebar://<kind>` 记账之外的导航地址;其语法等导航控制器整体做时再定。
|
||||
- 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。
|
||||
- 面向用户的撤销与跨类型通用内容导航栈([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred));Browser 只拥有自身页面历史。
|
||||
@@ -233,9 +233,13 @@
|
||||
- id: ui-sidebar-documentpreview
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
|
||||
|
||||
# The right Sidebar's workspace file tree tab type.
|
||||
# Sandboxed HTTP(S) Browser tabs, including loopback targets.
|
||||
- id: ui-sidebar-browser
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
|
||||
|
||||
- id: ui-sidebar-terminal
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar-terminal'
|
||||
# The right Sidebar's workspace file tree tab type.
|
||||
- id: ui-sidebar-files
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar-files'
|
||||
|
||||
|
||||
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user