diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2749cc8..040e717 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,8 @@ concurrency:
jobs:
validate:
name: Typecheck, smoke, and build
- runs-on: ubuntu-24.04
+ # Profile migration uses macOS shlock; validate on the supported desktop OS.
+ runs-on: macos-15
timeout-minutes: 20
steps:
@@ -26,6 +27,14 @@ jobs:
with:
persist-credentials: false
+ - name: Check out pinned Harness
+ uses: actions/checkout@v6
+ with:
+ repository: deepseek-ai/deepseek-harness
+ ref: 639ed015397290b3745d163aafe02ffee4aa3f84
+ path: upstream/deepseek-harness
+ persist-credentials: false
+
- name: Install pnpm
uses: pnpm/action-setup@v6
@@ -41,9 +50,32 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- # Development snapshots use bounded smoke coverage; full regression is separate.
- - name: Smoke
+ - name: Validate pinned adapter contracts
+ run: node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
+
+ - name: Install Harness dependencies
+ run: pnpm --dir upstream/deepseek-harness install --frozen-lockfile
+
+ - name: Build Harness and DeepViewer plugins
+ run: node apps/deepviewer-desktop/scripts/build-native-desktop.mjs --core-only
+
+ - name: Bounded smoke coverage
run: pnpm desktop:smoke
- - name: Build
- run: pnpm desktop:build
+ - name: Native Synapse integration and UI regression smoke
+ run: |
+ pnpm --filter @deepviewer/desktop smoke:synapse
+ pnpm --filter @deepviewer/desktop smoke:ui-regression
+
+ - name: Fresh upstream contracts and policy fixtures
+ run: |
+ node apps/deepviewer-desktop/scripts/smoke-native-contract-source.mjs
+ node apps/deepviewer-desktop/scripts/smoke-native-header-placement.mjs
+ pnpm --filter @deepviewer/desktop smoke:contracts
+ node --test --test-timeout=15000 apps/dsh-plugin-model-capabilities/test/*.test.mjs
+ node --experimental-strip-types --test apps/deepviewer-adapter/tests/workflows.test.mjs
+
+ - name: Authenticated Host and model plugin smoke
+ run: |
+ node apps/deepviewer-desktop/scripts/smoke-model-capabilities-host.mjs
+ node apps/dsh-plugin-model-capabilities/scripts/smoke-runtime.mjs
diff --git a/.gitignore b/.gitignore
index cd9419d..a592c5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,14 @@ out/
# Local checkout of the official upstream source. The analyzed revision and
# refresh procedure are recorded in upstream/README.md.
upstream/deepseek-harness/
+
+# Generated official desktop carrier and adapter bundles
+apps/deepviewer-desktop/lib/
+apps/deepviewer-desktop/renderer/
+apps/deepviewer-adapter/lib/
+
+# Generated official desktop launcher shims
+/apps/deepviewer-desktop/scripts/node-bin/
+
+# Local independent worktrees and inspection checkouts
+.worktrees/
diff --git a/README.md b/README.md
index 7db3f86..74b75be 100644
--- a/README.md
+++ b/README.md
@@ -12,15 +12,15 @@
-
+
- Download 0.3.3 (ARM64)
+ 0.5.0 release candidate (ARM64)
·
- What's new
+ What's new
·
Privacy & data
·
@@ -33,8 +33,10 @@
DeepViewer is an independent, open-source desktop agent workspace built on
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). It bundles
-the pinned runtime into a macOS application and adds Work and Chat spaces,
-file delivery, a sidebar workbench, and desktop controls.
+the pinned runtime into a macOS application with the native Electron
+DesktopHost, Work and Chat spaces, file delivery, a sidebar workbench, and a
+session map. The 0.5.0 source derives from Scoder 0.7.0 Build 43, restored to
+DeepViewer's identity and independent data directories.
@@ -44,93 +46,100 @@ file delivery, a sidebar workbench, and desktop controls.
> DeepViewer is a community project. It is not affiliated with or endorsed by DeepSeek.
> [!IMPORTANT]
-> The current release is **DeepViewer 0.3.3 / Build 75**, based on DeepSeek Harness `0.1.5-rc.2` and available for **Apple Silicon arm64 only**. The DMG is Developer ID signed and Apple-notarized. Final visual acceptance of migrated sessions, settings, and attachments remains with the maintainer.
+> **DeepViewer 0.5.0 is being prepared for public release**, based on DeepSeek Harness `0.2.0-rc.2`, for **Apple Silicon arm64 only**. A freshly built Developer ID signed and Apple-notarized DMG is the release target; its final build number, signing/notarization results, checksums, and download link are pending confirmation. Visual acceptance, real-account flows, and migrated sessions/settings/attachments remain separate manual checks.
> [!TIP]
-> The [release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) includes a SHA-256 checksum. Installed and development data now use separate, version-independent directories; back up existing data before a major upgrade. The previous public [0.2.5 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) remains available.
+> Installed and development data use separate, version-independent directories; back up existing data before a major upgrade. The previous public [0.3.3 / Build 75 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3), with its SHA-256 checksum, and [0.2.5 release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) remain available.
-## What changed since the previous public release (0.2.5)
+> [!NOTE]
+> All screenshots in this README are historical **0.3.3 / Build 75** references. They have not been replaced or used as evidence of the 0.5.0 interface or visual acceptance.
+
+## What changed since the previous public release (0.3.3)
-| Area | Changes in 0.3.3 |
+| Area | Changes in the 0.5.0 candidate |
| --- | --- |
-| Work and Chat | Separate workspace-backed Work sessions from workspace-free Chat history. Create projects from the sidebar, search Chat history, and recover session mode and titles after a restart without opening every conversation. |
-| Files and previews | Deliver generated images and other files as attachment cards. Materialize external or extensionless outputs inside the session workspace with an appropriate extension so the card and sidebar preview refer to the same file. |
-| Sidebar workbench | Integrate the open-source Better Sidebar workbench for files, editor, terminal, Git and browser panels. Desktop adaptations preserve window chrome, session-relative paths, navigation, and authentication checks. |
-| Models | Add a first-party reasoning and multimodal capability editor with bounded model scanning. Provider support is checked per model; a successful scan does not guarantee every provider feature works. |
-| Progress and appearance | Group related context and operations into expandable summaries, align their text and multimodal status with the conversation font-size setting, add clearer hover states, image-generation shimmer, and a 12 × 12 px Drive activity indicator. Settings layout and system appearance follow the desktop shell. |
-| Networking | Use the system proxy/PAC configuration for the bundled runtime, preserve explicit proxy settings, show clearer connection errors, and request scoped permission before accessing private-network addresses. |
-| Upgrade continuity | Installed and development editions use separate, fixed data directories. First launch imports older same-edition sessions and attachments with backups; later app and Harness versions reuse the fixed directory. Cold session lists restore Work/Chat classification from persisted events. |
+| Native desktop | Move from the previous custom shell and DSH 0.1.5 overlays to the official Electron/DesktopHost and pinned DSH `0.2.0-rc.2`, using Scoder `0.7.0 / Build 43` as the source baseline. |
+| Work, Chat and session map | Retain workspace-backed Work and workspace-free Chat, and add the built-in Synapse session map for exploring session context. |
+| Sidebar workbench | Harness native panels take over the standalone Better Sidebar activation. DeepViewer retains file/editor/Git workflows, session-relative paths, desktop browser integration, and permission checks. |
+| Built-in modules | Bundle subscriptions `0.3.1` and model-capabilities `1.0.2` as application modules. Model-capabilities replaces the old reasoning plugin and carries over its supported settings. |
+| Files and appearance | Keep attachment delivery and workspace previews, restore DeepViewer branding throughout the native shell, and follow desktop/system appearance. |
+| Development stability | Ignore filesystem synchronization that only changes artifact metadata, preserving the mounted renderer. Real code changes and completed-build timestamps still trigger hot reload. |
+| Upgrade continuity | Preserve separate stable/development data roots, snapshot the old DeepViewer kernel data before opening the new stores, and retain migration/rollback backups. Scoder and Saidex data are excluded from migration candidates. |
+| Networking | Retain system proxy/PAC support, explicit proxy settings, scoped loopback/RPC authentication, and private-network permission boundaries. |
-The removed Taskboard integration is **not** included. Some provider, sidebar, and migrated-data flows still need real-account and manual UI acceptance; see the [release record](docs/sdd/releases/v0.3.3.md) for the verification boundary.
+The standalone Better Sidebar and reasoning plugins are retired from activation; their replacement is part of this migration. Taskboard remains removed, and the historical preview plugin remains disabled. See the [0.5.0 specification](docs/sdd/specs/DV-0033-scoder-native-rebase/spec.md) for the current verification boundary and the [0.3.3 release record](docs/sdd/releases/v0.3.3.md) for historical evidence.
### Work, Chat, and process disclosure
Work groups sessions by project. Chat keeps conversations independent of a workspace. Both modes restore their history on startup, and related context, reasoning, searches, and operations appear in expandable summaries.
-
+
-
+
### Files and sidebar workbench
The sidebar can inspect workspace files, preview generated HTML, and browse pages alongside the conversation. File paths, panel navigation, and browser access are connected to the active desktop session.
-
+
-
+
### Generated images and attachments
Generated files are delivered as attachment cards with usable names and extensions. The same workspace copy opens in the sidebar preview, including images first produced outside the workspace.
-
+
-
+
### Appearance and version identity
Settings follow the desktop appearance, and the About page shows the app build and bundled Harness version.
-
+
## Install and data
-1. Download `DeepViewer-0.3.3-macos-arm64.dmg` and check it against `SHA256SUMS.txt` in the [release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3).
+1. Once 0.5.0 is published, download the expected `DeepViewer-0.5.0-arm64.dmg` and verify it against `SHA256SUMS.txt` from [GitHub Releases](https://github.com/Duoasa/DeepViewer/releases). The candidate's final assets and download link are pending confirmation.
2. Open the DMG and copy `DeepViewer.app` to Applications.
3. Open the app and configure a provider or subscription. No global Node.js or Harness installation is needed.
DeepViewer binds its local service to a random loopback port. Installed app data lives in `~/Library/Application Support/DeepViewer`; development app data lives in `~/Library/Application Support/DeepViewer Dev`. Replacing the application does not replace these directories. Legacy data is copied and verified before first-use cutover; migration backups are retained. Keep your own backup before a major upgrade.
-The release package is built from a clean allowlist of application code, pinned runtime packages, assets, and licenses. It does **not** contain the maintainer's sessions, workspaces, settings, logs, account credentials, home-directory paths, or development handoff files. The packaged app is audited before signing and publication.
+The packaging workflow materializes an allowlist of application code, pinned runtime packages, assets, and licenses. Release acceptance requires auditing the final package for developer-machine paths, private data, and escaping links, as well as verifying its signature, notarization, and checksums. The 0.5.0 candidate's final package evidence is pending.
## Open-source foundations and DeepViewer modifications
| Component | Source and license | What DeepViewer changes |
| --- | --- | --- |
-| Core agent | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), MIT, pinned to `fb2c4b9e698e30edb738bca4cf0618587db7d203` (`0.1.5-rc.2`) | Desktop integration and a [reproducible source patch](upstream/snapshots/v0.3.3/README.md) for Work/Chat, session recovery, file delivery, progressive disclosure, themes, and network bridging. The agent loop remains upstream-owned. |
-| Subscriptions and image tools | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions), MIT, `0.3.1` | DSH 0.1.5 compatibility adapter, scoped RPC/loopback checks, remaining-usage display, and image-generation status styling. |
-| Sidebar workbench | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar), MIT, `0.19.1` | macOS chrome and settings adaptation, native panel routing, session-relative paths, and desktop browser/authentication integration. |
-| Model capability editor | [DeepViewer first-party plugin](apps/dsh-plugin-reasoning), MIT, `0.1.0` | Provider/model scanning and manual reasoning and multimodal capability configuration. |
+| Core agent and native shell | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), MIT, pinned to `639ed015397290b3745d163aafe02ffee4aa3f84` (`0.2.0-rc.2`) | Official Electron/DesktopHost with the [DeepViewer adapter](apps/deepviewer-adapter) and checked compatibility contracts. The agent loop remains upstream-owned. |
+| Desktop source baseline | [Duoasa/scoder](https://github.com/Duoasa/scoder), `0.7.0 / Build 43`, pinned to `e277adc6ce512655095e6f65c63bdc72d24bae95`; original notices retained | Restore DeepViewer branding, package/update identities and data isolation, retain native features, and fix metadata-driven renderer replacement. Scoder's release history and user data are not imported. |
+| Subscriptions and image tools | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions), MIT, `0.3.1` | Built-in DSH 0.2.0 compatibility module, scoped RPC/loopback checks, remaining-usage display, and image-generation status styling. |
+| Sidebar provenance | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar), MIT | The standalone plugin is retired; Harness native panels take over the workbench. Attribution is retained for reused workflow code. |
+| Model capabilities | [DeepViewer model-capabilities module](apps/dsh-plugin-model-capabilities), MIT, `1.0.2` | Provider/model scanning and reasoning/multimodal configuration, replacing the old `dsh-plugin-reasoning` activation while preserving supported settings. |
+| Session map | [Vendored Synapse source](apps/deepviewer-adapter/workflows/vendor/synapse), with its original license and source record | Built-in session-map integration within the DeepViewer adapter. |
The historical first-party preview plugin is disabled in favor of the Harness preview. [dashi-taskboard](https://github.com/chuspeeism/dashi-taskboard) was evaluated and removed; no Taskboard runtime is bundled. Upstream copyrights and third-party licenses remain with their respective projects.
## Build from source
-Requires macOS arm64, Node.js 24+, and pnpm 11.19.0. The committed patch reconstructs the exact Harness source used for this release from the pinned upstream commit; no user data or prebuilt app is needed.
+Requires macOS arm64, Node.js 24+, and pnpm 11.19.0. The bootstrap checks out the pinned Harness source and applies the adapter's checked contracts; no user data or prebuilt app is needed.
```sh
git clone https://github.com/Duoasa/DeepViewer.git
cd DeepViewer
pnpm install --frozen-lockfile
-git clone https://github.com/deepseek-ai/deepseek-harness upstream/deepseek-harness
-git -C upstream/deepseek-harness checkout fb2c4b9e698e30edb738bca4cf0618587db7d203
-git -C upstream/deepseek-harness apply --whitespace=nowarn ../snapshots/v0.3.3/harness.patch
+node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
pnpm --dir upstream/deepseek-harness install --frozen-lockfile
-pnpm --dir upstream/deepseek-harness run build:official
-pnpm desktop:build
+pnpm desktop:dev
```
-`pnpm desktop:build` increments the local build number for continued development. The [snapshot instructions](upstream/snapshots/v0.3.3/README.md) explain how to reproduce fixed Build 75, including Runtime packaging, Developer ID signing, and Apple notarization. Signing credentials stay in the local Keychain and are never committed.
+`pnpm desktop:dev` builds and opens DeepViewer Dev using its separate profile. Use `pnpm desktop:dev:restart` to restart that project's development runner, or `pnpm desktop:build` to build without launching. Development builds increment the local build number.
+
+Basic verification uses `pnpm typecheck` and `pnpm desktop:smoke`; native integration and adapter-contract checks are defined in the [CI workflow](.github/workflows/ci.yml). `pnpm desktop:preview` prepares a local preview. `pnpm desktop:release` prepares signed arm64 DMG/ZIP artifacts and requires `DEEPVIEWER_SIGN_IDENTITY`, `DEEPVIEWER_TEAM_ID`, and `DEEPVIEWER_NOTARY_PROFILE`; it does not publish them. Signing/notary credentials remain local, with the notary profile in the Keychain.
+
+The [0.3.3 snapshot instructions](upstream/snapshots/v0.3.3/README.md) remain a historical reproduction guide for Build 75, not the build procedure for 0.5.0.
The [SDD documents](docs/sdd/README.md) record architecture, plugin provenance, requirements, and verification. DeepViewer's original code uses the [MIT License](LICENSE); all upstream licenses remain applicable.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ddeed17..716b168 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -12,15 +12,15 @@
-
+
- 下载 0.3.3(ARM64)
+ 0.5.0 发布候选(ARM64)
·
- 本版更新
+ 本版更新
·
隐私与数据
·
@@ -33,8 +33,9 @@
DeepViewer 是建立在
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 之上的独立开源桌面
-Agent 工作台。它将固定版本的 Runtime 封装进 macOS 应用,并加入 Work / Chat 空间、
-文件交付、侧栏工作台与桌面控制。
+Agent 工作台。它通过原生 Electron/DesktopHost 将固定版本的 Runtime 封装进 macOS
+应用,提供 Work / Chat 空间、文件交付、侧栏工作台和会话图谱。0.5.0 源码承接
+Scoder 0.7.0 Build 43,恢复 DeepViewer 品牌和独立的数据目录。
@@ -44,93 +45,100 @@ Agent 工作台。它将固定版本的 Runtime 封装进 macOS 应用,并加
> DeepViewer 是独立社区项目,与 DeepSeek 没有从属或官方背书关系。
> [!IMPORTANT]
-> 当前正式发布为 **DeepViewer 0.3.3 / Build 75**,内置 DeepSeek Harness `0.1.5-rc.2`,**仅支持 Apple Silicon arm64**。DMG 已完成 Developer ID 签名和 Apple 公证。迁移会话、设置与附件的最终界面验收仍由维护者进行。
+> **DeepViewer 0.5.0 正在准备公开发布**,内置 DeepSeek Harness `0.2.0-rc.2`,**仅支持 Apple Silicon arm64**。本次目标是从源码全新生成 Developer ID 签名并经 Apple 公证的 DMG;最终 Build 号、签名/公证结果、校验清单和下载链接待证据确认。界面、真实账户及迁移会话/设置/附件仍保留独立的人工验收边界。
> [!TIP]
-> [Release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 提供 SHA-256 校验清单。安装版和开发版现使用相互独立、与版本号无关的数据目录;重大升级前请备份。上一公开 [0.2.5 版本](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) 仍可下载。
+> 安装版和开发版使用相互独立、与版本号无关的数据目录;重大升级前请备份。上一公开 [0.3.3 / Build 75](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 及其 SHA-256 清单、历史 [0.2.5 版本](https://github.com/Duoasa/DeepViewer/releases/tag/v0.2.5-preview.1) 仍可下载。
-## 相比上一公开版本 0.2.5 的功能变化
+> [!NOTE]
+> 本 README 的全部截图均为 **0.3.3 / Build 75 历史参考**,本次没有替换截图,不代表 0.5.0 当前界面或视觉验收结果。
+
+## 相比上一公开版本 0.3.3 的功能变化
-| 领域 | 0.3.3 的变化 |
+| 领域 | 0.5.0 候选版本的变化 |
| --- | --- |
-| Work 与 Chat | 将依赖工作区的 Work 会话与无工作区的 Chat 历史区分;可从侧栏创建项目、搜索 Chat 历史。重启后无需逐条打开会话,也能从持久记录恢复模式和标题。 |
-| 文件与预览 | 生成的图片及其他文件以附件卡片交付。将工作区外或无后缀的产物复制到会话工作区,并根据真实类型补齐扩展名,使卡片和侧栏预览指向同一文件。 |
-| 侧栏工作台 | 集成开源 Better Sidebar,提供文件、编辑器、终端、Git 和浏览器面板;桌面适配窗口控件、相对文件路径、导航与鉴权。 |
-| 模型能力 | 增加第一方推理与多模态能力编辑器,按模型进行有边界的能力扫描;扫描通过不等于提供方的所有功能均已实测。 |
-| 过程与外观 | 同类上下文和操作合并为可展开摘要,字号与多模态状态跟随会话字号;加强悬停反馈,增加图片生成流光,以及侧栏 12 × 12 px Drive 运行指示。设置布局和系统外观与桌面壳对齐。 |
-| 网络 | 内置 Runtime 采用系统代理/PAC,保留显式代理配置;连接错误更清晰,访问内网地址前请求限定范围的授权。 |
-| 升级连续性 | 安装版与开发版分别使用固定数据目录。首次启动带备份导入同渠道旧会话和附件;后续应用及 Harness 升级继续使用固定目录。冷启动列表从持久事件恢复 Work/Chat 分类。 |
+| 原生桌面 | 从旧自建桌面壳和 DSH 0.1.5 覆盖层迁移到官方 Electron/DesktopHost 与固定 DSH `0.2.0-rc.2`,以 Scoder `0.7.0 / Build 43` 为源码基线。 |
+| Work、Chat 与会话图谱 | 保留依赖工作区的 Work 和无工作区的 Chat,增加内置 Synapse 会话图谱,用于查看会话关联与上下文。 |
+| 侧栏工作台 | Harness 原生面板承接独立 Better Sidebar 的挂载;DeepViewer 保留文件/编辑器/Git 操作、会话相对路径、桌面浏览器集成与权限检查。 |
+| 内置模块 | subscriptions `0.3.1` 和 model-capabilities `1.0.2` 作为应用模块内置;模型能力模块替代旧 reasoning 插件,并承接其支持的设置。 |
+| 文件与外观 | 保留附件交付和工作区预览,在原生壳中恢复 DeepViewer 品牌,跟随桌面/系统外观。 |
+| 开发稳定性 | 文件同步仅改变产物元数据时保留已挂载的界面;真实源码变化和完成构建的时间戳仍触发热更新。 |
+| 升级连续性 | 保留稳定版/开发版独立数据根目录,在新内核打开存储前快照旧 DeepViewer 数据,保留迁移与回滚备份;Scoder 和 Saidex 数据不作为迁移候选。 |
+| 网络 | 保留系统代理/PAC、显式代理、限定范围的 loopback/RPC 鉴权与内网访问授权边界。 |
-本版**不包含**已经移除的 Taskboard 集成。部分提供方、侧栏及迁移数据流程仍需真实账户和界面人工验收;验证边界见[发布记录](docs/sdd/releases/v0.3.3.md)。
+独立 Better Sidebar 和 reasoning 插件退出挂载,由本次迁移的原生功能与模型能力模块承接。Taskboard 保持移除,历史预览插件保持停用。当前验证边界见 [0.5.0 规格](docs/sdd/specs/DV-0033-scoder-native-rebase/spec.md);[0.3.3 发布记录](docs/sdd/releases/v0.3.3.md)仅提供历史证据。
### Work、Chat 与过程披露
Work 按项目组织会话,Chat 可以脱离工作区保存对话。两种模式都能在启动后恢复历史;同类上下文、思考、搜索和操作会合并为可展开的摘要。
-
+
-
+
### 文件与侧栏工作台
侧栏可以查看工作区文件、预览生成的 HTML,并与对话并排浏览网页。文件路径、面板导航和浏览器访问都与当前桌面会话关联。
-
+
-
+
### 图片生成与附件交付
生成文件以具有可用名称和后缀的附件卡片交付。即使图片最初生成在工作区外,也会复制到会话工作区,让附件与侧栏预览指向同一个文件。
-
+
-
+
### 外观与版本信息
设置页跟随桌面外观,“关于”页面展示应用构建号和内置 Harness 版本。
-
+
## 安装与数据
-1. 在 [Release](https://github.com/Duoasa/DeepViewer/releases/tag/v0.3.3) 下载 `DeepViewer-0.3.3-macos-arm64.dmg`,并用 `SHA256SUMS.txt` 校验。
+1. 待 0.5.0 发布后,从 [GitHub Releases](https://github.com/Duoasa/DeepViewer/releases) 下载预期文件 `DeepViewer-0.5.0-arm64.dmg`,并用 `SHA256SUMS.txt` 校验。候选版本的最终资产与下载链接待证据确认。
2. 打开 DMG,将 `DeepViewer.app` 复制到“应用程序”。
3. 打开应用并配置模型提供方或订阅账户。无需全局安装 Node.js 或 Harness。
本地服务仅监听随机 loopback 端口。安装版数据固定存放于 `~/Library/Application Support/DeepViewer`,开发版固定存放于 `~/Library/Application Support/DeepViewer Dev`。覆盖安装应用不会替换这些目录;首次迁移会先复制、校验并保留备份。重大升级前仍建议自行备份。
-公开安装包从明确允许的代码、固定 Runtime、资源和许可证全新构建,不包含维护者的会话、工作区、设置、日志、账户凭据、开发机主目录路径或交接文件。签名发布前会检查包体。
+打包流程从明确允许的代码、固定 Runtime、资源和许可证生成独立包体。正式验收要求检查最终包中的开发机路径、私有数据和越界链接,并核验签名、公证与校验清单。0.5.0 候选包的最终证据仍待确认。
## 开源引用与 DeepViewer 修改
| 组件 | 来源与许可证 | DeepViewer 的修改 |
| --- | --- | --- |
-| Agent 内核 | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),MIT,固定在 `fb2c4b9e698e30edb738bca4cf0618587db7d203`(`0.1.5-rc.2`) | 桌面集成及[可复原源码补丁](upstream/snapshots/v0.3.3/README.md),覆盖 Work/Chat、会话恢复、文件交付、过程披露、主题和网络桥接;Agent Loop 仍由上游维护。 |
-| 订阅与图片工具 | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions),MIT,`0.3.1` | DSH 0.1.5 兼容适配、限定范围 RPC/loopback 检查、剩余额度显示及图片生成状态样式。 |
-| 侧栏工作台 | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),MIT,`0.19.1` | macOS 窗口和设置适配、原生面板路由、会话相对路径、桌面浏览器与鉴权集成。 |
-| 模型能力编辑器 | [DeepViewer 第一方插件](apps/dsh-plugin-reasoning),MIT,`0.1.0` | 提供方/模型扫描,以及推理和多模态能力的手动配置。 |
+| Agent 内核与原生壳 | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),MIT,固定在 `639ed015397290b3745d163aafe02ffee4aa3f84`(`0.2.0-rc.2`) | 官方 Electron/DesktopHost 加 [DeepViewer adapter](apps/deepviewer-adapter) 与经过检查的兼容契约;Agent Loop 仍由上游维护。 |
+| 桌面源码基线 | [Duoasa/scoder](https://github.com/Duoasa/scoder),`0.7.0 / Build 43`,固定在 `e277adc6ce512655095e6f65c63bdc72d24bae95`;保留原始版权说明 | 恢复 DeepViewer 品牌、包名/更新目标和数据隔离,保留原生功能,修复元数据变化引发的界面热卸载;不导入 Scoder 发布记录与用户数据。 |
+| 订阅与图片工具 | [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions),MIT,`0.3.1` | 内置 DSH 0.2.0 兼容模块,限定范围 RPC/loopback 检查、剩余额度显示及图片生成状态样式。 |
+| 侧栏来源 | [omdsh-dev/DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),MIT | 独立插件退出挂载,工作台由 Harness 原生面板承接;复用工作流代码保留来源与版权。 |
+| 模型能力 | [DeepViewer model-capabilities 模块](apps/dsh-plugin-model-capabilities),MIT,`1.0.2` | 提供方/模型扫描和推理/多模态配置,替代旧 `dsh-plugin-reasoning` 挂载并承接支持的设置。 |
+| 会话图谱 | [Vendored Synapse 源码](apps/deepviewer-adapter/workflows/vendor/synapse),保留原始许可证和来源记录 | 在 DeepViewer adapter 中内置会话图谱集成。 |
历史第一方预览插件已停用,改用 Harness 官方预览。[dashi-taskboard](https://github.com/chuspeeism/dashi-taskboard) 曾参与评估,现已移除,Runtime 不再包含 Taskboard。上游版权和第三方许可证仍归各项目所有。
## 从源码构建
-需要 macOS arm64、Node.js 24+ 和 pnpm 11.19.0。仓库中的补丁可以从固定的 Harness 提交还原本版确切源码,不依赖用户数据或预构建应用。
+需要 macOS arm64、Node.js 24+ 和 pnpm 11.19.0。bootstrap 获取固定的 Harness 源码并应用经过检查的适配契约,不依赖用户数据或预构建应用。
```sh
git clone https://github.com/Duoasa/DeepViewer.git
cd DeepViewer
pnpm install --frozen-lockfile
-git clone https://github.com/deepseek-ai/deepseek-harness upstream/deepseek-harness
-git -C upstream/deepseek-harness checkout fb2c4b9e698e30edb738bca4cf0618587db7d203
-git -C upstream/deepseek-harness apply --whitespace=nowarn ../snapshots/v0.3.3/harness.patch
+node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
pnpm --dir upstream/deepseek-harness install --frozen-lockfile
-pnpm --dir upstream/deepseek-harness run build:official
-pnpm desktop:build
+pnpm desktop:dev
```
-`pnpm desktop:build` 会为继续开发递增本地 Build 号。[源码快照说明](upstream/snapshots/v0.3.3/README.md)提供复现固定 Build 75 的 Runtime 封包、Developer ID 签名与 Apple 公证步骤。签名凭据只保存在本机钥匙串,不提交到仓库。
+`pnpm desktop:dev` 会构建并启动使用独立数据目录的 DeepViewer Dev;`pnpm desktop:dev:restart` 重启本项目开发进程,`pnpm desktop:build` 仅构建、不启动。开发构建会递增本地 Build 号。
+
+基础验证使用 `pnpm typecheck` 和 `pnpm desktop:smoke`,原生集成及适配契约检查见 [CI 流程](.github/workflows/ci.yml)。`pnpm desktop:preview` 制作本地预览包;`pnpm desktop:release` 准备签名 arm64 DMG/ZIP,需要设置 `DEEPVIEWER_SIGN_IDENTITY`、`DEEPVIEWER_TEAM_ID` 和 `DEEPVIEWER_NOTARY_PROFILE`,不会自动发布。签名/公证凭据留在本机,notary profile 保存在钥匙串。
+
+[0.3.3 源码快照说明](upstream/snapshots/v0.3.3/README.md)保留为 Build 75 的历史复现指南,不用于 0.5.0 构建。
[SDD 文档](docs/sdd/README.md)记录架构、插件来源、需求和验证。DeepViewer 原创代码采用 [MIT License](LICENSE);各上游许可证继续适用。
diff --git a/apps/deepviewer-adapter/README.md b/apps/deepviewer-adapter/README.md
new file mode 100644
index 0000000..70083ea
--- /dev/null
+++ b/apps/deepviewer-adapter/README.md
@@ -0,0 +1,72 @@
+# DeepViewer native adapter
+
+DeepViewer 0.5.0 uses DSH 0.2.0-rc.2 (`639ed015397290b3745d163aafe02ffee4aa3f84`) and its Electron/private DesktopHost. The upstream checkout is disposable build input. Product adaptations live here; the old 0.1.5 whole-file overlays are not replayed.
+
+| Directory | Ownership |
+| --- | --- |
+| `desktop/` | Identity, data roots, Dock theme, system proxy/PAC and private-network authorization |
+| `client/` | Branding/About, welcome mascot/font, Work/Chat and native-sidebar editor/Git tabs |
+| `workflows/` | Chat isolation, generated-file delivery policy, workspace operations |
+| `migrations/` | Profile conversion, version-bound kernel backup and allowlisted frontend state |
+| `compatibility/` | Pinned native gaps, imported helper files and regression tests |
+| `scripts/` | Validate contracts before modifying disposable upstream sources; stage native bundles |
+
+Use native filesystem opening/reveal, document/browser previews, delivery cards, change review, PTY/browser/sidebar state, process folding and statistics. Better Sidebar and Taskboard are not runtime dependencies. The Git helper retains its upstream MIT attribution; editor libraries retain their licenses.
+
+The adapter, subscriptions and model capabilities are application-owned native modules. The shipped Web application composition owns their rows and client settings registrations. Generated CLI metadata owns their dependencies; the upstream installation manifest and lockfile stay unchanged. Integrated manifests omit `dsh.bundle`, and user profiles contain neither their dependencies nor their bundle selections. They have no separate cards, enable switches or uninstall controls in Plugins. Standalone source packages retain their original plugin packaging.
+
+## Reproduce from the repository root
+
+Use Node 24 and the root's pinned pnpm. `bootstrap-native-upstream.mjs` checks the tag/commit and anchors without resetting unknown edits. Install the upstream's pinned dependencies after bootstrap.
+
+```sh
+pnpm install --frozen-lockfile
+node apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
+pnpm --dir upstream/deepseek-harness install --frozen-lockfile
+pnpm desktop:build
+pnpm typecheck
+pnpm desktop:smoke
+pnpm --filter @deepviewer/desktop smoke:source
+pnpm --filter @deepviewer/desktop smoke:contracts
+pnpm --filter @deepviewer/desktop smoke:plugins
+pnpm --filter @deepviewer/desktop smoke:native
+pnpm --filter @deepviewer/desktop smoke:payload
+```
+
+`smoke:native` uses private homes, fake APIs and local update feeds. `smoke:payload` materializes the production dependency closure and creates a temporary ASAR; it checks actual native loading and Office conversion without creating a distributable app or DMG. Source maps, development links and other-platform runtime packages are excluded. `smoke:source` applies all 106 contracts twice to a fresh checkout and rejects Agent-loop patches.
+
+## Compatibility removal conditions
+
+Every anchor is named and explains its purpose. A changed commit or anchor stops preparation before any source write. Preserve the matching tests when upstream provides an equivalent seam, then remove the contract family and its copied helper together.
+
+| Contract family | Remove when the pinned upstream provides | Verification |
+| --- | --- | --- |
+| Dedicated RPC ownership | Explicit consumer-owned channel lifetime under Cordis 4 | RPC owner unload regression; authenticated Host/plugin smoke |
+| Work/Chat creation, cold list/history/search and uploads | Cwd-free Chat preset authorization, persisted preset classification and mode-filtered search | Native Host create/prompt/restart/page/search and workspace rejection; attachment fixtures |
+| Delivery preparation and source aliases | Hook to normalize generated files before native presentation | Delivery containment and producer-contract fixtures |
+| Branding/locales, hero layout and stats slot | Product identity/locales/layout hooks | Native renderer smoke; visual acceptance remains manual |
+| Protection tones | Structured warning tone for stale/unread filesystem guards | Native ToolRow 11-case regression |
+| Desktop port/network readiness/web fetch | Product-owned launch/network policy seam | Native Host auth/shutdown and PAC/local-access fixtures |
+| Update channel/version policy | Product-selected feed channel and release identity | Native updater tests and real HTTP Range reconstruction |
+| External client bundling | Supported native module-table builder for external manifests | Native adapter/model/subscriptions builds |
+| Native DeepViewer composition | Application-owned native-module composition and diagnostic disable hook | Host inventory/RPC/recovery, source contracts and staged manifest fixtures |
+
+No contract changes the Agent loop. Native slots and services own sidebar state; DeepViewer owns only the added editor state and product mode selection. Editor saves reject stale content and retain typing made while a save is in flight. Git stage/unstage accepts one selected changed file and only discovered repositories inside the Session workspace.
+
+## Data and recovery
+
+Stable DeepViewer and DeepViewer Dev keep their existing independent roots. Explicit `DEEPVIEWER_DEV_USER_DATA` creates an isolated test profile. Scoder, Saidex and official DSH are not migration sources. Before the new kernel opens durable stores, DeepViewer snapshots its own home into private `migration-backups/kernel-v020` with SHA-256 verification; node_modules, locks and caches are excluded. Incomplete copies are never declared complete. `restoreKernelSnapshot(home, destination)` restores to a new separate home and refuses existing destinations, preserving newer sessions.
+
+Profile conversion backs up metadata before removing retired bundles. The native-module checkpoint uses private `migration-backups/builtins-v1` metadata and keeps its previous migration journal. It removes the three modules' old dependency and bundle declarations, prunes their root lockfile entries and removes only unchanged application-owned links. Provider configuration and prior explicit disable selections become native row overrides. Completion follows successful writes; retry reads the backed-up selection. `rollbackDesktopProfile` restores the previous metadata/journal and owned links without touching accounts or sessions.
+
+Official recovery disables external plugins while DeepViewer's native modules remain available. `DEEPVIEWER_DISABLE_BUILTINS=1` temporarily starts pure native core and wins over explicit enable overrides. Existing subscriptions/model disable flags remain supported; those individual switches persist row disable overrides. Re-enable by removing the diagnostic flag and the relevant `disabled: true` override in the desktop profile, rather than through Plugins. Frontend migration reads only recorded localhost origins from DeepViewer's own logs, backs up allowlisted drafts/view state and imports missing values into `dsh-app://app/`. It does not import browser credentials or overwrite new-origin drafts.
+
+## Distribution and update qualification
+
+The local update harness verifies consecutive/skipped releases, differential byte ranges, missing cache/no-Range/corrupt-blockmap fallback, SHA-512 rejection and HTTP failures. It does not invoke installation. Native task/Host shutdown coordination is retained. No signed application modules are overwritten in place.
+
+`package-native.mjs --release` requires explicit signing identity/team/keychain notary profile, signs the native runtime, seals its inventory, checks the actual ASAR and notarizes the app and DMG. It builds ZIP/blockmap as the update transport and DMG for installation/recovery; it never publishes. Signed installation, data preservation and relaunch must still be qualified on real macOS installations before publication.
+
+The signed output directory's `qualification.json` must bind the feed channel/version and ZIP SHA-512, with schemaVersion 1 and all scenarios true: `consecutive`, `skipped`, `fullDownload`, `differentialFallback`, `busyTaskDeferral`, `signedInstall`, `userDataPreserved`, `relaunch`. This is a release evidence record, not a substitute for running those scenarios. `publish-native-update.mjs --publish` rejects missing/stale qualification, verifies the ZIP's actual signed product/version, requires an intentionally provisioned channel Release, uploads immutable ZIP/blockmaps without replacement, and uploads the mutable feed last.
+
+Stable uses `updates-stable/latest-mac.yml`; preview uses `updates-preview/preview-mac.yml`, with independent updater caches. Public releases increment semver; internal Build changes alone are not public update versions. This milestone has not published those endpoints.
diff --git a/apps/deepviewer-adapter/UPSTREAM-LICENSE b/apps/deepviewer-adapter/UPSTREAM-LICENSE
new file mode 100644
index 0000000..c1f7a78
--- /dev/null
+++ b/apps/deepviewer-adapter/UPSTREAM-LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepSeek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg b/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg
new file mode 100644
index 0000000..67c8e83
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/deepviewer-loading-logo.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/deepviewer-adapter/assets/fonts/NOTICE.txt b/apps/deepviewer-adapter/assets/fonts/NOTICE.txt
new file mode 100644
index 0000000..12064d0
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/fonts/NOTICE.txt
@@ -0,0 +1,4 @@
+Smiley Sans (得意黑) 2.0.1 — atelierAnchor
+https://github.com/atelier-anchor/smiley-sans
+Unmodified release asset: SmileySans-Oblique.ttf.woff2 (stored as SmileySans-Oblique.woff2).
+License: SIL Open Font License 1.1; see OFL-SmileySans.txt in this directory.
diff --git a/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt b/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt
new file mode 100644
index 0000000..3eda391
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/fonts/OFL-SmileySans.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2022--2024, atelierAnchor ,
+with Reserved Font Name and <得意黑>.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2 b/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2
new file mode 100644
index 0000000..0da5a7f
Binary files /dev/null and b/apps/deepviewer-adapter/assets/fonts/SmileySans-Oblique.woff2 differ
diff --git a/apps/deepviewer-adapter/assets/icon-dark.png b/apps/deepviewer-adapter/assets/icon-dark.png
new file mode 100644
index 0000000..13da831
Binary files /dev/null and b/apps/deepviewer-adapter/assets/icon-dark.png differ
diff --git a/apps/deepviewer-adapter/assets/icon-light.png b/apps/deepviewer-adapter/assets/icon-light.png
new file mode 100644
index 0000000..13da831
Binary files /dev/null and b/apps/deepviewer-adapter/assets/icon-light.png differ
diff --git a/apps/deepviewer-adapter/assets/synapse/LICENSE b/apps/deepviewer-adapter/assets/synapse/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/synapse/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/deepviewer-adapter/assets/synapse/app.js b/apps/deepviewer-adapter/assets/synapse/app.js
new file mode 100644
index 0000000..ef3a9dd
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/synapse/app.js
@@ -0,0 +1,1765 @@
+const app = document.querySelector('#app')
+if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
+const UI_STATE_KEY = 'deepviewer:synapse:view:v1'
+const savedBranchAnchors = []
+const savedCardPositions = []
+const savedCollapsedCards = []
+let locale = 'zh-CN'
+const copy = {
+ map: ['地图', 'Map'], detail: ['详情', 'Details'], compare: ['对比', 'Compare'],
+ arrange: ['整理', 'Arrange'], locate: ['定位', 'Locate'], zoomOut: ['缩小', 'Zoom out'], zoomIn: ['放大', 'Zoom in'],
+ mapViews: ['会话地图视图', 'Map views'], arrangeHint: ['整理节点', 'Arrange cards'], locateHint: ['定位到当前会话', 'Locate current conversation'],
+ sourceSession: ['DeepViewer 会话', 'DeepViewer conversation'], sourceBranch: ['DeepViewer 分支', 'DeepViewer branch'], followup: ['追问', 'Follow up'],
+ addFollowup: ['添加追问', 'Add follow-up'], expandChildren: ['展开后续对话', 'Expand following turns'], collapseChildren: ['折叠后续对话', 'Collapse following turns'],
+ branchNew: ['在新对话中分支', 'Branch into a new conversation'], dragCard: ['拖动卡片', 'Drag card'], drag: ['拖动', 'Drag'],
+ viewConversation: ['查看完整会话', 'View conversation'], failed: ['失败', 'Failed'], tools: ['工具', 'Tools'], waiting: ['等待助手回复', 'Waiting for a reply'],
+ replying: ['正在回复', 'Replying'], turnFailed: ['本轮失败', 'Turn failed'], openNative: ['在 DeepViewer 中打开', 'Open in DeepViewer'],
+ hideConversation: ['从地图隐藏此会话', 'Hide conversation from map'], hide: ['隐藏', 'Hide'], close: ['关闭', 'Close'],
+ emptyMap: ['当前会话还没有可展示的对话。', 'There are no turns to show yet.'], emptyMapHelp: ['使用下方输入框开始对话,发送后会自动显示在地图中。', 'Start a conversation in the composer below. New turns appear on the map.'],
+ processFallback: ['工具调用记录', 'Tool call record'], you: ['你', 'You'], error: ['错误', 'Error'], record: ['记录', 'Record'], branch: ['分支', 'Branch'],
+ branchAnswer: ['从此回答创建分支', 'Branch from this reply'], collapseProcess: ['收起过程记录', 'Collapse process records'], expandProcess: ['展开过程记录', 'Expand process records'],
+ collapse: ['收起', 'Collapse'], expand: ['展开', 'Expand'], waitingResult: ['等待结果', 'Waiting for result'], done: ['完成', 'Done'], process: ['过程记录', 'Process records'],
+ toolResult: ['工具结果', 'Tool result'], incomplete: ['本轮未完成', 'This turn is incomplete'], continueFollowup: ['继续追问', 'Continue conversation'],
+ createBranch: ['创建分支', 'Create branch'], cardDetails: ['卡片详情', 'Card details'], closeCardDetails: ['关闭卡片详情', 'Close card details'],
+ session: ['会话', 'Conversation'], openNativeHint: ['在原生对话中打开此会话', 'Open this conversation in the native view'], branchLatest: ['基于最新回答创建分支', 'Branch from the latest reply'],
+ emptyDetail: ['等待这条会话的第一条消息。', 'Waiting for the first message.'], waitQuestion: ['等待用户提问', 'Waiting for a question'],
+ selectionFollowup: ['基于所选内容创建追问', 'Follow up on the selected text'], selectionHint: ['基于所选内容追问', 'Ask about the selected text'],
+ noSource: ['该节点没有关联的 DeepViewer 会话', 'This card has no linked DeepViewer conversation'], standalone: ['请从 DeepViewer 的会话地图中操作', 'Open the conversation map in DeepViewer'],
+ timeout: ['DeepViewer 未在规定时间内响应', 'DeepViewer did not respond in time'], activeCollapsed: ['当前会话位于这个后续分支中,请先切换会话', 'Switch conversations before collapsing the active branch'],
+ waitBranch: ['请等待这张卡片的最终回答后再创建分支', 'Wait for the final reply before creating a branch'],
+ compareEmpty: ['选择两张卡片进行对比', 'Select two cards to compare'], compareHelp: ['在地图卡片下方点击“对比”,可以并排查看两个回答。', 'Choose Compare on two map cards to read their replies side by side.'],
+ attachmentMessage: ['附件消息', 'Attachment message'],
+ cardActions: ['继续或创建分支', 'Continue or create a branch'], continueSession: ['继续当前会话', 'Continue this conversation'],
+ continueHint: ['切到此会话的最新位置,在下方输入框继续。', 'Switch to the latest turn in this conversation and use the composer below.'],
+ branchFromReply: ['从此回答创建分支', 'Branch from this reply'], branchHint: ['保留到此回答的上下文,在新会话中继续。', 'Keep the context through this reply and continue in a new conversation.'],
+ preparingDraft: ['正在准备输入框…', 'Preparing the composer…'], draftPrepared: ['已切到此会话,请在下方输入框继续。', 'This conversation is ready. Continue in the composer below.'],
+ branchPrepared: ['新分支已准备,请在下方输入框开始。', 'The new branch is ready. Start in the composer below.'],
+ compareSelected: ['已加入对比', 'Selected for comparison'], clearCompare: ['清除对比', 'Clear comparison'],
+}
+const t = (key, ...values) => {
+ const value = copy[key]?.[locale === 'en-US' ? 1 : 0] ?? key
+ return values.reduce((text, item, index) => text.replaceAll(`{${index}}`, String(item)), value)
+}
+const turnLabel = value => locale === 'en-US' ? `Turn ${value}` : `第 ${value} 轮`
+const hideConfirm = title => locale === 'en-US'
+ ? `Hide “${title}” and its branches from the map? The original DeepViewer conversations remain available.`
+ : `从地图隐藏「${title}」及其分支?DeepViewer 原会话会保留,可继续查看。`
+const CARD_WIDTH = 310
+const CARD_HEIGHT = 276
+const CARD_GAP_Y = 42
+const CAMERA_INSET_X = 56
+const CAMERA_INSET_Y = 56
+// Cards outside the viewport (plus this world-space margin) are not mounted
+// into the DOM; the margin pre-mounts cards just before they scroll into view
+// so panning never flashes empty space.
+const VIEWPORT_MARGIN = 1400
+const state = {
+ summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, compareCardIds: [],
+ dshWorkspaces: [], selectedDshWorkspaceId: null,
+ historyBySession: new Map(), historyRequests: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
+ error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards),
+ dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 },
+ expandedMessageIds: new Set(),
+ canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
+ detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
+ inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
+ actionMenuCardId: null, composePending: false, notice: '', pointerPressed: false, renderDeferred: false,
+}
+
+const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]))
+const formatTime = value => new Date(value).toLocaleString(locale, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
+
+function viewStateKey(sessionId = state.currentDsh?.id) {
+ return typeof sessionId === 'string' ? `${UI_STATE_KEY}:${sessionId}` : null
+}
+function persistViewState(sessionId = state.currentDsh?.id, overrides = {}) {
+ const key = viewStateKey(sessionId)
+ if (key === null) return
+ const detail = app.querySelector('.detail-scroll')
+ if (detail instanceof HTMLElement && state.activeId !== null) state.detailScrollByThread.set(state.activeId, detail.scrollTop)
+ const inspector = app.querySelector('.card-inspector-scroll')
+ if (inspector instanceof HTMLElement && state.inspectorCardId !== null) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
+ // Store geometry and identifiers only. Messages, titles and drafts stay in the native session store.
+ const snapshot = { camera: state.canvasCamera, initialized: state.canvasViewInitialized, zoom: state.zoom,
+ mode: state.mode, activeId: state.activeId, selectedCardId: state.selectedCardId, inspectorCardId: state.inspectorCardId,
+ compareCardIds: state.compareCardIds, branchAnchors: [...state.branchAnchors], cardPositions: [...state.cardPositions],
+ collapsedCardIds: [...state.collapsedCardIds], expandedMessageIds: [...state.expandedMessageIds],
+ detailScroll: [...state.detailScrollByThread], inspectorScroll: [...state.inspectorScrollByCard], ...overrides }
+ try { sessionStorage.setItem(key, JSON.stringify(snapshot)) } catch { /* Storage may be disabled. */ }
+}
+function restoreViewState(sessionId) {
+ const key = viewStateKey(sessionId)
+ let saved
+ try { saved = key === null ? null : JSON.parse(sessionStorage.getItem(key) ?? 'null') } catch { saved = null }
+ const validId = value => typeof value === 'string' && value.length <= 512
+ const idList = values => Array.isArray(values) ? values.filter(validId).slice(0, 10000) : []
+ const positionList = values => Array.isArray(values) ? values.filter(item => Array.isArray(item) && validId(item[0]) && Number.isFinite(item[1]?.x) && Number.isFinite(item[1]?.y)).slice(0, 10000) : []
+ const scrollList = values => Array.isArray(values) ? values.filter(item => Array.isArray(item) && validId(item[0]) && Number.isFinite(item[1]) && item[1] >= 0).slice(0, 10000) : []
+ state.mode = ['canvas', 'thread', 'compare'].includes(saved?.mode) ? saved.mode : 'canvas'
+ state.zoom = Number.isFinite(saved?.zoom) ? Math.min(4, Math.max(.6, saved.zoom)) : 1
+ state.canvasCamera = Number.isFinite(saved?.camera?.x) && Number.isFinite(saved?.camera?.y) ? saved.camera : { x: 0, y: 0 }
+ state.canvasViewInitialized = saved?.initialized === true
+ state.activeId = validId(saved?.activeId) ? saved.activeId : null
+ state.selectedCardId = validId(saved?.selectedCardId) ? saved.selectedCardId : null
+ state.inspectorCardId = validId(saved?.inspectorCardId) ? saved.inspectorCardId : null
+ state.inspectorOpening = false
+ state.detailTargetCardId = validId(saved?.detailTargetCardId) ? saved.detailTargetCardId : null
+ state.compareCardIds = idList(saved?.compareCardIds).slice(0, 2)
+ state.cardPositions = new Map(positionList(saved?.cardPositions))
+ state.collapsedCardIds = new Set(idList(saved?.collapsedCardIds))
+ state.expandedMessageIds = new Set(idList(saved?.expandedMessageIds))
+ state.branchAnchors = new Map(Array.isArray(saved?.branchAnchors) ? saved.branchAnchors.filter(item => Array.isArray(item) && item.every(validId)).slice(0, 10000) : [])
+ state.detailScrollByThread = new Map(scrollList(saved?.detailScroll))
+ state.inspectorScrollByCard = new Map(scrollList(saved?.inspectorScroll))
+ return saved !== null
+}
+function rememberBranchAnchor(sessionId, cardId) { state.branchAnchors.set(sessionId, cardId); persistViewState() }
+function persistCardPositions() { persistViewState() }
+function persistCollapsedCards() { persistViewState() }
+window.addEventListener('pagehide', () => persistViewState())
+app.addEventListener('scroll', () => persistViewState(), true)
+
+function rememberCardPosition(cardId, position, aliases = []) {
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
+ persistCardPositions()
+}
+
+function resetCardPositions() { state.cardPositions.clear(); persistViewState() }
+
+function resetCanvasCamera() {
+ state.canvasViewInitialized = false
+ state.canvasCamera = { x: 0, y: 0 }
+}
+
+async function api(path, options = {}) {
+ return dshRpc('synapse:api', { path, method: options.method ?? 'GET', body: options.body });
+}
+
+function post(type, payload = {}) {
+ if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
+}
+
+function dshRpc(type, payload = {}) {
+ if (window.parent === window) return Promise.reject(new Error(t('standalone')))
+ const requestId = crypto.randomUUID()
+ post(type, { requestId, ...payload })
+ return new Promise((resolve, reject) => {
+ const timer = window.setTimeout(() => {
+ state.pendingRpc.delete(requestId)
+ reject(new Error(t('timeout')))
+ }, 20_000)
+ state.pendingRpc.set(requestId, { resolve, reject, timer })
+ })
+}
+
+function settleRpc(requestId, value, error) {
+ const pending = state.pendingRpc.get(requestId)
+ if (pending === undefined) return
+ state.pendingRpc.delete(requestId)
+ window.clearTimeout(pending.timer)
+ if (error === undefined) pending.resolve(value)
+ else pending.reject(error instanceof Error ? error : new Error(String(error)))
+}
+
+function setError(error = '') { state.error = error instanceof Error ? error.message : error; state.notice = ''; render() }
+
+async function loadThreadHistory() {}
+
+function canReplaceView() {
+ return !state.pointerPressed && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
+}
+
+function deferCanvasRefresh(delay = 700) {
+ state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
+}
+
+function currentDshWorkspace() {
+ const id = state.currentDsh?.id
+ return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
+}
+
+function selectedDshWorkspace() {
+ return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
+}
+
+function currentDshThread(threads = state.workspace?.threads ?? []) {
+ const id = state.currentDsh?.id
+ return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
+}
+
+async function threadsForDshWorkspace(workspace) {
+ if (workspace.sessionIds.length === 0) return []
+ const requested = new Set(workspace.sessionIds)
+ const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
+ return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
+}
+
+async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
+ const workspace = state.dshWorkspaces.find(item => item.id === id)
+ if (workspace === undefined) return false
+ const load = ++state.workspaceLoad
+ state.selectedDshWorkspaceId = id
+ const threads = await threadsForDshWorkspace(workspace)
+ if (load !== state.workspaceLoad) return true
+ const nextWorkspaceId = `dsh:${workspace.id}`
+ if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera && !state.canvasViewInitialized) resetCanvasCamera()
+ state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
+ const currentThread = currentDshThread(state.workspace.threads)
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : currentThread?.id ?? state.workspace.threads[0]?.id ?? null
+ if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
+ if (renderAfter && canReplaceView()) render()
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
+ return true
+}
+
+async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
+ const workspace = currentDshWorkspace()
+ if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
+ return openDshWorkspace(workspace.id, { preserveCanvasCamera })
+}
+
+async function refreshSummaries({ renderAfter = true } = {}) {
+ const before = JSON.stringify(state.summaries)
+ const body = await api('/synapse/api/workspaces')
+ state.summaries = body.workspaces
+ const changed = before !== JSON.stringify(state.summaries)
+ const current = state.workspace?.id
+ if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
+ const selected = selectedDshWorkspace()
+ if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
+ else if (state.workspace === null && state.currentDsh !== null) {
+ const summary = state.summaries.find(item => Array.isArray(item.sessionIds) && item.sessionIds.includes(state.currentDsh.id))
+ if (summary !== undefined) await openWorkspace(summary.id)
+ }
+ else if (renderAfter && changed && canReplaceView()) render()
+ return changed
+}
+
+async function openWorkspace(id, { renderAfter = true } = {}) {
+ const load = ++state.workspaceLoad
+ const body = await api(`/synapse/api/workspaces/${id}`)
+ if (load !== state.workspaceLoad) return
+ if (state.workspace?.id !== body.workspace.id && !state.canvasViewInitialized) resetCanvasCamera()
+ state.workspace = body.workspace
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
+ if (renderAfter && canReplaceView()) render()
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
+}
+
+let projectionRequest = null
+let projectionDirty = false
+function refreshProjection({ force = false } = {}) {
+ // Native turn events can precede a summary revision. Keep the detail refresh
+ // pending through pointer/drag gestures, and coalesce streaming notifications.
+ projectionDirty ||= force
+ if (projectionRequest !== null) return projectionRequest
+ projectionRequest = (async () => {
+ const summariesChanged = await refreshSummaries({ renderAfter: false })
+ projectionDirty ||= summariesChanged
+ if (!projectionDirty || state.workspace === null || !canReplaceView()) return summariesChanged
+ projectionDirty = false
+ if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
+ else await openWorkspace(state.workspace.id)
+ projectionDirty ||= !canReplaceView() || [...state.liveReplies].some(([sessionId, live]) => {
+ if (!Number.isSafeInteger(live.userSeq)) return false
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
+ return thread === undefined || !persistedMessagesFor(thread).some(message => message.kind === 'user' && message.sourceSeq === live.userSeq)
+ })
+ return true
+ })().catch(error => { projectionDirty = true; throw error }).finally(() => { projectionRequest = null })
+ return projectionRequest
+}
+
+async function archiveThread(thread) {
+ if (!window.confirm(hideConfirm(thread.title))) return
+ await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
+ state.historyBySession.delete(thread.dshSessionId)
+ state.detailScrollByThread.delete(thread.id)
+ state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
+ if (state.workspace !== null) {
+ const removed = new Set([thread.id])
+ for (let changed = true; changed;) {
+ changed = false
+ for (const item of state.workspace.threads) {
+ if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
+ removed.add(item.id)
+ changed = true
+ }
+ }
+ }
+ state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
+ for (const key of [...state.cardPositions.keys()]) {
+ if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
+ }
+ let collapsedChanged = false
+ for (const key of [...state.collapsedCardIds]) {
+ if ([...removed].some(id => key.startsWith(`${id}:`))) {
+ state.collapsedCardIds.delete(key)
+ collapsedChanged = true
+ }
+ }
+ if (collapsedChanged) persistCollapsedCards()
+ state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
+ ? state.activeId
+ : state.workspace.threads[0]?.id ?? null
+ render()
+ } else {
+ state.activeId = null
+ }
+ await refreshSummaries()
+}
+
+async function composeNative(parent, { text, atSeq, fork = false, anchorId } = {}) {
+ if (state.composePending) return
+ if (typeof parent.dshSessionId !== 'string') throw new Error(t('noSource'))
+ state.composePending = true
+ state.error = ''
+ state.notice = ''
+ updateComposeControls()
+ persistViewState()
+ try {
+ const result = await dshRpc('synapse:compose', { sessionId: parent.dshSessionId, text, atSeq, fork, anchorId })
+ if (fork && typeof result?.id === 'string') {
+ if (anchorId !== undefined) rememberBranchAnchor(result.id, anchorId)
+ persistViewState(result.id)
+ }
+ closeCardActionMenu()
+ state.notice = t(fork ? 'branchPrepared' : 'draftPrepared')
+ updateComposeNotice()
+ } finally {
+ state.composePending = false
+ updateComposeControls()
+ }
+}
+function openContinue(parent, anchorId = undefined, text = undefined) { return composeNative(parent, { text, anchorId }) }
+function openBranch(parent, atSeq = undefined, anchorId = undefined) { return composeNative(parent, { atSeq, fork: true, anchorId }) }
+
+function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
+function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
+
+function messagesFor(thread) {
+ // Host projection owns source filtering. A user may intentionally quote internal-looking text.
+ const messages = persistedMessagesFor(thread)
+ const live = state.liveReplies.get(thread.dshSessionId)
+ if (live?.running !== true || !Number.isSafeInteger(live.userSeq) || !messages.some(message => message.kind === 'user' && message.sourceSeq === live.userSeq)) return messages
+ const index = messages.findIndex(message => message.kind === 'user' && message.sourceSeq === live.userSeq)
+ const next = messages.findIndex((message, offset) => offset > index && message.kind === 'user')
+ const end = next === -1 ? messages.length : next
+ return [...messages.slice(0, end), { kind: 'assistant', text: live.text, pending: true, at: new Date().toISOString() }, ...messages.slice(end)]
+}
+
+function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
+function messageText(message) {
+ return message?.kind === 'user' && message.deepviewerAttachmentOnly === true ? t('attachmentMessage') : message?.text ?? ''
+}
+function questionFor(thread) {
+ const question = latestMessage(thread, 'user')
+ return question === undefined ? thread.dshSessionTitle ?? t('waitQuestion') : messageText(question)
+}
+function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
+
+function inlineMarkdown(text) {
+ return escapeHtml(text)
+ .replace(/`([^`\n]+)`/g, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ .replace(/~~([^~]+)~~/g, '$1 ')
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ')
+}
+
+const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
+
+const isTableDelimiter = line => {
+ const cells = tableCells(line)
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
+}
+
+function markdownBlock(text) {
+ const lines = text.split('\n')
+ const output = []
+ for (let index = 0; index < lines.length;) {
+ const line = lines[index]
+ if (line.trim() === '') { index++; continue }
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
+ if (heading !== null) {
+ const level = heading[1].length
+ output.push(`${inlineMarkdown(heading[2])} `)
+ index++
+ continue
+ }
+ const unordered = /^[-*+]\s+(.+)$/.exec(line)
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
+ if (unordered !== null || ordered !== null) {
+ const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
+ const items = []
+ while (index < lines.length) {
+ const item = matcher.exec(lines[index])
+ if (item === null) break
+ items.push(`${inlineMarkdown(item[1])} `)
+ index++
+ }
+ output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}${unordered === null ? 'ol' : 'ul'}>`)
+ continue
+ }
+ // GFM table: a leading-pipe header row followed by a |-delimiter row,
+ // then any number of leading-pipe body rows.
+ if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
+ const header = line
+ const body = []
+ index += 2
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
+ body.push(lines[index])
+ index++
+ }
+ output.push(`${tableCells(header).map(cell => `${inlineMarkdown(cell)} `).join('')} ${body.map(row => `${tableCells(row).map(cell => `${inlineMarkdown(cell)} `).join('')} `).join('')}
`)
+ continue
+ }
+ const paragraph = []
+ while (index < lines.length && lines[index].trim() !== '' && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index]) && !/^\d+[.)]\s+/.test(lines[index])) paragraph.push(lines[index++])
+ // A marker-only line such as PowerShell's "+ " diagnostic is neither a
+ // list item nor paragraph content under the rules above. Consume it so
+ // the parser always makes progress.
+ if (paragraph.length === 0) paragraph.push(lines[index++])
+ output.push(`${paragraph.map(inlineMarkdown).join(' ')}
`)
+ }
+ return output.join('')
+}
+
+// Markdown parsing is pure CPU and repeats for every card on every canvas
+// rebuild; cache the rendered HTML by input text so stable answers are never
+// re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
+const markdownCache = new Map()
+const MARKDOWN_CACHE_LIMIT = 5000
+function renderMarkdown(text) {
+ const key = String(text)
+ const cached = markdownCache.get(key)
+ if (cached !== undefined) return cached
+ const parts = key.split(/```/)
+ const rendered = parts.map((part, index) => index % 2 === 1
+ ? `${escapeHtml(part.replace(/^\w*\n/, ''))} `
+ : markdownBlock(part)).join('')
+ if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
+ markdownCache.set(key, rendered)
+ return rendered
+}
+
+function overlapsCard(position, other) {
+ return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
+ && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
+}
+
+function firstAvailableCardPosition(position, occupied) {
+ const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
+ while (true) {
+ const collisions = occupied.filter(other => overlapsCard(candidate, other))
+ if (collisions.length === 0) return candidate
+ candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
+ }
+}
+
+function connectorPath(fromPosition, toPosition) {
+ const fromX = fromPosition.x + CARD_WIDTH
+ const fromY = fromPosition.y + CARD_HEIGHT / 2
+ const toX = toPosition.x
+ const toY = toPosition.y + CARD_HEIGHT / 2
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
+}
+
+function connectorPathFromElements(fromCard, toCard) {
+ const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
+ const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
+ const toX = Number.parseFloat(toCard.style.left)
+ const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
+ if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
+}
+
+function selectorValue(value) {
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+}
+
+// Connector paths are rebuilt together with the canvas DOM; cache the mapping
+// from card id to its incident paths so dragging never scans the whole SVG.
+let connectorPathsByCard = new Map()
+function cacheCardConnectors() {
+ connectorPathsByCard = new Map()
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ for (const path of viewport.querySelectorAll('.connectors path[data-from]')) {
+ const fromId = path.getAttribute('data-from')
+ const toId = path.getAttribute('data-to')
+ if (fromId === null || toId === null) continue
+ for (const id of [fromId, toId]) {
+ const paths = connectorPathsByCard.get(id)
+ if (paths === undefined) connectorPathsByCard.set(id, new Set([path]))
+ else paths.add(path)
+ }
+ }
+}
+
+function refreshCardConnectors(cardId) {
+ const paths = connectorPathsByCard.get(cardId)
+ if (paths === undefined || paths.size === 0) return
+ const byId = state.canvasCardsById
+ if (byId === undefined) return
+ for (const path of paths) {
+ const fromId = path.getAttribute('data-from')
+ const toId = path.getAttribute('data-to')
+ if (fromId === null || toId === null) continue
+ const fromCard = byId.get(fromId)
+ const toCard = byId.get(toId)
+ if (fromCard === undefined || toCard === undefined) continue
+ // Data-driven endpoints: the counterpart card may be unmounted (outside
+ // the viewport) but its position is still authoritative.
+ path.setAttribute('d', connectorPath(fromCard.position, toCard.position))
+ }
+}
+
+function initialCanvasCamera(cards) {
+ // The native composer owns the current route. Local inspection is separate.
+ const route = nativeCanvasRoute(cards)
+ const focus = cards.find(card => card.id === route.currentCardId) ?? cards[0]
+ const position = focus?.position
+ if (position === undefined) return { x: 0, y: 0 }
+ return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
+}
+
+function placeConversationCards(cards) {
+ const saved = new Map(cards.flatMap(card => {
+ if (card.positionLocked !== true) return []
+ const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
+ return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
+ }))
+ const occupied = []
+ for (const card of cards) {
+ const position = saved.get(card.id)
+ if (position !== undefined) {
+ card.position = position
+ continue
+ }
+ card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
+ occupied.push(card.position)
+ }
+ return cards
+}
+
+function layoutConversationGraph(cards, threads) {
+ const childrenByThread = new Map()
+ for (const thread of threads) {
+ if (thread.parentId === null) continue
+ const children = childrenByThread.get(thread.parentId) ?? []
+ children.push(thread.id)
+ childrenByThread.set(thread.parentId, children)
+ }
+ const laneByThread = new Map()
+ const visitThread = threadId => {
+ if (laneByThread.has(threadId)) return
+ laneByThread.set(threadId, laneByThread.size)
+ for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
+ }
+ for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
+ for (const thread of threads) visitThread(thread.id)
+
+ const byId = new Map(cards.map(card => [card.id, card]))
+ const positioned = new Map()
+ const positionFor = (card, visiting = new Set()) => {
+ if (positioned.has(card.id)) return positioned.get(card.id)
+ if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
+ visiting.add(card.id)
+ const parent = card.parentId === null ? undefined : byId.get(card.parentId)
+ const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
+ const position = {
+ x: parentPosition === undefined ? 86 : parentPosition.x + 365,
+ y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
+ }
+ visiting.delete(card.id)
+ positioned.set(card.id, position)
+ return position
+ }
+ for (const card of cards) {
+ card.naturalPosition = positionFor(card)
+ if (!card.positionLocked) card.position = card.naturalPosition
+ }
+ return placeConversationCards(cards)
+}
+
+function conversationCards(threads) {
+ const cards = []
+ const cardsByThread = new Map()
+ for (const thread of threads) {
+ const messages = messagesFor(thread)
+ const turns = []
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
+ const question = messages[messageIndex]
+ if (question.kind !== 'user') continue
+ const replies = []
+ const errors = []
+ let processCount = 0
+ for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
+ const reply = messages[replyIndex]
+ if (reply.kind === 'user') break
+ if (reply.kind === 'assistant') replies.push(reply)
+ if (reply.kind === 'error') errors.push(reply)
+ if (Array.isArray(reply.process)) processCount += reply.process.length
+ else if (reply.kind === 'tool') processCount += 1
+ }
+ const answer = replies.at(-1) ?? null
+ const error = errors.at(-1) ?? null
+ const turnIndex = turns.length
+ const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
+ const previous = turns.at(-1)
+ const positionKey = `${thread.id}:turn-index:${turnIndex}`
+ const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
+ const positionLocked = savedPosition !== undefined
+ const position = positionLocked ? savedPosition : naturalPosition
+ turns.push({
+ id,
+ positionKey,
+ dshThreadId: thread.id,
+ sourceParentId: thread.parentId,
+ parentId: null,
+ sourceSeq: question.sourceSeq,
+ turnIndex,
+ naturalPosition,
+ position,
+ positionLocked,
+ question: messageText(question),
+ answer,
+ error,
+ processCount,
+ })
+ }
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
+ const latestTurn = turns.find(turn => turn.sourceSeq === liveReply?.userSeq)
+ if (liveReply?.running && Number.isSafeInteger(liveReply.userSeq) && latestTurn !== undefined && (latestTurn.answer === null || latestTurn.answer.pending === true)) latestTurn.answer = { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() }
+ if (turns.length === 0) {
+ const id = `${thread.id}:turn:empty`
+ const positionKey = `${thread.id}:turn-index:0`
+ const naturalPosition = { x: 86, y: 82 }
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
+ const positionLocked = savedPosition !== undefined
+ turns.push({
+ id,
+ positionKey,
+ dshThreadId: thread.id,
+ sourceParentId: thread.parentId,
+ parentId: null,
+ sourceSeq: undefined,
+ turnIndex: 0,
+ naturalPosition,
+ position: positionLocked ? savedPosition : naturalPosition,
+ positionLocked,
+ question: thread.dshSessionTitle ?? thread.title,
+ answer: null,
+ error: null,
+ processCount: 0,
+ })
+ }
+ turns.at(-1).canContinue = true
+ cardsByThread.set(thread.id, turns)
+ cards.push(...turns)
+ }
+ for (const card of cards) {
+ const siblings = cardsByThread.get(card.dshThreadId)
+ if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
+ else {
+ const parentCards = cardsByThread.get(card.sourceParentId)
+ const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
+ const firstChildQuestion = siblings?.[0]
+ const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
+ // A fork inherits every parent event before DeepViewer's durable seed boundary.
+ // The latest parent question below that boundary is the exact Turn where
+ // this child was born. Canvas coordinates never participate in lineage.
+ const inheritedTurn = Number.isSafeInteger(seedLength)
+ ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
+ : undefined
+ card.parentId = state.branchAnchors.get(threads.find(thread => thread.id === card.dshThreadId)?.dshSessionId) ?? state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
+ }
+ }
+ return layoutConversationGraph(cards, threads)
+}
+
+function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
+ const cardIds = new Set(cards.map(card => card.id))
+ const childrenByParent = new Map()
+ for (const card of cards) {
+ if (card.parentId === null || !cardIds.has(card.parentId)) continue
+ const children = childrenByParent.get(card.parentId) ?? []
+ children.push(card.id)
+ childrenByParent.set(card.parentId, children)
+ }
+
+ const hiddenIds = new Set()
+ for (const rootId of collapsedCardIds) {
+ if (!cardIds.has(rootId)) continue
+ const visited = new Set([rootId])
+ const visit = parentId => {
+ for (const childId of childrenByParent.get(parentId) ?? []) {
+ if (visited.has(childId)) continue
+ visited.add(childId)
+ hiddenIds.add(childId)
+ visit(childId)
+ }
+ }
+ visit(rootId)
+ }
+
+ // Persisted collapse roots must remain visible even if malformed metadata
+ // contains a cycle where two collapsed nodes otherwise hide each other.
+ for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
+
+ // Post-order accumulation: each card's descendant count is 1 + the sum of
+ // its children's subtree sizes, so the whole graph is O(n) instead of a BFS
+ // from every card (O(n²) on deep chains). Malformed parent cycles are
+ // detected through the DFS path: every member of a cycle reaches every other
+ // member plus the union of their off-cycle subtrees, so when the cycle entry
+ // pops last, all members are settled to (cycleSize - 1) + off-cycle total,
+ // which matches the per-card BFS' unique-descendant count.
+ const descendantCounts = new Map()
+ const inStack = new Set()
+ for (const card of cards) {
+ if (descendantCounts.has(card.id)) continue
+ const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }]
+ const path = [card.id]
+ let cycleEntry = null
+ let cycleMembers = null
+ let cycleOffCycleTotal = 0
+ inStack.add(card.id)
+ while (stack.length > 0) {
+ const top = stack[stack.length - 1]
+ if (top.index < top.children.length) {
+ const childId = top.children[top.index++]
+ if (descendantCounts.has(childId)) continue
+ if (inStack.has(childId)) {
+ // Back edge: the nodes from childId up to top.id form a cycle.
+ cycleEntry = childId
+ cycleMembers = new Set(path.slice(path.indexOf(childId)))
+ cycleOffCycleTotal = 0
+ continue
+ }
+ inStack.add(childId)
+ path.push(childId)
+ stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 })
+ } else {
+ stack.pop()
+ path.pop()
+ inStack.delete(top.id)
+ let count = 0
+ for (const childId of top.children) {
+ if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below
+ count += 1 + (descendantCounts.get(childId) ?? 0)
+ }
+ if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count
+ if (cycleMembers !== null && top.id === cycleEntry) {
+ // All cycle members have popped (the entry pops last in post-order);
+ // settle them so ancestors popping next read the final counts.
+ const base = cycleMembers.size - 1
+ for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal)
+ cycleEntry = null
+ cycleMembers = null
+ } else {
+ descendantCounts.set(top.id, count)
+ }
+ }
+ }
+ }
+
+ return {
+ cards: cards.filter(card => !hiddenIds.has(card.id)),
+ childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
+ descendantCounts,
+ }
+}
+
+function revealConversationThread(cards, threadId) {
+ const byId = new Map(cards.map(card => [card.id, card]))
+ let changed = false
+ for (const target of cards.filter(card => card.dshThreadId === threadId)) {
+ const visited = new Set([target.id])
+ let parentId = target.parentId
+ while (parentId !== null && !visited.has(parentId)) {
+ visited.add(parentId)
+ if (state.collapsedCardIds.delete(parentId)) changed = true
+ parentId = byId.get(parentId)?.parentId ?? null
+ }
+ }
+ if (changed) persistCollapsedCards()
+}
+
+// Derive the native route from the full graph once, never from persisted UI
+// selection. Child lineage includes the exact inherited parent turn and every
+// predecessor through the native conversation's latest turn.
+function nativeCanvasRoute(cards) {
+ const nativeThread = currentDshThread()
+ const currentCard = nativeThread === undefined ? undefined : cards.filter(card => card.dshThreadId === nativeThread.id).at(-1)
+ const lineage = new Set()
+ const byId = new Map(cards.map(card => [card.id, card]))
+ let card = currentCard
+ while (card !== undefined && !lineage.has(card.id)) {
+ lineage.add(card.id)
+ card = card.parentId === null ? undefined : byId.get(card.parentId)
+ }
+ return { currentCardId: currentCard?.id ?? null, lineage }
+}
+
+function updateNativeCanvasRoute() {
+ if (state.mode !== 'canvas' || state.canvasGraph === undefined || state.canvasCards === undefined) return
+ const route = nativeCanvasRoute(state.canvasGraph.allCards ?? state.canvasCards)
+ state.canvasGraph.nativeRoute = route
+ for (const card of app.querySelectorAll('.thread-card')) {
+ const current = card.dataset.cardId === route.currentCardId
+ card.classList.toggle('current', current)
+ if (current) card.setAttribute('aria-current', 'true')
+ else card.removeAttribute('aria-current')
+ }
+ for (const connector of app.querySelectorAll('.connectors path')) {
+ connector.classList.toggle('active-connector', route.lineage.has(connector.dataset.from) && route.lineage.has(connector.dataset.to))
+ }
+}
+
+function canvasConnectors(cards, route) {
+ const index = new Map(cards.map(card => [card.id, card]))
+ const links = cards.map(card => {
+ const parent = card.parentId === null ? null : index.get(card.parentId)
+ if (parent === undefined || parent === null) return ''
+ const active = route.lineage.has(card.id) && route.lineage.has(parent.id) ? ' active-connector' : ''
+ return ` `
+ })
+ return links.join('')
+}
+
+function conversationCard(card, graph) {
+ const selected = card.id === state.selectedCardId ? 'selected' : ''
+ const current = card.id === graph.nativeRoute?.currentCardId
+ const source = card.parentId === null ? t('sourceSession') : card.turnIndex === 0 ? t('sourceBranch') : t('followup')
+ const continueButton = card.canContinue === true
+ ? ` `
+ : ''
+ const childCount = graph.childCounts.get(card.id) ?? 0
+ const collapsed = state.collapsedCardIds.has(card.id)
+ const foldLabel = collapsed ? t('expandChildren') : t('collapseChildren')
+ const foldButton = childCount === 0 || card.canContinue === true ? '' : ` `
+ const branchButton = childCount === 0 || card.canContinue === true || !Number.isInteger(card.answer?.sourceSeq) ? '' : ` `
+ return `
+
+ ${continueButton}${foldButton}${branchButton}
+ ${escapeHtml(card.question)}
+ ${source} ${turnLabel(card.turnIndex + 1)} ${card.error === null ? '' : `${t('failed')} `}${card.processCount > 0 ? `${t('tools')} ${card.processCount} ` : ''}
+ ${card.answer === null ? (card.error === null ? `
${t('waiting')}
` : '') : card.answer.pending && card.answer.text === '' ? `
${t('replying')}
` : `${renderMarkdown(card.answer.text)}${card.answer.pending ? `
${t('replying')}
` : ''}`}${card.error === null ? '' : `
${t('turnFailed')}: ${escapeHtml(card.error.text)}
`}
+ ${t('compare')} ${t('detail')} DeepViewer ${t('hide')}
+ `
+}
+
+function selectionFollowupButton() {
+ return `${t('followup')} `
+}
+
+// Cards are mounted into the DOM only when they intersect the viewport
+// (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
+// translate(camera) scale(zoom), so screen = world * zoom + camera.
+function visibleCardIds(cards) {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
+ const bounds = viewport.getBoundingClientRect()
+ const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
+ const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
+ const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
+ const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
+ const visible = new Set()
+ for (const card of cards) {
+ const { x, y } = card.position
+ if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
+ visible.add(card.id)
+ }
+ return visible
+}
+
+// Incrementally mount cards entering the viewport and unmount cards leaving
+// it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
+function syncCanvasViewport() {
+ if (state.mode !== 'canvas' || state.canvasCards === undefined) return
+ const layer = document.querySelector('.cards-layer')
+ if (!(layer instanceof HTMLElement)) return
+ const visible = visibleCardIds(state.canvasCards)
+ for (const cardId of [...state.mountedCardIds]) {
+ if (visible.has(cardId)) continue
+ const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
+ if (element instanceof HTMLElement) element.remove()
+ state.mountedCardIds.delete(cardId)
+ }
+ for (const card of state.canvasCards) {
+ if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
+ const wrapper = document.createElement('div')
+ wrapper.innerHTML = conversationCard(card, state.canvasGraph)
+ const element = wrapper.firstElementChild
+ if (element instanceof HTMLElement) {
+ layer.appendChild(element)
+ const handle = element.querySelector('[data-drag-card]')
+ if (handle instanceof HTMLElement) bindDragHandle(handle)
+ }
+ state.mountedCardIds.add(card.id)
+ }
+}
+
+function renderCanvas() {
+ const threads = state.workspace?.threads ?? []
+ if (threads.length === 0) return `${t('emptyMap')} ${t('emptyMapHelp')}
`
+ const allCards = conversationCards(threads)
+ const graph = conversationGraphView(allCards)
+ graph.allCards = allCards
+ graph.nativeRoute = nativeCanvasRoute(allCards)
+ const cards = graph.cards
+ state.canvasCards = cards
+ state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
+ state.canvasGraph = graph
+ if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) {
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ }
+ if (!state.canvasViewInitialized) {
+ state.canvasCamera = initialCanvasCamera(cards)
+ state.canvasViewInitialized = true
+ // The viewport is not laid out yet while renderCanvas builds its HTML;
+ // center the focused card once the DOM is mounted (render tail).
+ state.canvasNeedsCenter = true
+ }
+ const visible = visibleCardIds(cards)
+ state.mountedCardIds = new Set(visible)
+ const mounted = cards.filter(card => visible.has(card.id))
+ const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
+ return `${canvasConnectors(cards, graph.nativeRoute)} ${mounted.map(card => conversationCard(card, graph)).join('')}
${inspector} `
+}
+
+function isProcessMessage(message) {
+ if (message.kind === 'tool' || message.kind === 'tool-result') return true
+ return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
+}
+
+function processSummary(text) {
+ return text.replace(/\s+/g, ' ').trim().slice(0, 140) || t('processFallback')
+}
+
+function threadMessage(thread, message) {
+ const isUser = message.kind === 'user'
+ const label = isUser ? t('you') : message.kind === 'assistant' ? 'DeepViewer' : message.kind === 'error' ? t('error') : t('record')
+ const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
+ ? ` ${t('branch')} `
+ : ''
+ const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
+ const collapsible = isProcessMessage(message)
+ const expanded = state.expandedMessageIds.has(messageId)
+ const fold = collapsible ? ` ` : ''
+ const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
+ const body = message.pending && message.text === '' ? ` ${t('replying')}
`
+ : `${collapsible && !expanded ? `${escapeHtml(processSummary(message.text))}
` : renderMarkdown(messageText(message))}${message.pending ? ` ${t('replying')}
` : ''}${process === null ? '' : processRecords(process, messageId)}`
+ const avatar = isUser ? '' : ' '
+ return `${avatar}${label} ${formatTime(message.at)} ${branch}${fold} ${body}
`
+}
+
+function processRecords(process, messageId) {
+ const key = `${messageId}:process`
+ const expanded = state.expandedMessageIds.has(key)
+ const entries = process.map((entry, index) => {
+ const entryKey = `${key}:${index}`
+ const entryExpanded = state.expandedMessageIds.has(entryKey)
+ const status = entry.error !== null ? t('failed') : entry.result === null ? t('waitingResult') : t('done')
+ const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `${escapeHtml(entry.arguments)} `
+ const outcomeHtml = entry.error !== null ? `${escapeHtml(entry.error)} ` : entry.result === null ? '' : `${escapeHtml(entry.result)} `
+ return `${escapeHtml(entry.name)} ${status} ${entryExpanded ? `
${argumentsHtml}${outcomeHtml}
` : ''}
`
+ }).join('')
+ return `${expanded ? t('collapseProcess') : t('process')} ${process.length} ${expanded ? entries : ''} `
+}
+
+function messagesForCard(card) {
+ const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId)
+ if (thread === undefined) return { thread: null, messages: [] }
+ const messages = messagesFor(thread)
+ let turnIndex = -1
+ let start = -1
+ for (let index = 0; index < messages.length; index++) {
+ if (messages[index].kind !== 'user') continue
+ turnIndex += 1
+ if (turnIndex === card.turnIndex) {
+ start = index
+ break
+ }
+ }
+ if (start === -1) return { thread, messages: [] }
+ const end = messages.findIndex((message, index) => index > start && message.kind === 'user')
+ return { thread, messages: messages.slice(start, end === -1 ? undefined : end) }
+}
+
+function inspectorProcessEntries(messages) {
+ const entries = []
+ for (const message of messages) {
+ if (Array.isArray(message.process)) {
+ entries.push(...message.process.map(entry => ({ ...entry })))
+ continue
+ }
+ if (message.kind === 'tool') {
+ entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null })
+ continue
+ }
+ if (message.kind === 'tool-result') {
+ const previous = entries.at(-1)
+ if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text
+ else entries.push({ name: t('toolResult'), arguments: null, result: message.text, error: null })
+ }
+ }
+ return entries
+}
+
+function renderCardInspector(card) {
+ if (card === undefined) return ''
+ const { thread, messages } = messagesForCard(card)
+ if (thread === null) return ''
+ const process = inspectorProcessEntries(messages)
+ const answer = card.answer === null
+ ? card.error === null ? `${t('waiting')}
` : ''
+ : `${renderMarkdown(card.answer.text)}${card.answer.pending ? `${t('replying')}
` : ''} `
+ const error = card.error === null ? '' : `${t('incomplete')} ${escapeHtml(card.error.text)}
`
+ const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`)
+ const continueAction = card.canContinue === true ? ` ${t('continueFollowup')} ` : ''
+ const branch = Number.isInteger(card.answer?.sourceSeq)
+ ? ` ${t('createBranch')} `
+ : ''
+ const openDshAction = ` ${t('openNative')} `
+ return ``
+}
+
+function renderThread() {
+ const thread = currentThread()
+ if (thread === null) return renderCanvas()
+ const messages = messagesFor(thread)
+ const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq
+ return ``
+}
+
+function renderCompare() {
+ const cards = conversationCards(state.workspace?.threads ?? [])
+ const selected = state.compareCardIds.map(id => cards.find(card => card.id === id)).filter(Boolean)
+ if (selected.length < 2) return `${t('compareEmpty')} ${t('compareHelp')}
`
+ return `${selected.map(card => `
${escapeHtml(card.question)} ${t('detail')} ${turnLabel(card.turnIndex + 1)}
${card.answer === null ? `
${t('waiting')}
` : renderMarkdown(card.answer.text)}${card.error === null ? '' : `
${escapeHtml(card.error.text)}
`}
`).join('')}
`
+}
+
+function render() {
+ // Keep the actual pressed DOM node mounted until the browser dispatches click.
+ // Native subscriptions may arrive between pointerdown and pointerup.
+ if (state.pointerPressed) { state.renderDeferred = true; return }
+ state.renderDeferred = false
+ // Remember the departing thread's scroll position per thread id, so
+ // switching sessions restores each conversation's own place instead of
+ // smearing one session's position onto another.
+ if (state.mode === 'thread' && state.detailThreadId !== null) {
+ const detail = document.querySelector('.detail-scroll')
+ if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
+ }
+ if (state.mode === 'canvas' && state.inspectorCardId !== null) {
+ const inspector = document.querySelector('.card-inspector-scroll')
+ if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
+ }
+ state.detailThreadId = state.mode === 'thread' ? state.activeId : null
+ const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
+ const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
+ const cardScrollTops = new Map()
+ if (state.mode === 'canvas') {
+ // Key by the unique card id: every card of a session shares data-thread,
+ // so keying on it would clobber sibling cards' scroll positions. Only
+ // scrollable answers have a position worth preserving; reading the two
+ // height properties shares the same forced layout as the scrollTop read.
+ for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
+ if (answer.scrollHeight <= answer.clientHeight) continue
+ const card = answer.closest('.thread-card')
+ if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
+ }
+ }
+ const workspace = state.workspace
+ const threads = workspace?.threads ?? []
+ const view = state.mode === 'thread' ? renderThread() : state.mode === 'compare' ? renderCompare() : renderCanvas()
+ const canvasControls = state.mode === 'canvas' && threads.length > 0 ? ` ${t('arrange')} ${t('locate')}${Math.round(state.zoom * 100)}% ` : ''
+ const detailAvailable = currentThread() !== null
+ const canvasTabs = `${t('map')} ${t('detail')} ${t('compare')}${state.compareCardIds.length === 0 ? '' : ` (${state.compareCardIds.length}/2)`} ${state.mode === 'compare' && state.compareCardIds.length > 0 ? `${t('clearCompare')} ` : ''} `
+ app.innerHTML = `${state.error ? `${escapeHtml(state.error)} ×
` : ''}${view}${selectionFollowupButton()}${renderCardActionMenu()}${renderComposeNotice()} `
+ persistViewState()
+ installDragging()
+ updateComposeControls()
+ positionCardActionMenu()
+ queueLayoutReport()
+ cacheCardConnectors()
+ // The initial camera from renderCanvas is inset (viewport not laid out yet);
+ // center it on the focused card once the canvas DOM is mounted.
+ if (state.canvasNeedsCenter) {
+ state.canvasNeedsCenter = false
+ window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() })
+ }
+ for (const [cardId, scrollTop] of cardScrollTops) {
+ const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
+ if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
+ }
+ if (detailScrollTop !== null) window.requestAnimationFrame(() => {
+ const nextDetail = document.querySelector('.detail-scroll')
+ if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
+ })
+ if (inspectorScrollTop !== null) window.requestAnimationFrame(() => {
+ const inspector = document.querySelector('.card-inspector-scroll')
+ if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop
+ })
+ if (state.inspectorOpening) window.requestAnimationFrame(() => {
+ document.querySelector('.card-inspector')?.classList.remove('is-opening')
+ state.inspectorOpening = false
+ })
+ // Jump the detail view to the card the user clicked: card ids carry the
+ // source sequence (`:turn:`), which matches data-message-seq
+ // anchors on the rendered messages.
+ const targetCardId = state.detailTargetCardId
+ state.detailTargetCardId = null
+ if (targetCardId !== null) {
+ const match = /:turn:(\d+)$/.exec(targetCardId)
+ const seq = match === null ? null : match[1]
+ if (seq !== null) window.requestAnimationFrame(() => {
+ const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
+ if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
+ })
+ }
+}
+
+function renderPreservingDetailScroll() {
+ render()
+}
+
+let inspectorCloseTimer = 0
+function openCardInspector(cardId) {
+ if (inspectorCloseTimer !== 0) {
+ window.clearTimeout(inspectorCloseTimer)
+ inspectorCloseTimer = 0
+ }
+ state.inspectorOpening = state.inspectorCardId === null
+ state.inspectorCardId = cardId
+}
+
+function closeCardInspector({ animate = true } = {}) {
+ if (state.inspectorCardId === null) return
+ if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer)
+ const cardId = state.inspectorCardId
+ const inspector = document.querySelector('.card-inspector')
+ if (!animate || !(inspector instanceof HTMLElement)) {
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ inspector?.remove()
+ persistViewState()
+ queueLayoutReport()
+ return
+ }
+ inspector.classList.add('is-closing')
+ inspectorCloseTimer = window.setTimeout(() => {
+ inspectorCloseTimer = 0
+ if (state.inspectorCardId !== cardId) return
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ document.querySelector('.card-inspector')?.remove()
+ persistViewState()
+ queueLayoutReport()
+ }, 180)
+}
+
+// Card inspection is local browsing. Native session navigation occurs only
+// after an explicit Continue, Branch, or Open in DeepViewer action.
+function selectCanvasCard(cardId) {
+ const card = state.canvasCardsById?.get(cardId)
+ if (card === undefined) return
+ const previousInspector = app.querySelector('.card-inspector-scroll')
+ if (previousInspector instanceof HTMLElement && state.inspectorCardId !== null) state.inspectorScrollByCard.set(state.inspectorCardId, previousInspector.scrollTop)
+ const unchanged = state.inspectorCardId === cardId
+ state.activeId = card.dshThreadId
+ state.selectedCardId = cardId
+ state.error = ''
+ app.querySelector('.status-message')?.remove()
+ for (const element of app.querySelectorAll('.thread-card')) element.classList.toggle('selected', element.dataset.cardId === cardId)
+ const detailTab = app.querySelector('.canvas-tabs [data-action="show-thread"]')
+ if (detailTab instanceof HTMLElement) detailTab.dataset.thread = card.dshThreadId
+ openCardInspector(cardId)
+ if (!unchanged || !app.querySelector('.card-inspector')) {
+ const wrapper = document.createElement('div')
+ wrapper.innerHTML = renderCardInspector(card)
+ const next = wrapper.firstElementChild
+ const previous = app.querySelector('.card-inspector')
+ if (previous) previous.replaceWith(next)
+ else app.querySelector('.canvas-view')?.append(next)
+ const scroll = next.querySelector('.card-inspector-scroll')
+ if (scroll instanceof HTMLElement) scroll.scrollTop = state.inspectorScrollByCard.get(cardId) ?? 0
+ window.requestAnimationFrame(() => { next.classList.remove('is-opening'); state.inspectorOpening = false })
+ }
+ persistViewState()
+ queueLayoutReport()
+}
+
+function renderCardActionMenu() {
+ const card = state.canvasCardsById?.get(state.actionMenuCardId)
+ if (card === undefined || state.mode !== 'canvas') return ''
+ const canBranch = Number.isInteger(card.answer?.sourceSeq) && card.answer?.pending !== true
+ return ``
+}
+function positionCardActionMenu() {
+ const menu = app.querySelector('.card-action-menu')
+ if (!(menu instanceof HTMLElement)) return
+ const anchor = app.querySelector(`[data-action="show-card-actions"][data-card="${CSS.escape(state.actionMenuCardId)}"]`)
+ if (!(anchor instanceof HTMLElement)) return closeCardActionMenu()
+ const rect = anchor.getBoundingClientRect()
+ const left = Math.min(window.innerWidth - menu.offsetWidth - 8, rect.right + 8)
+ const top = Math.min(window.innerHeight - menu.offsetHeight - 8, rect.top)
+ menu.style.left = `${Math.max(8, left)}px`
+ menu.style.top = `${Math.max(8, top)}px`
+}
+function closeCardActionMenu() {
+ state.actionMenuCardId = null
+ app.querySelector('.card-action-menu')?.remove()
+ for (const button of app.querySelectorAll('[data-action="show-card-actions"]')) button.setAttribute('aria-expanded', 'false')
+}
+function openCardActionMenu(cardId) {
+ if (state.composePending) return
+ if (state.actionMenuCardId === cardId) return closeCardActionMenu()
+ closeCardActionMenu()
+ state.actionMenuCardId = cardId
+ app.querySelector('.main-stage')?.insertAdjacentHTML('beforeend', renderCardActionMenu())
+ const anchor = app.querySelector(`[data-action="show-card-actions"][data-card="${CSS.escape(cardId)}"]`)
+ anchor?.setAttribute('aria-expanded', 'true')
+ positionCardActionMenu()
+ app.querySelector('.card-action-menu button:not(:disabled)')?.focus({ preventScroll: true })
+}
+function updateComposeControls() {
+ for (const button of app.querySelectorAll('[data-action="open-continue"], [data-action="open-branch"], [data-action="show-card-actions"], [data-action="follow-selection"]')) {
+ button.disabled = state.composePending || button.dataset.action === 'open-branch' && button.dataset.card !== undefined && !Number.isInteger(Number(button.dataset.seq || NaN))
+ }
+ const menu = app.querySelector('.card-action-menu')
+ if (menu instanceof HTMLElement) menu.setAttribute('aria-busy', String(state.composePending))
+}
+function renderComposeNotice() {
+ return state.notice ? `${escapeHtml(state.notice)} ×
` : ''
+}
+function updateComposeNotice() {
+ app.querySelector('.compose-notice')?.remove()
+ app.querySelector('.main-stage')?.insertAdjacentHTML('beforeend', renderComposeNotice())
+}
+let layoutReportFrame = 0
+let previousInspectorWidth = -1
+function queueLayoutReport() {
+ if (layoutReportFrame !== 0) return
+ layoutReportFrame = window.requestAnimationFrame(() => {
+ layoutReportFrame = 0
+ const inspector = app.querySelector('.card-inspector')
+ const inspectorWidth = window.innerWidth > 640 && inspector instanceof HTMLElement ? inspector.offsetWidth : 0
+ if (inspectorWidth === previousInspectorWidth) return
+ previousInspectorWidth = inspectorWidth
+ post('synapse:layout', { inspectorWidth })
+ })
+}
+window.addEventListener('resize', () => { queueLayoutReport(); positionCardActionMenu() })
+
+function applyCanvasTransform() {
+ const content = document.querySelector('.canvas-content')
+ if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
+ persistViewState()
+ positionCardActionMenu()
+}
+
+function bindDragHandle(handle) {
+ handle.addEventListener('pointerdown', event => {
+ const cardId = event.currentTarget.dataset.dragCard
+ const card = event.currentTarget.closest('.thread-card')
+ if (cardId === undefined || !(card instanceof HTMLElement)) return
+ event.preventDefault()
+ const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
+ const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
+ let position = origin.position
+ let stopped = false
+ let frame = 0
+ state.dragging = true
+ // Coalesce pointermove updates to one DOM pass per animation frame so a
+ // high report-rate pointer cannot queue a reflow per event.
+ const apply = () => {
+ frame = 0
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
+ // Keep the virtualized data object in sync so viewport visibility and
+ // connector paths track the live drag position.
+ const dataCard = state.canvasCardsById?.get(cardId)
+ if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y }
+ card.style.left = `${position.x}px`
+ card.style.top = `${position.y}px`
+ refreshCardConnectors(cardId)
+ }
+ const move = moveEvent => {
+ position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
+ }
+ const stop = () => {
+ if (stopped) return
+ stopped = true
+ document.removeEventListener('pointermove', move)
+ document.removeEventListener('pointerup', stop)
+ document.removeEventListener('pointercancel', stop)
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
+ apply()
+ rememberCardPosition(cardId, position, aliases)
+ state.dragging = false
+ deferCanvasRefresh(120)
+ // No full render: only the dragged card's inline position and its
+ // connectors changed; rebuilding the whole canvas on drop is the jank.
+ }
+ document.addEventListener('pointermove', move)
+ document.addEventListener('pointerup', stop)
+ document.addEventListener('pointercancel', stop)
+ })
+}
+
+function installDragging() {
+ for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
+}
+
+function canvasViewport(target) {
+ return target instanceof Element ? target.closest('.canvas-viewport') : null
+}
+
+function zoomCanvas(viewport, nextZoom, clientX, clientY) {
+ const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
+ if (zoom === state.zoom) return
+ const bounds = viewport.getBoundingClientRect()
+ const localX = clientX - bounds.left
+ const localY = clientY - bounds.top
+ const worldX = (localX - state.canvasCamera.x) / state.zoom
+ const worldY = (localY - state.canvasCamera.y) / state.zoom
+ state.zoom = zoom
+ state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
+ const content = viewport.querySelector('.canvas-content')
+ if (content instanceof HTMLElement) {
+ // Drop the composited layer before zooming: a cached will-change raster
+ // would be upscaled instead of re-rasterized, which was the original
+ // zoom-blur bug. will-change re-applies via .is-panning on the next pan.
+ content.style.willChange = 'auto'
+ applyCanvasTransform()
+ syncCanvasViewport()
+ window.requestAnimationFrame(() => { content.style.willChange = '' })
+ } else {
+ applyCanvasTransform()
+ syncCanvasViewport()
+ }
+ const label = document.querySelector('.canvas-controls span')
+ if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
+}
+
+function zoomCanvasAtCenter(delta) {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ const bounds = viewport.getBoundingClientRect()
+ zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
+}
+
+function focusActiveCard() {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ const cards = state.canvasCards
+ if (cards === undefined || cards.length === 0) return
+ const route = state.canvasGraph?.nativeRoute ?? nativeCanvasRoute(cards)
+ const card = cards.find(card => card.id === route.currentCardId) ?? cards[0]
+ const { x: left, y: top } = card.position
+ const bounds = viewport.getBoundingClientRect()
+ state.canvasCamera = {
+ x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
+ y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
+ }
+ applyCanvasTransform()
+ syncCanvasViewport()
+}
+
+let selectionFollowup = null
+let selectionFollowupFrame = 0
+
+function hideSelectionFollowup() {
+ if (selectionFollowupFrame !== 0) {
+ window.cancelAnimationFrame(selectionFollowupFrame)
+ selectionFollowupFrame = 0
+ }
+ selectionFollowup = null
+ const button = app.querySelector('.selection-followup')
+ if (button instanceof HTMLButtonElement) button.hidden = true
+}
+
+function selectionFollowupTarget(range) {
+ const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
+ const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
+ if (!(start instanceof Element) || !(end instanceof Element)) return null
+ const answer = start.closest('.thread-answer')
+ if (answer instanceof HTMLElement && answer.contains(end)) {
+ const card = answer.closest('.thread-card[data-thread]')
+ if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread }
+ }
+ const messageBody = start.closest('.message-assistant .message-body')
+ const thread = currentThread()
+ if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id }
+ return null
+}
+
+function updateSelectionFollowup() {
+ selectionFollowupFrame = 0
+ const button = app.querySelector('.selection-followup')
+ const selection = window.getSelection()
+ if (!(button instanceof HTMLButtonElement) || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup()
+ const text = selection.toString().trim()
+ const range = selection.getRangeAt(0)
+ const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range)
+ const rect = range.getBoundingClientRect()
+ if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup()
+ selectionFollowup = { ...target, text }
+ button.dataset.thread = target.threadId
+ button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px`
+ button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px`
+ button.hidden = false
+}
+
+function queueSelectionFollowup() {
+ if (selectionFollowupFrame !== 0) return
+ selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup)
+}
+
+app.addEventListener('pointerdown', event => {
+ const viewport = canvasViewport(event.target)
+ if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
+ event.preventDefault()
+ const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
+ let pendingCamera = null
+ let frame = 0
+ state.canvasGesture = true
+ viewport.classList.add('is-panning')
+ viewport.setPointerCapture(event.pointerId)
+ const apply = () => {
+ frame = 0
+ if (pendingCamera === null) return
+ state.canvasCamera = pendingCamera
+ pendingCamera = null
+ applyCanvasTransform()
+ syncCanvasViewport()
+ }
+ const move = moveEvent => {
+ pendingCamera = {
+ x: origin.camera.x + moveEvent.clientX - origin.x,
+ y: origin.camera.y + moveEvent.clientY - origin.y,
+ }
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
+ }
+ const stop = () => {
+ viewport.classList.remove('is-panning')
+ document.removeEventListener('pointermove', move)
+ document.removeEventListener('pointerup', stop)
+ document.removeEventListener('pointercancel', stop)
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
+ apply()
+ state.canvasGesture = false
+ deferCanvasRefresh(120)
+ }
+ document.addEventListener('pointermove', move)
+ document.addEventListener('pointerup', stop)
+ document.addEventListener('pointercancel', stop)
+})
+
+app.addEventListener('wheel', event => {
+ const viewport = canvasViewport(event.target)
+ if (!(viewport instanceof HTMLElement)) return
+ const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
+ if (card instanceof HTMLElement) {
+ // Over a card the wheel scrolls that card's own answer with the browser's
+ // native wheel (OS-smooth, never a page jump per notch); the answer's
+ // overscroll-behavior: contain stops the scroll chaining into the canvas.
+ const answer = card.querySelector('.thread-answer')
+ if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
+ deferCanvasRefresh()
+ return
+ }
+ // A card with no scrollable answer swallows the wheel instead of zooming.
+ event.preventDefault()
+ deferCanvasRefresh()
+ return
+ }
+ event.preventDefault()
+ zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
+}, { passive: false })
+
+// Track pointer-down so the card click handler can tell a plain click from a
+// text-selection or drag gesture; acting on the latter would re-render and
+// wipe the user's selection.
+let pointerDownPosition = null
+app.addEventListener('pointerdown', event => {
+ pointerDownPosition = { x: event.clientX, y: event.clientY }
+ if (event.button !== 0) return
+ state.pointerPressed = true
+ if (event.target instanceof Element && !event.target.closest('.card-action-menu, [data-action="show-card-actions"]')) closeCardActionMenu()
+})
+// The browser fires click immediately after pointerup. Release on the next task
+// so incoming subscriptions cannot replace its target during that event chain.
+const releasePointer = () => window.setTimeout(() => {
+ state.pointerPressed = false
+ if (state.renderDeferred && canReplaceView()) render()
+}, 0)
+document.addEventListener('pointerup', releasePointer, true)
+document.addEventListener('pointercancel', releasePointer, true)
+app.addEventListener('pointerdown', event => {
+ const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null
+ if (button instanceof HTMLButtonElement) event.preventDefault()
+ else hideSelectionFollowup()
+})
+app.addEventListener('pointerup', queueSelectionFollowup)
+app.addEventListener('scroll', hideSelectionFollowup, true)
+document.addEventListener('selectionchange', queueSelectionFollowup)
+document.addEventListener('keydown', event => {
+ if (event.key !== 'Escape') return
+ if (state.actionMenuCardId !== null) { event.preventDefault(); closeCardActionMenu(); return }
+ if (state.mode !== 'canvas' || state.inspectorCardId === null) return
+ event.preventDefault()
+ closeCardInspector({ animate: false })
+})
+
+app.addEventListener('click', async event => {
+ state.pointerPressed = false
+ const button = event.target instanceof Element ? event.target.closest('[data-action]') : null
+ if (!(button instanceof HTMLElement)) {
+ const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]') : null
+ if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
+ // A double-click selects a word and a drag selects a range; neither is a
+ // select-click, so leave the selection intact instead of re-rendering.
+ if (event.detail > 1) return
+ if (event.detail > 0 && pointerDownPosition !== null
+ && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
+ const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
+ if (thread === undefined) return
+ const cardId = card.dataset.cardId
+ if (cardId === undefined) return
+ selectCanvasCard(cardId)
+ return
+ }
+ const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
+ try {
+ if (button.dataset.action === 'follow-selection') {
+ const followup = selectionFollowup
+ hideSelectionFollowup()
+ if (followup !== null && thread !== undefined && thread.id === followup.threadId) await openContinue(thread, undefined, followup.text)
+ return
+ }
+ if (button.dataset.action === 'show-card-actions' && button.dataset.card !== undefined) { openCardActionMenu(button.dataset.card); return }
+ if (button.dataset.action === 'dismiss-notice') { state.notice = ''; updateComposeNotice(); return }
+ if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
+ if (button.dataset.action === 'show-thread' && thread !== undefined) {
+ state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null
+ if (button.dataset.card !== undefined) { state.selectedCardId = button.dataset.card; state.inspectorCardId = button.dataset.card; state.inspectorOpening = false }
+ render(); void loadThreadHistory(thread)
+ }
+ if (button.dataset.action === 'show-compare') { state.mode = 'compare'; render() }
+ if (button.dataset.action === 'clear-compare') { state.compareCardIds = []; render() }
+ if (button.dataset.action === 'toggle-compare' && button.dataset.card !== undefined) {
+ const id = button.dataset.card
+ state.compareCardIds = state.compareCardIds.includes(id) ? state.compareCardIds.filter(item => item !== id) : [...state.compareCardIds.slice(-1), id]
+ render()
+ }
+ if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
+ if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
+ const cardId = button.dataset.card
+ const collapsing = !state.collapsedCardIds.has(cardId)
+ if (collapsing && state.workspace !== null) {
+ const allCards = conversationCards(state.workspace.threads)
+ const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
+ const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
+ const visibleIds = new Set(visibleCards.map(card => card.id))
+ const nativeRoute = nativeCanvasRoute(allCards)
+ if (nativeRoute.currentCardId !== null && !visibleIds.has(nativeRoute.currentCardId)
+ || state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError(t('activeCollapsed'))
+ }
+ collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
+ persistCollapsedCards()
+ render()
+ window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
+ }
+ if (button.dataset.action === 'open-continue' && thread !== undefined) await openContinue(thread, button.dataset.card)
+ if (button.dataset.action === 'open-branch' && thread !== undefined) {
+ const requestedSeq = button.dataset.seq === undefined || button.dataset.seq === '' ? NaN : Number(button.dataset.seq)
+ if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError(t('waitBranch'))
+ const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
+ await openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
+ }
+ if (button.dataset.action === 'toggle-message' && button.dataset.message !== undefined) { state.expandedMessageIds.has(button.dataset.message) ? state.expandedMessageIds.delete(button.dataset.message) : state.expandedMessageIds.add(button.dataset.message); renderPreservingDetailScroll() }
+ if (button.dataset.action === 'open-dsh' && typeof thread?.dshSessionId === 'string') post('synapse:open-session', { sessionId: thread.dshSessionId, seq: button.dataset.seq !== undefined && button.dataset.seq !== '' && Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined })
+ if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
+ if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
+ if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
+ if (button.dataset.action === 'focus-active') focusActiveCard()
+ if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
+ if (button.dataset.action === 'layout' && state.workspace !== null) {
+ resetCardPositions()
+ resetCanvasCamera()
+ render()
+ }
+ } catch (error) { setError(error) }
+})
+
+window.addEventListener('message', event => {
+ if (event.source !== window.parent || event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
+ const data = event.data
+ if (data.type === 'synapse:map-opened') {
+ // Do NOT reset the camera here: toggling dialog<->map for the same
+ // session must keep the user's viewport. A fresh canvas (canvasView
+ // not initialized) still centers via renderCanvas; a real session switch
+ // re-centers in the current-session handler below.
+ render()
+ window.requestAnimationFrame(() => post('synapse:map-ready'))
+ }
+ if (data.type === 'synapse:layout') {
+ if (Number.isFinite(data.composerHeight) && data.composerHeight >= 0) {
+ document.documentElement.style.setProperty('--map-composer-height', `${Math.min(window.innerHeight, data.composerHeight)}px`)
+ queueLayoutReport()
+ positionCardActionMenu()
+ }
+ }
+ if (data.type === 'synapse:presentation') {
+ const before = `${locale}|${document.documentElement.style.cssText}`
+ locale = data.locale === 'en-US' ? 'en-US' : 'zh-CN'
+ document.documentElement.lang = locale
+ document.title = locale === 'en-US' ? 'DeepViewer Conversation Map' : 'DeepViewer 会话地图'
+ const size = Number.isFinite(data.fontSize) ? Math.min(32, Math.max(10, data.fontSize)) : 14
+ document.documentElement.style.setProperty('--map-font-size', `${size}px`)
+ if (typeof data.fontFamily === 'string' && data.fontFamily.length < 500) document.documentElement.style.setProperty('--map-font-family', data.fontFamily)
+ const tokens = data.tokens ?? {}
+ for (const name of ['background', 'surface', 'text', 'muted', 'border', 'accent', 'hover', 'subtle', 'error']) {
+ if (typeof tokens[name] === 'string' && CSS.supports('color', tokens[name])) document.documentElement.style.setProperty(`--map-${name}`, tokens[name])
+ }
+ if (before !== `${locale}|${document.documentElement.style.cssText}`) render()
+ }
+ if (data.type === 'synapse:theme') {
+ document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
+ }
+ if (data.type === 'synapse:workspaces') {
+ const previousWorkspaces = JSON.stringify(state.dshWorkspaces)
+ state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
+ if (previousWorkspaces === JSON.stringify(state.dshWorkspaces) && state.workspace !== null) return
+ const current = currentDshWorkspace()
+ if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
+ else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
+ else if (canReplaceView()) render()
+ }
+ if (data.type === 'synapse:current-session') {
+ const previousId = state.currentDsh?.id
+ if (previousId === data.session?.id) { state.currentDsh = data.session; updateNativeCanvasRoute(); return }
+ closeCardActionMenu()
+ if (previousId !== undefined) persistViewState(previousId)
+ const restored = restoreViewState(data.session?.id)
+ state.currentDsh = data.session
+ updateNativeCanvasRoute()
+ if (typeof data.session?.id !== 'string') { state.workspace = null; state.activeId = null; state.selectedDshWorkspaceId = null; render(); return }
+ const preserveCanvasCamera = restored
+ const thread = currentDshThread()
+ if (thread !== undefined) {
+ const preserveSelectedCard = state.activeId === thread.id
+ if (!restored) state.activeId = thread.id
+ if (!preserveSelectedCard && !restored) {
+ state.selectedCardId = null
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ }
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
+ }
+ if (previousId !== data.session?.id) {
+ // A real session switch: re-center on the new session's latest turn,
+ // whether it lives in the same workspace (openCurrentWorkspace returns
+ // false) or a different one (it resets the camera itself).
+ void openCurrentWorkspace({ preserveCanvasCamera }).then(async opened => {
+ if (!opened && currentDshThread() === undefined) await refreshProjection({ force: true })
+ if (!opened && canReplaceView()) {
+ render()
+ if (!preserveCanvasCamera) focusActiveCard()
+ }
+ }).catch(setError)
+ }
+ else if (canReplaceView()) render()
+ }
+ if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
+ if (thread !== undefined) {
+ if (data.running === true) {
+ state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '', userSeq: Number.isSafeInteger(data.userSeq) ? data.userSeq : undefined })
+ if (Number.isSafeInteger(data.userSeq) && !persistedMessagesFor(thread).some(message => message.kind === 'user' && message.sourceSeq === data.userSeq)) void refreshProjection({ force: true }).catch(setError)
+ // Streaming: patch the live card's answer in place instead of
+ // rebuilding the whole canvas on every chunk; a full render reconciles
+ // at stream end. The detail view is single-thread, so keep its cheap
+ // throttled full render.
+ if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
+ else if (canReplaceView()) scheduleLiveRender()
+ } else {
+ state.liveReplies.delete(data.sessionId)
+ if (canReplaceView()) renderPreservingDetailScroll()
+ void refreshProjection({ force: true }).catch(setError)
+ }
+ }
+ }
+ if (data.type === 'synapse:composed') settleRpc(data.requestId, data.session ?? data)
+ if (data.type === 'synapse:api-result') settleRpc(data.requestId, data.value)
+ if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
+})
+
+post('synapse:request-current')
+refreshSummaries().catch(setError)
+let polling = false
+let liveRenderTimer = 0
+let liveCardFrame = 0
+let liveCardSessionId = null
+function scheduleLiveCardUpdate(sessionId) {
+ // Coalesce streaming chunks to one DOM patch per animation frame.
+ liveCardSessionId = sessionId
+ if (liveCardFrame !== 0) return
+ liveCardFrame = window.requestAnimationFrame(() => {
+ liveCardFrame = 0
+ if (liveCardSessionId === null) return
+ const id = liveCardSessionId
+ liveCardSessionId = null
+ applyLiveReplyToCard(id)
+ })
+}
+function applyLiveReplyToCard(sessionId) {
+ if (state.mode !== 'canvas' || state.dragging || state.canvasGesture) return
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
+ const live = state.liveReplies.get(sessionId)
+ if (thread === undefined || live?.running !== true || !Number.isSafeInteger(live.userSeq)) return
+ updateNativeCanvasRoute()
+ const dataCard = state.canvasCards?.find(card => card.dshThreadId === thread.id && card.sourceSeq === live.userSeq)
+ if (dataCard === undefined) return
+ const card = app.querySelector(`.thread-card[data-card-id="${CSS.escape(dataCard.id)}"]`)
+ const answer = card?.querySelector('.thread-answer')
+ const markup = `${live.text.trim() === '' ? '' : renderMarkdown(live.text)}${t('replying')}
`
+ if (answer instanceof HTMLElement) answer.innerHTML = markup
+ // Details follows only its selected turn; a later turn must not replace an older answer.
+ if (state.inspectorCardId === dataCard.id) {
+ const inspector = app.querySelector('.card-inspector-scroll')
+ const current = inspector?.querySelector('.card-inspector-answer, .card-inspector-pending')
+ if (current instanceof HTMLElement) {
+ const scroll = inspector.scrollTop
+ current.outerHTML = `${markup} `
+ inspector.scrollTop = scroll
+ }
+ }
+}
+function scheduleLiveRender() {
+ if (liveRenderTimer !== 0 || !canReplaceView()) return
+ liveRenderTimer = window.setTimeout(() => {
+ liveRenderTimer = 0
+ if (canReplaceView()) renderPreservingDetailScroll()
+ }, 120)
+}
+async function pollProjection() {
+ if (polling || document.hidden || !canReplaceView()) return
+ polling = true
+ try {
+ await refreshProjection()
+ } finally { polling = false }
+}
+window.setInterval(() => { void pollProjection() }, 1_000)
diff --git a/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png b/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png
new file mode 100644
index 0000000..13da831
Binary files /dev/null and b/apps/deepviewer-adapter/assets/synapse/deepviewer-icon.png differ
diff --git a/apps/deepviewer-adapter/assets/synapse/index.html b/apps/deepviewer-adapter/assets/synapse/index.html
new file mode 100644
index 0000000..67769b6
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/synapse/index.html
@@ -0,0 +1 @@
+DeepViewer Conversation Map
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/assets/synapse/styles.css b/apps/deepviewer-adapter/assets/synapse/styles.css
new file mode 100644
index 0000000..1c5ce12
--- /dev/null
+++ b/apps/deepviewer-adapter/assets/synapse/styles.css
@@ -0,0 +1,202 @@
+/* DeepViewer's native adapter supplies the tokens and the shared chat font size. */
+:root {
+ color-scheme: light;
+ --map-font-size: 14px;
+ --map-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
+ --map-background: #fff; --map-surface: #f7f7f8; --map-text: #202124;
+ --map-muted: #80858d; --map-border: #e4e5e8; --map-accent: #457bf3;
+ --map-hover: #edeef1; --map-subtle: #f3f4f6; --map-error: #d84a45;
+ font-family: var(--map-font-family); font-size: var(--map-font-size); color: var(--map-text); background: var(--map-background);
+}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --map-background: #151515; --map-surface: #232323; --map-text: #eee;
+ --map-muted: #92969e; --map-border: #363638; --map-accent: #6a9aff;
+ --map-hover: #303033; --map-subtle: #202023; --map-error: #fa7b73;
+}
+* { box-sizing: border-box; }
+html, body, #app { width: 100%; height: 100%; min-width: 0; min-height: 0; margin: 0; overflow: hidden; }
+body { background: var(--map-background); }
+button { display: inline-flex; align-items: center; justify-content: center; gap: .3em; min-height: 30px; border: 1px solid var(--map-border); border-radius: 8px; background: transparent; color: var(--map-text); padding: 4px 9px; font: inherit; font-size: .9rem; cursor: pointer; }
+button:hover { background: var(--map-hover); }
+button:disabled { cursor: default; opacity: .4; }
+button:focus-visible { outline: 2px solid var(--map-accent); outline-offset: 2px; }
+button.primary { border-color: var(--map-accent); background: var(--map-accent); color: white; }
+button svg { width: 1em; height: 1em; flex: none; }
+.synapse-shell { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
+.map-toolbar { display: flex; flex: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; min-width: 0; padding: 8px 12px; border-bottom: 1px solid var(--map-border); background: var(--map-background); }
+.canvas-tabs, .canvas-controls { display: flex; position: static; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; }
+.canvas-tabs button { border-color: transparent; color: var(--map-muted); }
+.canvas-tabs button.active { background: var(--map-subtle); color: var(--map-text); font-weight: 600; }
+.canvas-controls { margin-left: auto; }
+.canvas-controls button { min-width: 30px; padding-inline: 7px; }
+.canvas-controls span { min-width: 44px; text-align: center; color: var(--map-muted); font-size: .86rem; font-variant-numeric: tabular-nums; }
+.main-stage { display: flex; position: relative; flex-direction: column; flex: 1; min-width: 0; min-height: 0; overflow: hidden; background: var(--map-background); }
+.status-message { display: flex; flex: none; align-items: center; gap: 10px; padding: 8px 12px; background: var(--map-subtle); color: var(--map-error); font-size: .92rem; }
+.status-message span { min-width: 0; flex: 1; overflow-wrap: anywhere; }
+.status-message button { min-width: 28px; min-height: 28px; border: 0; padding: 0; color: var(--map-muted); }
+.canvas-view { display: flex; position: relative; flex: 1; min-width: 0; min-height: 0; }
+.canvas-viewport { align-self: flex-start; position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; background: var(--map-background); background-image: radial-gradient(var(--map-border) .7px, transparent .7px); background-size: 20px 20px; cursor: grab; }
+.canvas-viewport.is-panning { cursor: grabbing; user-select: none; }
+.canvas-viewport.is-panning .canvas-content { will-change: transform; }
+.canvas-content { position: absolute; inset: 0; transform-origin: 0 0; }
+.connectors { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
+.connectors path { fill: none; stroke: var(--map-border); stroke-width: 1.5; }
+.connectors path.active-connector { stroke: var(--map-accent); }
+.cards-layer { position: absolute; inset: 0; }
+.thread-card { position: absolute; display: grid; width: 310px; height: 276px; grid-template-rows: auto auto minmax(0, 1fr) auto; border: 1px solid var(--map-border); border-radius: 12px; background: var(--map-surface); box-shadow: 0 2px 8px rgb(0 0 0 / 5%); overflow: visible; }
+.thread-card:hover { border-color: color-mix(in srgb, var(--map-border), var(--map-muted) 35%); }
+/* Browsing is neutral; the blue edge always follows the native composer. */
+.thread-card.selected { border-color: color-mix(in srgb, var(--map-border), var(--map-text) 45%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--map-border), var(--map-text) 45%); }
+.thread-card.current { border-color: var(--map-accent); box-shadow: 0 0 0 1px var(--map-accent); }
+.node-handle { display: grid; position: absolute; top: -18px; left: 50%; width: 40px; height: 32px; min-height: 32px; padding: 0; transform: translateX(-50%); border: 0; border-radius: 6px; background: transparent; cursor: grab; }
+.node-handle:hover { background: transparent; }
+.node-handle::after { display: grid; place-items: center; width: 28px; height: 14px; border: 1px solid var(--map-border); border-radius: 5px; background: var(--map-surface); content: '···'; color: var(--map-muted); font-size: .8rem; line-height: 1; letter-spacing: 1px; }
+.thread-card-head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 14px 12px 7px; }
+.topic-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--map-muted); }
+.thread-card.current .topic-dot { background: var(--map-accent); }
+.thread-title { min-width: 0; min-height: 22px; flex: 1; justify-content: start; overflow: hidden; border: 0; padding: 0; color: var(--map-text); font-size: 1rem; font-weight: 600; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
+.thread-title:hover { background: transparent; }
+.graph-continue-button, .graph-fold-button, .graph-branch-button { display: grid; position: absolute; z-index: 4; right: -16px; width: 32px; height: 32px; min-height: 32px; padding: 0; border-radius: 50%; border-color: var(--map-border); background: var(--map-surface); color: var(--map-muted); }
+.graph-continue-button, .graph-fold-button { top: 50%; transform: translateY(-50%); }
+.graph-branch-button { top: calc(50% + 38px); transform: translateY(-50%); }
+.graph-continue-button svg, .graph-fold-button svg { fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.8; }
+.graph-branch-button svg { fill: currentColor; }
+.thread-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 0 12px; color: var(--map-muted); font-size: .78rem; }
+.thread-meta span + span::before { content: '·'; margin-right: 5px; }
+.thread-meta .card-process-count { margin-left: auto; color: var(--map-accent); }
+.thread-meta .card-error-status { color: var(--map-error); }
+.thread-answer { min-width: 0; min-height: 0; margin: 9px 12px; overflow-x: hidden; overflow-y: auto; color: var(--map-text); font-size: 1rem; line-height: 1.6; overflow-wrap: anywhere; overscroll-behavior: contain; scrollbar-color: var(--map-muted) transparent; scrollbar-width: thin; }
+.thread-answer p { margin: 0 0 8px; }
+.thread-answer h1, .thread-answer h2, .thread-answer h3 { margin: 0 0 8px; font-size: 1.05rem; }
+.thread-answer ul, .thread-answer ol { margin: 0 0 8px; padding-left: 20px; }
+.thread-answer-empty, .thread-answer-pending { color: var(--map-muted); }
+.thread-answer-error { color: var(--map-error); overflow-wrap: anywhere; }
+.thread-card footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 2px; padding: 6px; border-top: 1px solid var(--map-border); }
+.thread-card footer button { min-height: 25px; border: 0; padding: 3px 5px; font-size: .78rem; color: var(--map-text); }
+.thread-card footer .compare-button { margin-right: auto; }
+.thread-card footer .compare-button.selected { background: var(--map-hover); color: var(--map-accent); }
+.selection-followup { display: inline-flex; position: fixed; z-index: 90; min-height: 30px; transform: translateX(-100%); background: var(--map-accent); color: white; border-color: var(--map-accent); box-shadow: 0 2px 8px rgb(0 0 0 / 12%); font-size: .85rem; user-select: none; }
+.selection-followup:hover { background: var(--map-accent); }
+.selection-followup svg { fill: none; stroke: currentColor; stroke-width: 1.5; }
+.selection-followup[hidden] { display: none; }
+.card-inspector { position: absolute; z-index: 30; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); min-width: 0; border-left: 1px solid var(--map-border); background: var(--map-background); opacity: 1; transform: translateX(0); transition: transform 180ms ease, opacity 150ms ease; }
+.card-inspector.is-opening, .card-inspector.is-closing { opacity: 0; transform: translateX(24px); }
+.card-inspector-head { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 12px; align-items: start; padding: 16px 18px 12px; }
+.card-inspector-meta { display: flex; flex-wrap: wrap; gap: 6px; color: var(--map-muted); font-size: .85rem; }
+.card-inspector-error-status { color: var(--map-error); }
+.card-inspector-head h2 { margin: 8px 0 0; overflow-wrap: anywhere; font-size: 1.08rem; line-height: 1.5; }
+.card-inspector-close { width: 30px; height: 30px; border: 0; padding: 0; border-radius: 50%; color: var(--map-muted); }
+.card-inspector-close svg { fill: none; stroke: currentColor; stroke-width: 1.7; }
+.card-inspector-scroll { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow-y: auto; padding: 12px 18px 20px; overscroll-behavior: contain; }
+.card-inspector-answer { color: var(--map-text); font-size: 1rem; line-height: 1.75; }
+.card-inspector-answer p { margin: 0 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.card-inspector-answer h1, .card-inspector-answer h2, .card-inspector-answer h3 { font-size: 1.08rem; }
+.card-inspector-pending { color: var(--map-muted); }
+.card-inspector-error { border-left: 3px solid var(--map-error); padding-left: 10px; color: var(--map-error); font-size: 1rem; line-height: 1.6; }
+.card-inspector-error p { margin: 5px 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.card-inspector .process-records { margin-top: 20px; }
+.card-inspector-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--map-border); }
+.card-inspector-actions button { font-size: .85rem; }
+.card-inspector-actions svg { fill: none; stroke: currentColor; stroke-width: 1.4; }
+.empty-canvas, .compare-empty { display: grid; flex: 1; min-height: 0; align-content: center; justify-items: center; padding: 24px; text-align: center; }
+.empty-canvas strong, .compare-empty strong { font-size: 1.05rem; font-weight: 500; }
+.empty-canvas p, .compare-empty p { max-width: 460px; color: var(--map-muted); line-height: 1.65; }
+.detail-view { display: flex; flex: 1; flex-direction: column; width: min(900px, 100%); min-height: 0; margin: 0 auto; background: var(--map-background); }
+.detail-head { display: flex; flex: none; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 12px; padding: 18px 22px 14px; border-bottom: 1px solid var(--map-border); }
+.detail-head-title { min-width: 0; flex: 1 1 200px; }
+.detail-head-meta { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: .85rem; color: var(--map-muted); }
+.detail-subtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.detail-badge { flex: none; }
+.detail-head h1 { margin: 8px 0 0; font-size: 1.15rem; line-height: 1.45; overflow-wrap: anywhere; }
+.detail-head-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+.detail-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 16px 22px 24px; overscroll-behavior: contain; }
+.message { margin: 0 0 20px; min-width: 0; font-size: 1rem; }
+.message header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--map-muted); font-size: .82rem; }
+.message-role { font-weight: 600; color: var(--map-text); }
+.message time { margin-right: auto; font-variant-numeric: tabular-nums; }
+.message-user { margin-left: auto; width: fit-content; max-width: 88%; border-radius: 12px; background: var(--map-subtle); padding: 12px 16px; }
+.message-body { line-height: 1.7; overflow-wrap: anywhere; }
+.message-body p { margin: 0 0 10px; white-space: pre-wrap; }
+.message-body p:last-child { margin-bottom: 0; }
+.message-body h1, .message-body h2, .message-body h3 { margin: 14px 0 8px; font-size: 1.12rem; }
+.message-branch, .message-fold { min-height: 25px; border: 0; padding: 2px 5px; font-size: .82rem; color: var(--map-muted); }
+.message-branch svg, .message-fold svg { fill: none; stroke: currentColor; stroke-width: 1.4; }
+.message-fold svg { transition: transform 140ms ease; }
+.message.expanded .message-fold svg { transform: rotate(90deg); }
+.message-summary { color: var(--map-muted); }
+.message-error, .process-error { color: var(--map-error); }
+.message-streaming { color: var(--map-muted); }
+.streaming-dot { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: var(--map-accent); animation: map-pulse 1.4s ease-in-out infinite; }
+@keyframes map-pulse { 50% { opacity: .3; } }
+.process-records { margin-top: 12px; border-top: 1px solid var(--map-border); padding-top: 8px; }
+.process-records-fold, .process-entry-fold { display: flex; width: 100%; justify-content: space-between; border: 0; padding: 5px 0; color: var(--map-muted); font-size: .85rem; text-align: left; }
+.process-records-fold:hover, .process-entry-fold:hover { background: transparent; color: var(--map-text); }
+.process-count { margin-left: auto; }
+.process-entry { border-top: 1px solid var(--map-border); padding-top: 6px; }
+.process-entry-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; }
+.process-entry-body { padding-bottom: 8px; }
+.process-status { flex: none; margin-left: 8px; font-size: .8rem; }
+.process-status-error { color: var(--map-error); }
+.process-status-done { color: var(--map-muted); }
+pre { max-width: 100%; overflow-x: auto; border: 1px solid var(--map-border); border-radius: 8px; background: var(--map-subtle); padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: .9rem; }
+code { border-radius: 3px; background: var(--map-subtle); padding: 1px 3px; font-family: ui-monospace, SFMono-Regular, monospace; font-size: .9em; }
+pre code { background: transparent; padding: 0; }
+blockquote { margin-inline: 0; border-left: 3px solid var(--map-border); padding-left: 12px; color: var(--map-muted); }
+table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .9rem; }
+th, td { border: 1px solid var(--map-border); padding: 5px 8px; text-align: left; }
+th { background: var(--map-subtle); }
+a { color: var(--map-accent); }
+.note-empty { color: var(--map-muted); line-height: 1.6; }
+.compare-view { flex: 1; min-height: 0; overflow: auto; padding: 18px; }
+.compare-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; width: 100%; max-width: 1100px; margin: 0 auto; }
+.compare-columns article { min-width: 0; border: 1px solid var(--map-border); border-radius: 12px; background: var(--map-surface); overflow: hidden; }
+.compare-title { display: flex; align-items: center; gap: 8px; padding: 14px; border-bottom: 1px solid var(--map-border); }
+.compare-title h2 { min-width: 0; flex: 1; margin: 0; overflow-wrap: anywhere; font-size: 1rem; line-height: 1.5; }
+.compare-title button { font-size: .82rem; }
+.compare-meta { margin: 10px 14px; font-size: .85rem; color: var(--map-muted); }
+.compare-answer { padding: 0 14px 14px; font-size: 1rem; line-height: 1.7; overflow-wrap: anywhere; }
+.compare-answer p { margin: 0 0 10px; }
+@media (max-width: 640px) {
+ .map-toolbar { padding: 6px 8px; gap: 4px 8px; }
+ .canvas-controls { margin-left: 0; }
+ .card-inspector { width: 100%; top: auto; max-height: 80%; border-top: 1px solid var(--map-border); border-left: 0; }
+ .card-inspector.is-opening, .card-inspector.is-closing { transform: translateY(24px); }
+ .detail-head { padding: 14px 14px 10px; }
+ .detail-scroll { padding: 14px; }
+ .compare-view { padding: 12px; }
+ .compare-columns { grid-template-columns: 1fr; }
+}
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
+
+@media (min-width: 641px) {
+ .canvas-view:has(.card-inspector) > .canvas-viewport {
+ width: calc(100% - min(460px, 100%));
+ height: calc(100% - var(--map-composer-height, 0px));
+ flex: none;
+ }
+}
+
+.card-action-menu {
+ position: fixed;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 240px;
+ max-width: calc(100% - 16px);
+ padding: 6px;
+ border: 1px solid var(--map-border);
+ border-radius: 10px;
+ background: var(--map-surface);
+ box-shadow: 0 6px 22px rgb(0 0 0 / 14%);
+}
+.card-action-menu button { justify-content: flex-start; min-height: 36px; border-color: transparent; }
+.card-action-menu-hint { margin: 0; padding: 4px 8px; color: var(--map-muted); font-size: .82rem; line-height: 1.5; }
+.compose-notice { position: absolute; z-index: 40; top: 8px; left: 8px; max-width: min(560px, calc(100% - 16px)); border: 1px solid var(--map-border); border-radius: 8px; box-shadow: 0 2px 8px rgb(0 0 0 / 8%); display: flex; flex: none; align-items: center; gap: 8px; padding: 8px 12px; color: var(--map-text); background: var(--map-subtle); font-size: .9rem; line-height: 1.5; }
+.compose-notice span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.compose-notice button { flex: none; min-width: 28px; min-height: 28px; border: 0; padding: 0; color: var(--map-muted); }
+
+@media (min-width: 641px) {
+ .main-stage:has(.card-inspector) .compose-notice { max-width: calc(100% - min(460px, 100%) - 16px); }
+}
diff --git a/apps/deepviewer-adapter/client/CodeEditor.tsx b/apps/deepviewer-adapter/client/CodeEditor.tsx
new file mode 100644
index 0000000..5a5dab2
--- /dev/null
+++ b/apps/deepviewer-adapter/client/CodeEditor.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useRef } from 'react'
+import { EditorState } from '@codemirror/state'
+import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'
+import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands'
+import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
+import { bracketMatching, foldGutter, foldKeymap, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
+import { javascript } from '@codemirror/lang-javascript'
+import { json } from '@codemirror/lang-json'
+import { markdown } from '@codemirror/lang-markdown'
+import { python } from '@codemirror/lang-python'
+import { html } from '@codemirror/lang-html'
+import { css } from '@codemirror/lang-css'
+
+function language(path: string) {
+ const extension = path.split('.').at(-1)?.toLowerCase()
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(extension ?? '')) return javascript({ jsx: extension?.endsWith('x'), typescript: extension?.startsWith('t') })
+ if (extension === 'json') return json()
+ if (extension === 'md') return markdown()
+ if (extension === 'py') return python()
+ if (['html', 'htm'].includes(extension ?? '')) return html()
+ if (extension === 'css') return css()
+ return []
+}
+export function CodeEditor({ path, text, label, onChange, onSave }: { path: string; text: string; label: string; onChange: (text: string) => void; onSave: () => void }) {
+ const container = useRef(null), editor = useRef(undefined)
+ const callbacks = useRef({ onChange, onSave }); callbacks.current = { onChange, onSave }
+ useEffect(() => {
+ const view = new EditorView({ parent: container.current!, state: EditorState.create({ doc: text, extensions: [
+ lineNumbers(), history(), highlightActiveLine(), drawSelection(), bracketMatching(), foldGutter(), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle), language(path),
+ keymap.of([{ key: 'Mod-s', run: () => { callbacks.current.onSave(); return true } }, ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap, indentWithTab]),
+ EditorView.contentAttributes.of({ 'aria-label': label, spellcheck: 'false' }),
+ EditorView.theme({ '&': { height: '100%', color: 'var(--dsw-alias-label-primary)', backgroundColor: 'var(--dsw-alias-bg-base)' }, '.cm-scroller': { overflow: 'auto', fontFamily: 'Menlo, monospace', fontSize: '12px' }, '.cm-gutters': { color: 'var(--dsw-alias-label-tertiary)', background: 'var(--dsw-alias-bg-l1)', borderRight: 'none' }, '.cm-activeLine': { background: 'color-mix(in srgb, currentColor 5%, transparent)' }, '.cm-cursor': { borderLeftColor: 'currentColor' } }),
+ EditorView.updateListener.of(update => { if (update.docChanged) callbacks.current.onChange(update.state.doc.toString()) }),
+ ] }) }); editor.current = view
+ return () => { view.destroy(); editor.current = undefined }
+ // Document identity creates an editor; ordinary edits preserve selection, undo and scroll.
+ }, [path, label])
+ useEffect(() => { const view = editor.current; if (view && view.state.doc.toString() !== text) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }) }, [text])
+ return
+}
diff --git a/apps/deepviewer-adapter/client/DeepViewerMascot.tsx b/apps/deepviewer-adapter/client/DeepViewerMascot.tsx
new file mode 100644
index 0000000..b448e3c
--- /dev/null
+++ b/apps/deepviewer-adapter/client/DeepViewerMascot.tsx
@@ -0,0 +1,6 @@
+import css from './welcome.module.css'
+
+/** Keep the original DeepViewer mark in the native conversation hero slot. */
+export function DeepViewerMascot() {
+ return
+}
diff --git a/apps/deepviewer-adapter/client/ModeSwitch.tsx b/apps/deepviewer-adapter/client/ModeSwitch.tsx
new file mode 100644
index 0000000..b653573
--- /dev/null
+++ b/apps/deepviewer-adapter/client/ModeSwitch.tsx
@@ -0,0 +1,27 @@
+import { IconFolderOpenOutlineRegular, IconNewChatOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
+import type {} from './locales.ts'
+import { useRef, useState } from 'react'
+import type { PropsRuntime, PropsLocale, InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-agent-preset-registry/types'
+import css from './mode.module.css'
+type Injected = { change: (mode: 'work' | 'chat') => Promise }
+type Props = PropsRuntime<'sidebar.mode'> & InjectFace & PropsLocale<'deepviewer'>
+export function ModeSwitch({ wide, useSessions, change, t }: Props) {
+ const chat = useSessions(s => Object.values(s.byId).some(row => (row.retainedBy.mainView ?? 0) > 0 && row.projectionValues?.agentPreset === 'deepviewer-chat'))
+ const [busy, setBusy] = useState(false), [error, setError] = useState()
+ const pending = useRef(false)
+ const select = async (mode: 'work' | 'chat') => {
+ if (pending.current || (mode === 'chat') === chat) return
+ pending.current = true
+ setBusy(true); setError(undefined)
+ try { await change(mode) } catch (error) { setError(error instanceof Error ? error.message : String(error)) }
+ finally { pending.current = false; setBusy(false) }
+ }
+ return
+
+ {(['work', 'chat'] as const).map(mode => { void select(mode) }}>{mode === 'work' ? : }{wide && {t(mode)} } )}
+
+ {error &&
{error}
}
+
+}
diff --git a/apps/deepviewer-adapter/client/SynapseView.tsx b/apps/deepviewer-adapter/client/SynapseView.tsx
new file mode 100644
index 0000000..711ce54
--- /dev/null
+++ b/apps/deepviewer-adapter/client/SynapseView.tsx
@@ -0,0 +1,300 @@
+import { useEffect, useRef } from 'react'
+import type { Context } from '@deepseek-ai/cordis'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
+import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+
+declare module '@deepseek-ai/dsh-api-session-controller/client' { interface SessionReferenceSourceMap { deepviewerMap: unknown } }
+
+type MapProps = PropsRuntime<'conversation.view'> & {
+ /** Native shell navigation prepares the destination's own view state. */
+ openSessionView?: (id: SessionId, view: string, focus: string) => Promise | void
+}
+
+function safeSeq(value: unknown): value is number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
+}
+
+/** The iframe receives resolved theme values, never a second theme preference. */
+function presentation(ctx: Context) {
+ const theme = ctx.theme.getTheme()
+ const style = getComputedStyle(document.body)
+ const dark = theme.active.colorScheme === 'dark'
+ const token = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback
+ return {
+ locale: ctx.locale.getLocale().active.startsWith('zh') ? 'zh-CN' : 'en-US',
+ fontSize: theme.fontSize,
+ fontFamily: style.fontFamily || '-apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif',
+ dark,
+ tokens: {
+ background: token('--dsw-alias-bg-base', dark ? '#141414' : '#ffffff'),
+ surface: token('--dsw-alias-bg-layer-1', dark ? '#242424' : '#ffffff'),
+ text: token('--dsw-alias-label-primary', dark ? '#f4f4f4' : '#242424'),
+ muted: token('--dsw-alias-label-secondary', dark ? '#aaaaaa' : '#747474'),
+ border: token('--dsw-alias-border-l2', dark ? '#383838' : '#e1e1e1'),
+ accent: token('--dsw-alias-link', dark ? '#6b9fff' : '#3478f6'),
+ hover: token('--dsw-alias-interactive-bg-hover', dark ? '#303030' : '#f1f1f1'),
+ subtle: token('--dsw-alias-bg-layer-2', dark ? '#1c1c1c' : '#f7f7f7'),
+ error: token('--dsw-alias-state-error-primary', dark ? '#ff8b86' : '#c62a24'),
+ },
+ }
+}
+
+export function registerSynapseView(ctx: Context) {
+ const text = ctx.locale.bind('deepviewer')
+ function MapView({ sessionId, openView, openSessionView }: MapProps) {
+ const iframe = useRef(null)
+ useEffect(() => {
+ const frame = iframe.current
+ if (!frame) return
+ let disposed = false, reconcileQueued = false, navigationRequest = 0, pendingFork = false
+ let selected = sessionId
+ const connection = ctx.get('connection') as ConnectionHandle
+ const observers = new Map void }>()
+ const send = (type: string, payload: Record = {}) => {
+ if (!disposed) frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin)
+ }
+ // The iframe owns its details width; the native input owns its height.
+ // Both measurements stay in this one mount, and never become preferences.
+ const content = frame.closest('[data-conversation-content]')
+ const composer = content?.querySelector('[data-deepviewer-composer-seat]')
+ let inspectorWidth = 0, lastComposerHeight: number | undefined
+ const clearInspector = () => {
+ inspectorWidth = 0
+ content?.removeAttribute('data-deepviewer-map-inspector')
+ content?.style.removeProperty('--deepviewer-map-inspector-width')
+ }
+ const setInspector = (width: number) => {
+ if (width === 0) { clearInspector(); return }
+ inspectorWidth = width
+ content?.setAttribute('data-deepviewer-map-inspector', '')
+ content?.style.setProperty('--deepviewer-map-inspector-width', `${width}px`)
+ }
+ const publishLayout = (force = false) => {
+ if (disposed) return
+ // A smaller window must not retain a stale desktop details column.
+ if (inspectorWidth > frame.clientWidth) clearInspector()
+ const height = composer?.offsetHeight ?? 0
+ if (force || height !== lastComposerHeight) {
+ lastComposerHeight = height
+ send('synapse:layout', { composerHeight: height })
+ }
+ }
+ const geometry = new ResizeObserver(() => { publishLayout() })
+ geometry.observe(frame)
+ if (composer) geometry.observe(composer)
+ const snapshot = (id: SessionId) => {
+ const row = ctx.sessions.list.getSnapshot().byId[id]
+ return row ? { id, title: row.displayTitle, cwd: row.cwd ?? null } : null
+ }
+ const requireSession = (id: unknown): SessionId => {
+ if (typeof id !== 'string' || !snapshot(id as SessionId)) throw new Error(text('mapSessionMissing'))
+ return id as SessionId
+ }
+ const reconcile = () => {
+ if (disposed) return
+ const sessions = ctx.sessions.list.getSnapshot()
+ // Cold history remains in the Host projection. Only the selected and
+ // actually running sessions need Client streaming references.
+ const wanted = new Set(sessions.ids.filter(id => sessions.byId[id]?.running))
+ if (snapshot(selected)) wanted.add(selected)
+ for (const [id, observer] of observers) {
+ if (wanted.has(id)) continue
+ observers.delete(id); observer.cleanup?.(); observer.reference.release()
+ send('synapse:live-reply', { sessionId: id, running: false, text: '' })
+ }
+ for (const id of wanted) {
+ if (observers.has(id)) continue
+ const reference = ctx.sessions.retain(id, { source: 'deepviewerMap' })
+ const observer = { reference, cleanup: undefined as (() => void) | undefined }
+ observers.set(id, observer)
+ void reference.ready.then(binding => {
+ if (disposed || observers.get(id) !== observer) return
+ const chat = ctx.uiConversation.binding(binding).target('chat')
+ const publish = () => {
+ const current = chat.getSnapshot()
+ const partial = current?.legacy.partial
+ // The native classifier assigns both turn-opening and next-step
+ // human inputs to user/steering; injected notices are context.
+ const userSeq = current?.legacy.nodes.filter(node => node.kind === 'user' || node.kind === 'steering').at(-1)?.seq
+ const output = partial?.blocks.filter(block => block.kind === 'text').map(block => block.text).join('') ?? ''
+ send('synapse:live-reply', { sessionId: id, running: binding.session.getSnapshot().running, text: output, userSeq })
+ }
+ const offChat = chat.subscribe(publish), offSession = binding.session.subscribe(publish)
+ observer.cleanup = () => { offChat(); offSession() }
+ publish()
+ }).catch(error => {
+ if (disposed || observers.get(id) !== observer) return
+ observers.delete(id); observer.cleanup?.(); reference.release()
+ send('synapse:bridge-error', { message: error instanceof Error ? error.message : String(error) })
+ })
+ }
+ }
+ const scheduleReconcile = () => {
+ if (reconcileQueued || disposed) return
+ reconcileQueued = true
+ queueMicrotask(() => { reconcileQueued = false; reconcile() })
+ }
+ const sync = () => {
+ const sessions = ctx.sessions.list.getSnapshot()
+ const workspaces = ctx.workspaces.list.getSnapshot().items
+ const known = new Set(workspaces.flatMap(w => w.sessionIds))
+ send('synapse:workspaces', { workspaces: [
+ ...workspaces.map(w => ({ id: w.workspaceId, title: w.title, path: w.path, sessionIds: w.sessionIds })),
+ { id: 'dsh-ungrouped', title: text('mapUngrouped'), path: null, sessionIds: sessions.ids.filter(id => !known.has(id)) },
+ ] })
+ send('synapse:current-session', { session: snapshot(selected) })
+ send('synapse:presentation', presentation(ctx))
+ send('synapse:theme', { dark: ctx.theme.getTheme().active.colorScheme === 'dark' })
+ scheduleReconcile()
+ publishLayout()
+ }
+ const navigate = async (id: SessionId, view: string, seq?: number, request = ++navigationRequest) => {
+ const previous = selected
+ const focus = seq === undefined ? '' : `seq:${seq}`
+ const commit = async () => {
+ if (disposed || request !== navigationRequest) return false
+ if (!openSessionView && id !== sessionId) throw new Error('Native session navigation unavailable')
+ selected = id
+ if (openSessionView) await openSessionView(id, view, focus)
+ else { ctx.uiWorkspace.openSession(id); openView(view, focus) }
+ if (request !== navigationRequest) return false
+ if (!disposed) sync()
+ return true
+ }
+ try {
+ // Keep paged history alive until the native destination owns it.
+ return await ctx.sessions.using(id, { source: 'deepviewerMap' }, async reference => {
+ const binding = await reference.ready
+ if (seq !== undefined) await binding.session.loadThrough(seq as SessionSeq)
+ return commit()
+ })
+ } catch (error) {
+ if (!disposed && request === navigationRequest) { selected = previous; sync() }
+ throw error
+ }
+ }
+ const create = async (workspaceId?: unknown) => {
+ const workspaces = ctx.workspaces.list.getSnapshot().items
+ const workspace = workspaceId === undefined
+ ? workspaces.find(w => w.sessionIds.includes(selected))
+ : workspaces.find(w => w.workspaceId === workspaceId)
+ if (workspaceId && workspaceId !== 'dsh-ungrouped' && !workspace) throw new Error(text('mapSessionMissing'))
+ const source = ctx.sessions.list.getSnapshot().byId[selected]
+ return ctx.sessions.create(workspace ? { workspaceId: workspace.workspaceId as WorkspaceId } : { agentPreset: source?.projectionValues?.agentPreset === 'deepviewer-chat' ? 'deepviewer-chat' : undefined })
+ }
+ const onMessage = async (event: MessageEvent) => {
+ if (event.source !== frame.contentWindow || event.origin !== location.origin || event.data?.source !== 'dsh-synapse') return
+ const data = event.data
+ try {
+ if (data.type === 'synapse:layout') {
+ const width = data.inspectorWidth
+ if (typeof width !== 'number' || !Number.isFinite(width) || width < 0 || width > frame.clientWidth) throw new Error('Invalid inspector width')
+ setInspector(width)
+ publishLayout()
+ } else if (data.type === 'synapse:api') {
+ const result = await connection.rpc.call('/deepviewer-synapse', 'request', { path: data.path, method: data.method, body: data.body })
+ if (!result.ok) throw new Error(result.error.message)
+ send('synapse:api-result', { requestId: data.requestId, value: result.value })
+ } else if (data.type === 'synapse:request-current') { sync(); publishLayout(true); send('synapse:map-opened') }
+ else if (data.type === 'synapse:close') await navigate(selected, 'chat')
+ else if (data.type === 'synapse:open-session' || data.type === 'synapse:activate-session') {
+ if (data.seq !== undefined && !safeSeq(data.seq)) throw new Error('Invalid conversation position')
+ await navigate(requireSession(data.sessionId), data.type === 'synapse:open-session' ? 'chat' : 'deepviewer-map', data.seq)
+ } else if (data.type === 'synapse:compose') {
+ // A second click must never manufacture a second native branch.
+ if (data.fork === true && pendingFork) throw new Error('A branch is already being prepared')
+ const request = ++navigationRequest
+ if (data.atSeq !== undefined && !safeSeq(data.atSeq)) throw new Error('Invalid fork boundary')
+ if (data.text !== undefined && (typeof data.text !== 'string' || data.text.length > 100000)) throw new Error('Invalid draft')
+ if (data.fork !== undefined && typeof data.fork !== 'boolean') throw new Error('Invalid compose action')
+ if (data.fork === true && data.sessionId === undefined) throw new Error('Fork source required')
+ if (data.fork === true) pendingFork = true
+ let preparedInput: ReturnType | undefined
+ let preparedScope: Context | undefined
+ let acknowledged = false
+ try {
+ let id = data.sessionId === undefined ? await create() : requireSession(data.sessionId)
+ if (data.fork === true) id = await ctx.sessions.fork({ sessionId: id, atSeq: data.atSeq, increaseTitle: true })
+ // The native editor owns attachments, model/permission controls,
+ // drafts and submission. A canvas action never sends a prompt.
+ await ctx.sessions.using(id, { source: 'deepviewerMap' }, async reference => {
+ const binding = await reference.ready
+ if (disposed) return
+ if (request !== navigationRequest) throw new Error('Conversation action was superseded')
+ const input = ctx.conversation.input.for(binding.ctx)
+ preparedInput = input; preparedScope = binding.ctx
+ if (data.text) {
+ const draft = input.state.getSnapshot().draft
+ const next = draft.trim() ? `${draft}\n\n${data.text}` : data.text
+ if (next.length > 100000) throw new Error('Draft exceeds supported length')
+ input.setDraft(next)
+ }
+ // Reply before native selection can retire this iframe. The
+ // canvas persists the new child's exact visual anchor here.
+ send('synapse:composed', { requestId: data.requestId, session: snapshot(id) })
+ acknowledged = true
+ if (!await navigate(id, 'deepviewer-map', undefined, request)) {
+ if (!disposed) throw new Error('Conversation action was superseded')
+ return
+ }
+ await new Promise(resolve => requestAnimationFrame(() => resolve()))
+ if (request === navigationRequest) input.focus()
+ })
+ } catch (error) {
+ if (!acknowledged) throw error
+ const message = error instanceof Error ? error.message : String(error)
+ // A settled preparation RPC cannot carry a later navigation
+ // failure. Surface it in the native editor and the surviving map.
+ preparedInput?.notify('error', message)
+ const current = ctx.sessions.binding(selected)
+ if (current && current.ctx !== preparedScope) ctx.conversation.input.for(current.ctx).notify('error', message)
+ send('synapse:bridge-error', { message })
+ } finally { if (data.fork === true) pendingFork = false }
+ } else if (data.type === 'synapse:create-session') {
+ const request = ++navigationRequest
+ const id = await create(data.workspaceId)
+ send('synapse:created-session', { requestId: data.requestId, session: snapshot(id) })
+ await navigate(id, 'deepviewer-map', undefined, request)
+ } else if (data.type === 'synapse:fork-session') {
+ const request = ++navigationRequest
+ if (data.atSeq !== undefined && !safeSeq(data.atSeq)) throw new Error('Invalid fork boundary')
+ const id = await ctx.sessions.fork({ sessionId: requireSession(data.sessionId), atSeq: data.atSeq, increaseTitle: true })
+ send('synapse:forked-session', { requestId: data.requestId, session: snapshot(id) })
+ await navigate(id, 'deepviewer-map', undefined, request)
+ } else if (data.type === 'synapse:send-message') {
+ throw new Error('Use the native DeepViewer composer to send messages')
+ }
+ } catch (error) { send('synapse:bridge-error', { requestId: data.requestId, message: error instanceof Error ? error.message : String(error) }) }
+ }
+ const onLoad = () => { sync(); publishLayout(true); send('synapse:map-opened') }
+ window.addEventListener('message', onMessage)
+ frame.addEventListener('load', onLoad)
+ const offSessions = ctx.sessions.list.subscribe(sync)
+ const offWorkspaces = ctx.workspaces.list.subscribe(sync)
+ const offTheme = ctx.on('theme/change', sync)
+ const offLocale = ctx.on('locale/change', sync)
+ const appearance = new MutationObserver(sync)
+ appearance.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] })
+ appearance.observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'lang'] })
+ sync()
+ return () => {
+ disposed = true
+ geometry.disconnect(); clearInspector()
+ window.removeEventListener('message', onMessage); frame.removeEventListener('load', onLoad)
+ offSessions(); offWorkspaces(); offTheme?.(); offLocale?.(); appearance.disconnect()
+ for (const observer of observers.values()) { observer.cleanup?.(); observer.reference.release() }
+ observers.clear()
+ }
+ }, [sessionId, openView, openSessionView])
+ return
+ }
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'deepviewer-map', order: 20, label: () => text('map') }, MapView))
+}
diff --git a/apps/deepviewer-adapter/client/WorkspacePanels.tsx b/apps/deepviewer-adapter/client/WorkspacePanels.tsx
new file mode 100644
index 0000000..7fd843a
--- /dev/null
+++ b/apps/deepviewer-adapter/client/WorkspacePanels.tsx
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useState } from 'react'
+import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
+import type {} from './locales.ts'
+import type { EditorStore } from './editor-store.ts'
+import { CodeEditor } from './CodeEditor.tsx'
+import css from './workspace.module.css'
+export type WorkspaceInjected = { call: (sessionId: string, method: string, payload?: Record) => Promise }
+type Base = PropsRuntime<'sidebar.right.pane.tab'> & InjectFace & PropsLocale<'deepviewer'>
+export function EditorPanel({ sessionId, useTabInfo, useStore, actions, call, t }: Base & PropsStore) {
+ const { tab } = useTabInfo(), path = (tab.navigation.params as { path?: string } | undefined)?.path
+ const file = useStore(state => path === undefined ? undefined : state.byPath[path])
+ const [error, setError] = useState(), [busy, setBusy] = useState(false)
+ const reload = useCallback(async () => {
+ if (!path || (file?.text !== file?.saved && !window.confirm(t('discardDraft')))) return
+ setBusy(true); setError(undefined)
+ try { const value = await call(sessionId, 'editor.read', { path }) as { path: string; text: string; version: string }; actions.loaded(value) }
+ catch (error) { setError(String(error)) } finally { setBusy(false) }
+ }, [path, sessionId, file?.text, file?.saved, call, actions, t])
+ useEffect(() => { if (path && !file) void reload() }, [path, file, reload])
+ const save = async () => {
+ if (!file || busy) return
+ setBusy(true); setError(undefined)
+ try { const value = await call(sessionId, 'editor.save', { path: file.path, text: file.text, version: file.version }) as { path: string; text: string; version: string }; actions.saved(value) }
+ catch (error) { setError(String(error)) } finally { setBusy(false) }
+ }
+ return
+ {path} {file && file.text !== file.saved && ● } { void save() }}>{t('save')} { void reload() }}>{t('reload')}
+ {error && {error}
}
+ {file && actions.edit(file.path, text)} onSave={() => { void save() }} />}
+
+}
+interface GitState { isRepo: boolean; root?: string; branch?: string; repositories?: string[]; entries: { path: string; xy: string }[] }
+export function GitPanel({ sessionId, useTabInfo, call, t }: Base) {
+ const { tab } = useTabInfo()
+ const [status, setStatus] = useState(), [diff, setDiff] = useState(''), [message, setMessage] = useState(''), [error, setError] = useState(), [busy, setBusy] = useState(false), [branches, setBranches] = useState([]), [repository, setRepository] = useState()
+ const refresh = useCallback(async () => {
+ if (!tab.visible) return
+ setError(undefined)
+ try { const state = await call(sessionId, 'git.status', { repository }) as GitState; setStatus(state); if (state.isRepo) setBranches((await call(sessionId, 'git.branches', { repository }) as { names: string[] }).names) }
+ catch (error) { setError(String(error)) }
+ }, [tab.visible, sessionId, repository, call])
+ useEffect(() => { void refresh() }, [refresh])
+ const run = async (method: string, payload: Record = {}) => {
+ if (busy) return
+ setBusy(true); setError(undefined)
+ try { const value = await call(sessionId, method, { ...payload, repository }); if (method === 'git.diff') setDiff(String(value)); else if (method === 'git.log') setDiff(JSON.stringify(value, null, 2)); else { await refresh(); if (method === 'git.commit') setMessage('') } }
+ catch (error) { setError(String(error)) } finally { setBusy(false) }
+ }
+ return
+ Git {status?.isRepo && { void run('git.checkout', { branch: event.target.value }) }}>{branches.map(branch => {branch} )} } { void refresh() }}>{t('reload')} { void run('git.log') }}>{t('history')}
+ {error && {error}
}
+ {status?.isRepo && (status.repositories?.length ?? 0) > 1 && { setRepository(event.target.value); setDiff('') }}>{status.repositories?.map(path => {path} )} }
+ {!status?.isRepo && {status?.repositories?.length ? setRepository(event.target.value)}>{t('repository')} {status.repositories.map(path => {path} )} : t('noRepository')}
}
+ {status?.entries.map(entry => { void run('git.diff', { path: entry.path, staged: entry.xy[0] !== ' ' && entry.xy !== '??' }) }}>{entry.xy} {entry.path} { void run('git.stage', { path: entry.path }) }}>+ { void run('git.unstage', { path: entry.path }) }}>−
)}
+ {status?.isRepo && setMessage(event.target.value)} /> entry.xy[0] !== ' ' && entry.xy !== '??')} onClick={() => { void run('git.commit', { message }) }}>{t('commit')}
}
+ {diff && {diff} }
+
+}
+export function WorkspacePanelTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>) { const { tab } = useTabInfo(); const path = (tab.navigation.params as { path?: string } | undefined)?.path; return <>{path?.split('/').at(-1) ?? tab.title}> }
diff --git a/apps/deepviewer-adapter/client/about.module.css b/apps/deepviewer-adapter/client/about.module.css
new file mode 100644
index 0000000..c88f844
--- /dev/null
+++ b/apps/deepviewer-adapter/client/about.module.css
@@ -0,0 +1,134 @@
+.root {
+ min-height: calc(100vh - 220px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ padding: 32px 24px 12vh;
+ color: var(--dsw-alias-label-primary);
+ text-align: center;
+}
+
+.icon {
+ display: block;
+ width: 112px;
+ height: 112px;
+ border-radius: 24px;
+ object-fit: contain;
+ filter: drop-shadow(0 14px 24px rgba(0, 0, 0, 0.16));
+ user-select: none;
+}
+
+.identity {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 30px;
+}
+
+.iconDark {
+ display: none;
+}
+
+:global(body[data-ds-dark-theme]) .iconLight {
+ display: none;
+}
+
+:global(body[data-ds-dark-theme]) .iconDark {
+ display: block;
+}
+
+.name {
+ margin: 0;
+ color: var(--dsw-alias-label-primary);
+ font-size: 36px;
+ line-height: 44px;
+ font-weight: 650;
+ letter-spacing: -0.03em;
+}
+
+.description {
+ max-width: 520px;
+ margin: 10px 0 0;
+ color: var(--dsw-alias-label-secondary);
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 500;
+}
+
+.version {
+ margin: 12px 0 0;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 14px;
+ line-height: 22px;
+ font-weight: 450;
+}
+
+.coreSection {
+ width: min(760px, 100%);
+ margin-top: 44px;
+ border-top: 1px solid var(--dsw-alias-border-l1);
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
+}
+
+.coreRow {
+ min-height: 76px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 32px;
+ padding: 18px 0;
+ box-sizing: border-box;
+ text-align: left;
+}
+
+.coreCopy {
+ min-width: 0;
+}
+
+.coreName {
+ margin: 0;
+ color: var(--dsw-alias-label-primary);
+ font-size: 15px;
+ line-height: 22px;
+ font-weight: 500;
+}
+
+.coreDescription {
+ margin: 4px 0 0;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 13px;
+ line-height: 20px;
+ font-weight: 400;
+}
+
+.coreVersion {
+ flex: none;
+ color: var(--dsw-alias-label-secondary);
+ font-size: 14px;
+ line-height: 22px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-height: 720px) {
+ .root {
+ min-height: 420px;
+ padding-bottom: 40px;
+ }
+
+ .icon {
+ width: 88px;
+ height: 88px;
+ border-radius: 20px;
+ }
+
+ .identity {
+ margin-top: 22px;
+ }
+
+ .coreSection {
+ margin-top: 30px;
+ }
+}
diff --git a/apps/deepviewer-adapter/client/build-info.ts b/apps/deepviewer-adapter/client/build-info.ts
new file mode 100644
index 0000000..3ecb5fd
--- /dev/null
+++ b/apps/deepviewer-adapter/client/build-info.ts
@@ -0,0 +1,2 @@
+// The staging pipeline stamps its copy from the product manifest for each build.
+export const buildInfo = { version: '0.5.0', build: 'development', kernel: '0.2.0-rc.2' }
diff --git a/apps/deepviewer-adapter/client/editor-save.ts b/apps/deepviewer-adapter/client/editor-save.ts
new file mode 100644
index 0000000..3404295
--- /dev/null
+++ b/apps/deepviewer-adapter/client/editor-save.ts
@@ -0,0 +1,5 @@
+export interface SavedEditorFile { path: string; text: string; saved: string; version: string }
+export function acknowledgeSave(current: SavedEditorFile, written: { path: string; text: string; version: string }): SavedEditorFile {
+ // A save response acknowledges the submitted text; typing during the request remains a draft.
+ return { ...current, saved: written.text, version: written.version }
+}
diff --git a/apps/deepviewer-adapter/client/editor-store.ts b/apps/deepviewer-adapter/client/editor-store.ts
new file mode 100644
index 0000000..a2078a9
--- /dev/null
+++ b/apps/deepviewer-adapter/client/editor-store.ts
@@ -0,0 +1,11 @@
+import { defineStore } from '@deepseek-ai/dsh-client-store'
+import { acknowledgeSave } from './editor-save.ts'
+export interface EditorFile { path: string; text: string; saved: string; version: string }
+export function createEditorStore() {
+ return defineStore({ init: (): { byPath: Record } => ({ byPath: {} }), persist: 'deepviewer.editor.v1', actions: {
+ loaded: (draft, file: { path: string; text: string; version: string }) => { draft.byPath[file.path] = { ...file, saved: file.text } },
+ saved: (draft, file: { path: string; text: string; version: string }) => { const current = draft.byPath[file.path]; if (current) draft.byPath[file.path] = acknowledgeSave(current, file) },
+ edit: (draft, path: string, text: string) => { const file = draft.byPath[path]; if (file) file.text = text },
+ } })
+}
+export type EditorStore = ReturnType
diff --git a/apps/deepviewer-adapter/client/index.tsx b/apps/deepviewer-adapter/client/index.tsx
new file mode 100644
index 0000000..6165ae2
--- /dev/null
+++ b/apps/deepviewer-adapter/client/index.tsx
@@ -0,0 +1,95 @@
+import { registerSynapseView } from './SynapseView.tsx'
+import typographyCss from './typography.css?inline'
+import synapseLayoutCss from './synapse-layout.css?inline'
+import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import type {} from '@deepseek-ai/dsh-client-ui-settings-general/client'
+import aboutCss from './about.module.css'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
+import type { PropsRuntime, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { EditorPanel, GitPanel, WorkspacePanelTitle } from './WorkspacePanels.tsx'
+import { createEditorStore } from './editor-store.ts'
+import { buildInfo } from './build-info.ts'
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { interface SidebarRightTabParamsMap { 'deepviewer-editor': { path: string } } }
+import { zh, en } from './locales.ts'
+import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { IWorkspaces } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { ModeSwitch } from './ModeSwitch.tsx'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { Context } from '@deepseek-ai/cordis'
+import { DeepViewerMascot } from './DeepViewerMascot.tsx'
+import css from './welcome.module.css'
+import cssText from './welcome.module.css?inline'
+
+export const inject = ['slots', 'locale', 'theme', 'uiWorkspace', 'sessions', 'workspaces', 'sidebarRight', 'sidebarRightTabs', 'connection', 'conversation', 'uiConversation']
+
+export function apply(ctx: Context): void {
+ registerSynapseView(ctx)
+ ctx.effect(() => {
+ const style = document.createElement('style')
+ style.dataset.deepviewer = 'adapter'
+ style.textContent = typographyCss + '\n' + cssText + '\n' + synapseLayoutCss
+ document.head.append(style)
+ return () => style.remove()
+ })
+ const themeSource: ObservableSnapshot = {
+ getSnapshot: () => ctx.theme.getTheme(),
+ subscribe: listener => { const off = ctx.on('theme/change', listener); return () => { off() } },
+ }
+ type ThemeInjected = { hooks: { deepviewerTheme: ObservableSnapshot } }
+ function BrandMark({ useDeepviewerTheme }: InjectFace) {
+ const theme = useDeepviewerTheme(snapshot => snapshot)
+ return
+ }
+ function BrandName() { return DeepViewer }
+ ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.register({ name: 'sidebar.brand.mark', inject: () => ({ hooks: { deepviewerTheme: themeSource } }) }, BrandMark))
+ ctx.slots.inject('sidebar.brand.name', () => ctx.slots.register({ name: 'sidebar.brand.name' }, BrandName))
+ ctx.slots.inject('conversation.hero.brand.mark', () => ctx.slots.register({ name: 'conversation.hero.brand.mark' }, DeepViewerMascot))
+ function About({ useDeepviewerTheme, t }: InjectFace & PropsLocale<'deepviewer'>) {
+ const theme = useDeepviewerTheme(snapshot => snapshot)
+ return
+
+
DeepViewer {t('version', { version: `${buildInfo.version} (${buildInfo.build})` })}
+
DeepSeek Harness
{t('coreDescription')}
{buildInfo.kernel}
+
+ }
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'about', order: 100, label: () => ctx.locale.bind('deepviewer')('about'), locale: 'deepviewer', inject: () => ({ hooks: { deepviewerTheme: themeSource } }) }, About))
+ ctx.effect(() => ctx.locale.register('deepviewer', { zh, en }))
+ const sessions = ctx.get('sessions') as ISessions
+ const workspaces = ctx.get('workspaces') as IWorkspaces
+ const change = async (mode: 'work' | 'chat') => {
+ if (mode === 'chat') ctx.uiWorkspace.openSession(await sessions.create({ agentPreset: 'deepviewer-chat' }))
+ else {
+ const workspace = workspaces.list.getSnapshot().items[0]
+ if (workspace) await ctx.uiWorkspace.openWorkspace(workspace.workspaceId)
+ else ctx.uiWorkspace.openSession(await sessions.create({ agentPreset: 'standard' }))
+ }
+ }
+ ctx.slots.inject('sidebar.mode', () => ctx.slots.register({ name: 'sidebar.mode', locale: 'deepviewer', inject: () => ({ change }) }, ModeSwitch))
+ const connection = ctx.get('connection') as ConnectionHandle
+ const call = async (sessionId: string, method: string, payload: Record = {}) => {
+ const result = await connection.rpc.call('/deepviewer-workspace', method, { ...payload, sessionId })
+ if (!result.ok) throw new Error(result.error.message)
+ return result.value
+ }
+ const text = ctx.locale.bind('deepviewer'), editorStore = createEditorStore()
+ ctx.effect(() => ctx.sidebarRightTabs.register({ id: '@deepviewer/editor', kind: 'deepviewer-editor', keepMounted: true, title: () => text('editor') }))
+ ctx.effect(() => ctx.sidebarRightTabs.register({ id: '@deepviewer/git', kind: 'deepviewer-git', keepMounted: true, title: () => 'Git', guide: [{ id: 'deepviewer-git', order: 20, title: () => 'Git', description: () => text('gitDescription') }] }))
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: '@deepviewer/editor', store: editorStore, locale: 'deepviewer', inject: () => ({ call }) }, EditorPanel))
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: '@deepviewer/git', locale: 'deepviewer', inject: () => ({ call }) }, GitPanel))
+ for (const key of ['@deepviewer/editor', '@deepviewer/git']) ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key }, WorkspacePanelTitle))
+ function EditAction({ absolutePath, t }: PropsRuntime<'sidebar.right.tab.document.actions'> & PropsLocale<'deepviewer'>) { return {
+ const target = ctx.sidebarRight.commandTarget(event.currentTarget)
+ if (target && ctx.sidebarRight.isTargetCurrent(target)) ctx.sidebarRight.openTab('deepviewer-editor', { paneId: target.paneId, params: { path: absolutePath } })
+ }}>{t('editor')} }
+ ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.document.actions', id: 'deepviewer-edit', locale: 'deepviewer' }, EditAction))
+}
diff --git a/apps/deepviewer-adapter/client/locales.ts b/apps/deepviewer-adapter/client/locales.ts
new file mode 100644
index 0000000..9eb7d6c
--- /dev/null
+++ b/apps/deepviewer-adapter/client/locales.ts
@@ -0,0 +1,5 @@
+export const zh = { map: '会话地图', mapUngrouped: '未分组', mapSessionMissing: '关联会话或工作区不可用', work: 'Work', chat: 'Chat', gitDescription: '查看工作区变更与提交记录', modes: '会话模式', editor: '编辑文件', save: '保存', reload: '重新加载', unsaved: '未保存', discardDraft: '放弃未保存的修改并重新加载?', branch: '分支', history: '提交历史', repository: '选择仓库', noRepository: '此工作区没有 Git 仓库', commitMessage: '提交说明', commit: '提交', coreDescription: 'Agent 原生内核', about: '关于 DeepViewer', version: '版本 {version}', kernel: 'DSH 内核 {version}' }
+export const en = { map: 'Session map', mapUngrouped: 'Ungrouped', mapSessionMissing: 'Session or workspace is unavailable', work: 'Work', chat: 'Chat', gitDescription: 'Review workspace changes and commit history', modes: 'Conversation mode', editor: 'Edit file', save: 'Save', reload: 'Reload', unsaved: 'Unsaved', discardDraft: 'Discard unsaved changes and reload?', branch: 'Branch', history: 'History', repository: 'Select repository', noRepository: 'No Git repository in this workspace', commitMessage: 'Commit message', commit: 'Commit', coreDescription: 'Native agent core', about: 'About DeepViewer', version: 'Version {version}', kernel: 'DSH core {version}' }
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface LocaleNamespaceMap { deepviewer: keyof typeof zh }
+}
diff --git a/apps/deepviewer-adapter/client/mode.module.css b/apps/deepviewer-adapter/client/mode.module.css
new file mode 100644
index 0000000..c89dc08
--- /dev/null
+++ b/apps/deepviewer-adapter/client/mode.module.css
@@ -0,0 +1,10 @@
+.root { flex: none; margin: 8px 0 20px; }
+.tabs { display: flex; gap: 4px; padding: 3px; border-radius: 12px; background: var(--dsw-alias-interactive-bg-hover); }
+.tabs button { display: flex; align-items: center; justify-content: center; gap: 8px; flex: 1; min-width: 0; height: 34px; border: 0; border-radius: 9px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-weight: 500; cursor: pointer; }
+.tabs button[aria-pressed='true'] { background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); }
+.tabs button:disabled { cursor: wait; }
+.tabs button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 2px; }
+.root[data-wide='false'] .tabs { flex-direction: column; }
+:global([data-platform='darwin']) .tabs button[aria-pressed='true'] { background: rgb(255 255 255 / .55); }
+:global([data-platform='darwin']) :global([data-ds-dark-theme]) .tabs button[aria-pressed='true'] { background: rgb(255 255 255 / .15); }
+.error { margin-top: 6px; font-size: 12px; color: var(--dsw-alias-state-warn-primary); overflow-wrap: anywhere; }
diff --git a/apps/deepviewer-adapter/client/synapse-layout.css b/apps/deepviewer-adapter/client/synapse-layout.css
new file mode 100644
index 0000000..66d843e
--- /dev/null
+++ b/apps/deepviewer-adapter/client/synapse-layout.css
@@ -0,0 +1,42 @@
+/* The map owns the remaining view height; the single native composer stays in flow. */
+[data-conversation-scroll]:has([data-deepviewer-map]) {
+ overflow: hidden;
+ scrollbar-gutter: auto;
+ margin-right: 0;
+}
+[data-conversation-scroll]:has([data-deepviewer-map]) > [data-slot='conversation.session'],
+[data-conversation-scroll]:has([data-deepviewer-map]) [data-deepviewer-view-area] {
+ display: flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+[data-conversation-scroll]:has([data-deepviewer-map]) > [data-deepviewer-composer-seat] {
+ position: relative;
+ inset: auto;
+ flex: none;
+ background: var(--dsw-alias-bg-base);
+}
+[data-conversation-content]:has([data-deepviewer-map]) [data-side] { display: none; }
+
+body:has([data-deepviewer-map]) [data-deepviewer-navigation-row] { flex-wrap: wrap; }
+body:has([data-deepviewer-map]) [data-deepviewer-navigation-row] > [role='tablist'] { max-width: 100%; overflow-x: auto; overflow-y: hidden; }
+body:has([data-deepviewer-map]) [data-deepviewer-session-statistics] { flex: 1 1 240px; }
+
+/* The native underline otherwise extends one pixel below a scrolling tab strip. */
+body:has([data-deepviewer-map]) [data-deepviewer-navigation-row] > [role='tablist'] > [role='tab']::after { bottom: 0; }
+
+/* With a right inspector, the map owns the full content height. The single native
+ composer occupies only its left column; the iframe reserves its measured height. */
+[data-conversation-content][data-deepviewer-map-inspector] > [data-conversation-scroll] { position: relative; }
+[data-conversation-content][data-deepviewer-map-inspector] > [data-conversation-scroll] > [data-deepviewer-composer-seat] {
+ position: absolute;
+ top: auto;
+ bottom: 0;
+ left: 0;
+ right: var(--deepviewer-map-inspector-width);
+ width: auto;
+ background: var(--dsw-alias-bg-base);
+}
diff --git a/apps/deepviewer-adapter/client/typography.css b/apps/deepviewer-adapter/client/typography.css
new file mode 100644
index 0000000..2a543a7
--- /dev/null
+++ b/apps/deepviewer-adapter/client/typography.css
@@ -0,0 +1,11 @@
+/* Shared product font, bundled with the app; no dependency on installed fonts. */
+@font-face {
+ font-family: 'Smiley Sans';
+ src: url('/assets/deepviewer/fonts/SmileySans-Oblique.woff2') format('woff2');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+:root {
+ --deepviewer-font-smiley: 'Smiley Sans', var(--dsw-font-family, sans-serif);
+}
diff --git a/apps/deepviewer-adapter/client/welcome.module.css b/apps/deepviewer-adapter/client/welcome.module.css
new file mode 100644
index 0000000..85cc52c
--- /dev/null
+++ b/apps/deepviewer-adapter/client/welcome.module.css
@@ -0,0 +1,30 @@
+.brandLogo {
+ display: block;
+ width: 106px;
+ height: 112px;
+ flex: none;
+ color: var(--dsw-alias-label-primary);
+ background-color: currentColor;
+ mask: url('/assets/deepviewer/deepviewer-loading-logo.svg') center / contain no-repeat;
+ user-select: none;
+}
+:global([data-deepviewer-hero]) { padding: 0 20px 30px; }
+:global([data-deepviewer-headline]) { flex-direction: column; align-items: center; gap: 20px; }
+:global([data-deepviewer-headline] > span:first-child) { width: auto; height: auto; }
+:global([data-deepviewer-hero-title]) { font-family: var(--deepviewer-font-smiley); font-weight: 400; font-size: clamp(24px, 3vw, 30px); font-synthesis: none; }
+:global([data-deepviewer-hero-title] > span:nth-child(2)) { display: none; }
+.brandName { font-size: 18px; font-weight: 650; letter-spacing: -.02em; }
+.brandMark { display: block; width: 24px; height: 24px; border-radius: 6px; }
+.about { display: flex; align-items: center; gap: 16px; padding: 12px 0; }
+.aboutIcon { width: 64px; height: 64px; border-radius: 16px; }
+.aboutTitle { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
+.aboutVersion { color: var(--dsw-alias-label-secondary); margin: 0; }
+
+.editAction { flex: none; white-space: nowrap; font-family: inherit; }
+
+/* Use the conversation's live typography setting and theme foreground. */
+:global(body [data-chat-running]) {
+ color: var(--dsw-alias-label-secondary);
+ font-size: var(--dsh-content-font-size, 14px);
+ --dsw-alias-label-shimmer: var(--dsw-alias-label-primary);
+}
diff --git a/apps/deepviewer-adapter/client/workspace.module.css b/apps/deepviewer-adapter/client/workspace.module.css
new file mode 100644
index 0000000..a08fa3c
--- /dev/null
+++ b/apps/deepviewer-adapter/client/workspace.module.css
@@ -0,0 +1,10 @@
+.panel { height: 100%; display: flex; flex-direction: column; min-height: 0; color: var(--dsw-alias-label-primary); font-size: 13px; }
+.toolbar { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
+.panel button, .panel input, .panel select { font: inherit; background: var(--dsw-alias-interactive-bg-hover); color: inherit; border: 0; border-radius: 6px; padding: 5px 8px; }
+.panel button { cursor: pointer; } .panel button:disabled { opacity: .4; cursor: default; }
+.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.editor { flex: 1; resize: none; border: 0; outline: none; padding: 16px; background: transparent; color: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.65; tab-size: 2; }
+.file { display: flex; gap: 4px; align-items: center; padding: 4px 10px; } .filePath { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.commit { display: flex; gap: 8px; margin: 10px; } .commit input { flex: 1; min-width: 0; }
+.diff { flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 12px; font: 12px/1.6 ui-monospace, monospace; }
+.error { padding: 10px 12px; color: var(--dsw-alias-state-error-primary); } .empty { padding: 20px; color: var(--dsw-alias-label-secondary); }
diff --git a/apps/deepviewer-adapter/compatibility/branding-contracts.mjs b/apps/deepviewer-adapter/compatibility/branding-contracts.mjs
new file mode 100644
index 0000000..c5b4568
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/branding-contracts.mjs
@@ -0,0 +1,182 @@
+export const brandingContracts = [
+ {
+ "id": "desktop-copy-33",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " aboutMenu: 'About DeepSeek Harness',",
+ "after": " aboutMenu: 'About DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-34",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " aboutProduct: 'DeepSeek Harness',",
+ "after": " aboutProduct: 'DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged.",
+ "count": 2
+ },
+ {
+ "id": "desktop-copy-36",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " hideApplication: 'Hide DeepSeek Harness',",
+ "after": " hideApplication: 'Hide DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-39",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " quitApplication: 'Quit DeepSeek Harness',",
+ "after": " quitApplication: 'Quit DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-40",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " openApplication: 'Open DeepSeek Harness',",
+ "after": " openApplication: 'Open DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-43",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " quitTitle: 'Quit DeepSeek Harness?',",
+ "after": " quitTitle: 'Quit DeepViewer?',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-58",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " startupFailed: 'DeepSeek Harness is unavailable',",
+ "after": " startupFailed: 'DeepViewer is unavailable',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-68",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " welcomeTitle: 'DeepSeek Harness',",
+ "after": " welcomeTitle: 'DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged.",
+ "count": 2
+ },
+ {
+ "id": "desktop-copy-69",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " welcomeBrand: 'DeepSeek Harness',",
+ "after": " welcomeBrand: 'DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged.",
+ "count": 2
+ },
+ {
+ "id": "desktop-copy-71",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " welcomeTaglineBrand: 'DeepSeek Harness',",
+ "after": " welcomeTaglineBrand: 'DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged.",
+ "count": 2
+ },
+ {
+ "id": "desktop-copy-132",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " updateTitle: 'DeepSeek Harness Update',",
+ "after": " updateTitle: 'DeepViewer Update',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-204",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " aboutMenu: '关于 DeepSeek Harness',",
+ "after": " aboutMenu: '关于 DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-207",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " hideApplication: '隐藏 DeepSeek Harness',",
+ "after": " hideApplication: '隐藏 DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-210",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " quitApplication: '退出 DeepSeek Harness',",
+ "after": " quitApplication: '退出 DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-211",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " openApplication: '打开 DeepSeek Harness',",
+ "after": " openApplication: '打开 DeepViewer',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-214",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " quitTitle: '退出 DeepSeek Harness?',",
+ "after": " quitTitle: '退出 DeepViewer?',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-229",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " startupFailed: 'DeepSeek Harness 无法使用',",
+ "after": " startupFailed: 'DeepViewer 无法使用',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "desktop-copy-303",
+ "file": "apps/desktop/src/locale.ts",
+ "before": " updateTitle: 'DeepSeek Harness 更新',",
+ "after": " updateTitle: 'DeepViewer 更新',",
+ "reason": "Desktop product copy; provider names and attribution remain unchanged."
+ },
+ {
+ "id": "boot-wordmark",
+ "file": "packages/client/web/src/boot-page.ts",
+ "before": "this.wordmark = div(css.wordmark, 'HARNESS')",
+ "after": "this.wordmark = div(css.wordmark, 'DeepViewer')\n const icon = document.createElement('img')\n icon.src = '/assets/deepviewer/icon-light.png'\n icon.alt = ''\n icon.width = 72; icon.height = 72\n icon.style.cssText = 'display:block;margin:0 auto 14px'\n this.wordmark.prepend(icon)",
+ "reason": "Framework-free DeepViewer startup screen retains the native failure report."
+ },
+ {
+ "id": "boot-no-slogan",
+ "file": "packages/client/web/src/boot-page.ts",
+ "before": "this.hint = div(css.hint, 'Loading plugins…')",
+ "after": "this.hint = div(css.hint)",
+ "reason": "Product startup has no slogan."
+ },
+ {
+ "id": "web-title",
+ "file": "apps/web/index.html",
+ "before": "DSH Local Build ",
+ "after": "DeepViewer ",
+ "reason": "Product window title."
+ },
+ {
+ "id": "desktop-startup-ready",
+ "file": "apps/desktop/src/main.ts",
+ "before": "async function main(): Promise {",
+ "after": "async function main(): Promise {\n await (globalThis as typeof globalThis & { __DEEPVIEWER_HOST_ENVIRONMENT__?: Promise }).__DEEPVIEWER_HOST_ENVIRONMENT__",
+ "reason": "Complete allowlisted frontend migration and PAC initialization before rendering any window."
+ },
+ {
+ "id": "desktop-build-number",
+ "file": "apps/desktop/src/main.ts",
+ "before": " version: '',",
+ "after": " version: process.env.DEEPVIEWER_BUILD_NUMBER ?? '',",
+ "reason": "DeepViewer has a monotonic build number independent of the kernel version."
+ },
+ {
+ "id": "desktop-icon",
+ "file": "apps/desktop/src/main.ts",
+ "before": "const applicationIconPath = development ? join(app.getAppPath(), 'resources', 'icon-windows.png')",
+ "previousAfter": "const applicationIconPath = development ? join(app.getAppPath(), '.desktop', 'native-icon', 'icon-light.png')",
+ "after": "const applicationIconPath = development ? join(app.getAppPath(), '.desktop', 'native-icon', 'DeepViewerDockThemes', 'light.png')",
+ "reason": "Use the product icon for the native About panel."
+ },
+ {
+ "id": "isolated-command-management",
+ "file": "apps/desktop/src/main.ts",
+ "before": " ...process.platform === 'darwin' || process.platform === 'win32'\n ? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [],",
+ "after": " ...(process.env.DEEPVIEWER_DESKTOP !== '1' && (process.platform === 'darwin' || process.platform === 'win32'))\n ? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [],",
+ "reason": "DeepViewer does not replace the independent official DSH command registration."
+ }
+]
diff --git a/apps/deepviewer-adapter/compatibility/chat-contracts.mjs b/apps/deepviewer-adapter/compatibility/chat-contracts.mjs
new file mode 100644
index 0000000..3a773ed
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/chat-contracts.mjs
@@ -0,0 +1,245 @@
+export const chatContracts = [
+ { id: 'chat-history-projection-parameter', file: 'packages/api/session-controller/src/history.ts', before: ' withProjections: boolean,', after: ' _withProjections: boolean,', reason: 'History always observes the preset needed to authorize workspace-free Chat.' },
+ { id: 'chat-create-cwd-match', file: 'packages/api/session-controller/src/agent.ts', before: 'if (agent.session.header.cwd !== cwd) {', after: "if (agent.session.header.cwd !== (presetId === 'deepviewer-chat' ? undefined : cwd)) {", reason: 'Workspace-free Chat creation must validate its absent cwd instead of the Host default directory.' },
+ { id: 'chat-adopt-cwd-match', file: 'packages/api/session-controller/src/agent.ts', before: 'if (observation.header.cwd !== cwd) {', after: "if (observation.header.cwd !== (presetId === 'deepviewer-chat' ? undefined : cwd)) {", reason: 'Explicit-id Chat adoption cannot silently bind a workspace.' },
+ { id: 'chat-resume-cwd-free', file: 'packages/api/session-controller/src/agent.ts', before: 'if (observation.header.id !== sessionId || observation.header.cwd === undefined) {', after: "if (observation.header.id !== sessionId || (observation.header.cwd === undefined && this.presetForObservation(observation) !== 'deepviewer-chat')) {", reason: 'A cold Chat Agent is valid without a workspace; unknown workspace-free sessions stay rejected.' },
+ { id: 'chat-inspect-projections', file: 'packages/api/session-controller/src/agent.ts', before: " projectionMode: 'none',", after: " projectionMode: 'all',", reason: 'Inspect must prove a workspace-free session is Chat from its persisted native preset projection.' },
+ { id: 'chat-inspect-cwd-free', file: 'packages/api/session-controller/src/agent.ts', before: 'if (observation.header.cwd === undefined) {', after: "if (observation.header.cwd === undefined && observation.projections?.values.agentPreset !== 'deepviewer-chat') {", reason: 'Read cold Chat attachments/history while preserving ordinary Session authorization.' },
+ { id: 'chat-history-projections', file: 'packages/api/session-controller/src/history.ts', before: "projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',", after: "projectionMode: 'all',", reason: 'Cold history needs a native preset projection to authorize workspace-free Chat.' },
+ { id: 'chat-history-cwd-free', file: 'packages/api/session-controller/src/history.ts', before: 'if (observation.header.cwd === undefined) {', after: "if (observation.header.cwd === undefined && observation.projections?.values.agentPreset !== 'deepviewer-chat') {", reason: 'History may read workspace-free Chat without opening a project Agent.' },
+ {
+ "id": "chat-commands.ts-0",
+ "file": "packages/api/session-controller/src/commands.ts",
+ "before": "import { randomUUID } from 'node:crypto'\nimport type { Context } from '@deepseek-ai/cordis'\nimport { brandString } from '@deepseek-ai/dsh-brand'\nimport type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'\nimport { AttachmentError } from '@deepseek-ai/dsh-attachment'\nimport type {\n AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,\n} from '@deepseek-ai/dsh-attachment'\nimport type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'\nimport type {} from '@deepseek-ai/dsh-client-file-upload'\n",
+ "after": "import { randomUUID } from 'node:crypto'\nimport type { Context } from '@deepseek-ai/cordis'\nimport { brandString } from '@deepseek-ai/dsh-brand'\nimport type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'\nimport { AttachmentError } from '@deepseek-ai/dsh-attachment'\nimport { expandChatAttachments } from './chat-attachments.ts'\nimport type {\n AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,\n} from '@deepseek-ai/dsh-attachment'\nimport type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'\nimport type {} from '@deepseek-ai/dsh-client-file-upload'\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-commands.ts-1",
+ "file": "packages/api/session-controller/src/commands.ts",
+ "before": " * @returns the Session identity and resolved preset when configured.\n */\n async create(request: SessionCreateRequest): Promise {\n if (request.workspaceId !== undefined && request.cwd !== undefined) {\n throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})\n }\n const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`)\n let workspace: Workspace | undefined\n if (request.workspaceId !== undefined) {\n workspace = this.ctx.workspaceRegistry.get(request.workspaceId)\n",
+ "after": " * @returns the Session identity and resolved preset when configured.\n */\n async create(request: SessionCreateRequest): Promise {\n if (request.workspaceId !== undefined && request.cwd !== undefined) {\n throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})\n }\n if (request.agentPreset === 'deepviewer-chat' && (request.workspaceId !== undefined || request.cwd !== undefined)) {\n throw new RemoteError('gateway/bad-request', 'Chat sessions do not accept a workspace or working directory', {})\n }\n const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`)\n let workspace: Workspace | undefined\n if (request.workspaceId !== undefined) {\n workspace = this.ctx.workspaceRegistry.get(request.workspaceId)\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-commands.ts-2",
+ "file": "packages/api/session-controller/src/commands.ts",
+ "before": " const admission = resolvePromptFileReceipts(\n request.content,\n receiptId => this.ctx.fileUploads.resolve(agent, receiptId),\n )\n const content = await this.ctx.attachments.admitPromptContent(admission.content)\n const message: UserMessage = createUserMessage({ content, source })\n if (this.ctx.agents.get(agent.id) !== agent) {\n throw new RemoteError(\n 'session/not-found',\n `session \"${agent.id}\" was disposed during prompt admission`,\n",
+ "after": " const admission = resolvePromptFileReceipts(\n request.content,\n receiptId => this.ctx.fileUploads.resolve(agent, receiptId),\n )\n const content = await this.ctx.attachments.admitPromptContent(admission.content)\n if (this.agents.presetForSession(agent.session) === 'deepviewer-chat') {\n await expandChatAttachments(this.ctx.attachments, content)\n }\n const message: UserMessage = createUserMessage({ content, source })\n if (this.ctx.agents.get(agent.id) !== agent) {\n throw new RemoteError(\n 'session/not-found',\n `session \"${agent.id}\" was disposed during prompt admission`,\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-types.ts-0",
+ "file": "packages/api/session-controller/src/types.ts",
+ "before": "}\n\n/** Session search request. */\nexport interface SessionSearchRequest {\n readonly query: string\n}\n\n/** Session search response value. */\nexport interface SessionSearchValue {\n readonly items: readonly SessionSearchItem[]\n",
+ "after": "}\n\n/** Session search request. */\nexport interface SessionSearchRequest {\n readonly query: string\n readonly mode?: 'chat' | 'work'\n}\n\n/** Session search response value. */\nexport interface SessionSearchValue {\n readonly items: readonly SessionSearchItem[]\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-index.ts-0",
+ "file": "packages/api/session-controller/src/index.ts",
+ "before": " * @param signal - cancellation for list and search reads.\n * @returns authorized bounded Session search results.\n */\n @Remote('search')\n search(request: SessionSearchRequest, signal: AbortSignal): Promise {\n return this.listState.search(request.query, signal)\n }\n\n /**\n * Create or idempotently adopt one ordinary Session.\n * @param request - requested identity, location, and Agent preset.\n",
+ "after": " * @param signal - cancellation for list and search reads.\n * @returns authorized bounded Session search results.\n */\n @Remote('search')\n search(request: SessionSearchRequest, signal: AbortSignal): Promise {\n return this.listState.search(request.query, signal, request.mode)\n }\n\n /**\n * Create or idempotently adopt one ordinary Session.\n * @param request - requested identity, location, and Agent preset.\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-0",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " * Search current visible message content without activating any matching Session.\n * @param query - literal message-content query.\n * @param signal - cancellation for list and search reads.\n * @returns authorized bounded Session search results.\n */\n async search(query: string, signal: AbortSignal): Promise {\n const normalizedQuery = normalizeSearchQuery(query)\n signal.throwIfAborted()\n const provider = this.ctx.get('sessionQuery')\n if (provider === undefined) {\n throw new RemoteError(\n",
+ "after": " * Search current visible message content without activating any matching Session.\n * @param query - literal message-content query.\n * @param signal - cancellation for list and search reads.\n * @returns authorized bounded Session search results.\n */\n async search(query: string, signal: AbortSignal, mode?: 'chat' | 'work'): Promise {\n const normalizedQuery = normalizeSearchQuery(query)\n signal.throwIfAborted()\n const provider = this.ctx.get('sessionQuery')\n if (provider === undefined) {\n throw new RemoteError(\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-1",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " }\n try {\n const visible = await provider.listSessions(signal)\n signal.throwIfAborted()\n const visibleIds = new Set(visible\n .filter(record => record.header.cwd !== undefined)\n .map(record => record.header.id))\n if (visibleIds.size === 0) return { items: [], hasMore: false }\n const authorized: SessionSearchItem[] = []\n const acceptedIds = new Set()\n const seenCursors = new Set()\n",
+ "after": " }\n try {\n const visibleIds = mode === undefined\n ? new Set((await provider.listSessions(signal)).filter(record => record.header.cwd !== undefined).map(record => record.header.id))\n : new Set((await this.list(signal)).filter(summary => {\n const preset = summary.projections?.values.agentPreset\n return mode === 'chat' ? preset === 'deepviewer-chat' : summary.cwd !== undefined && preset !== 'deepviewer-chat'\n }).map(summary => summary.sessionId))\n signal.throwIfAborted()\n if (visibleIds.size === 0) return { items: [], hasMore: false }\n const authorized: SessionSearchItem[] = []\n const acceptedIds = new Set()\n const seenCursors = new Set()\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-2",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " if (record.header.cwd === undefined) continue\n cold.push(record.header)",
+ "after": " cold.push(record.header)",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-3",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " for (const header of cold) items.push(this.summarizeCold(header))",
+ "after": " for (const header of cold) {\n const summary = await this.summarizeCold(header, signal)\n // Work is workspace-bound; Chat remains visible while detached and cwd-free.\n if (header.cwd !== undefined || summary.projections?.values.agentPreset === 'deepviewer-chat') items.push(summary)\n }",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-4",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " private summarizeCold(header: SessionHeader): SessionSummary {\n const projections = this.projectionsFor(header, undefined)",
+ "after": " private async summarizeCold(header: SessionHeader, signal?: AbortSignal): Promise {\n const projections = await this.coldProjections(header, signal)",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "chat-list.ts-5",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": " private projectionsFor(\n",
+ "after": " /** Recover list classification and titles after cache loss without activating an Agent. */\n private async coldProjections(header: SessionHeader, signal?: AbortSignal): Promise {\n signal?.throwIfAborted()\n const cached = this.projectionsFor(header, undefined)\n if (cached?.values.agentPreset !== undefined && cached.values.sessionListMetadata !== undefined) return cached\n try {\n const handle = await this.ctx.sessionPersistence.open(header.id, 'read', signal === undefined ? undefined : { signal })\n try {\n const { events } = await handle.read(0, Number.MAX_SAFE_INTEGER, signal === undefined ? undefined : { signal })\n signal?.throwIfAborted()\n const cache = this.ctx.get('sessionProjectionCache')\n const snapshot = cache !== undefined\n ? cache.coldSnapshot(handle.header, handle.inheritedEventCount, events)\n : this.ctx.sessionProjections.restore({}, events, SessionLogOffset(0), handle.header, handle.inheritedEventCount).snapshot\n return { kind: 'cached', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues }\n } finally { await handle.close() }\n } catch (error) {\n signal?.throwIfAborted()\n this.ctx.logger.warn(`api-session.list: could not rebuild metadata for \"${header.id}\": ${String(error)}`)\n return cached\n }\n }\n\n private projectionsFor(\n",
+ "reason": "Preserve workspace-free Chat and cold mode-filtered history at the Session API boundary."
+ },
+ {
+ "id": "deepviewer-mode-11",
+ "file": "packages/api/session-controller/src/list.ts",
+ "before": "import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'",
+ "after": "import { SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type SessionId } from '@deepseek-ai/dsh-session'",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-12",
+ "file": "packages/api/session-controller/src/agent.ts",
+ "before": " try {\n await mkdir(cwd, { recursive: true })",
+ "after": " try {\n if (presetId !== 'deepviewer-chat') await mkdir(cwd, { recursive: true })",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-13",
+ "file": "packages/api/session-controller/src/agent.ts",
+ "before": " meta: {\n cwd,",
+ "after": " meta: {\n ...(presetId === 'deepviewer-chat' ? {} : { cwd }),",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-14",
+ "file": "packages/api/session-controller/src/client/contract/sessions.ts",
+ "before": " create(opts?: {\n workspaceId?: WorkspaceId",
+ "after": " create(opts?: {\n agentPreset?: string\n workspaceId?: WorkspaceId",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-15",
+ "file": "packages/api/session-controller/src/client/sessions/service.ts",
+ "before": "async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }",
+ "after": "async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-16",
+ "file": "packages/api/session-controller/src/client/sessions/manager.ts",
+ "before": " opts: {\n workspaceId?: WorkspaceId",
+ "after": " opts: {\n agentPreset?: string\n workspaceId?: WorkspaceId",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-17",
+ "file": "packages/api/session-controller/src/client/sessions/manager.ts",
+ "before": "const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }",
+ "after": "const shared = { ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), ...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }) }",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-18",
+ "file": "packages/api/session-controller/src/client/contract/sessions.ts",
+ "before": " query: string,\n signal: AbortSignal,",
+ "after": " query: string,\n signal: AbortSignal,\n mode?: 'chat' | 'work',",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-19",
+ "file": "packages/api/session-controller/src/client/sessions/service.ts",
+ "before": " query: string,\n signal: AbortSignal,",
+ "after": " query: string,\n signal: AbortSignal,\n mode?: 'chat' | 'work',",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-20",
+ "file": "packages/api/session-controller/src/client/sessions/manager.ts",
+ "before": " query: string,\n signal: AbortSignal,",
+ "after": " query: string,\n signal: AbortSignal,\n mode?: 'chat' | 'work',",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-21",
+ "file": "packages/api/session-controller/src/client/sessions/service.ts",
+ "before": "return this.manager.search(query, signal)",
+ "after": "return this.manager.search(query, signal, mode)",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-22",
+ "file": "packages/api/session-controller/src/client/sessions/manager.ts",
+ "before": "this.remote.session.search({ query }, signal)",
+ "after": "this.remote.session.search({ query, ...(mode === undefined ? {} : { mode }) }, signal)",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-23",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx",
+ "before": " const inputState = useInput(s => s)",
+ "after": " const isChat = useSessions(s => sessionId !== undefined && s.byId[sessionId]?.projectionValues?.agentPreset === 'deepviewer-chat')\n const inputState = useInput(s => s)",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-24",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx",
+ "before": " const heroWorkspaceRow = (",
+ "after": " const heroWorkspaceRow = isChat ? null : (",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-25",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx",
+ "before": "const inert = sessionId === undefined || (hero && chipTitle === undefined)",
+ "after": "const inert = sessionId === undefined || (!isChat && hero && chipTitle === undefined)",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-26",
+ "file": "packages/client/ui-sidebar/src/client/contract/slots.ts",
+ "before": " 'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps }",
+ "after": " 'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps }\n 'sidebar.mode': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-27",
+ "file": "packages/client/ui-sidebar/src/client/contract/slots.ts",
+ "before": " | 'sidebar.brand.name'",
+ "after": " | 'sidebar.brand.name'\n | 'sidebar.mode'",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-28",
+ "file": "packages/client/ui-sidebar/src/client/index.ts",
+ "before": " 'sidebar.brand.name': { kind: 'single', scope: 'root' },",
+ "after": " 'sidebar.brand.name': { kind: 'single', scope: 'root' },\n 'sidebar.mode': { kind: 'single', scope: 'root' },",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-29",
+ "file": "packages/client/ui-sidebar/src/client/SidebarRoot.tsx",
+ "before": " {/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */}",
+ "after": " {renderSlot('sidebar.mode', { wide, expandSidebar: () => { if (collapsed) toggleSidebar() } })}\n {/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */}",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-30",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": " const list = useSessions(state => state)",
+ "after": " const allSessions = useSessions(state => state)\n const isChat = Object.values(allSessions.byId).some(row => (row.retainedBy.mainView ?? 0) > 0 && row.projectionValues?.agentPreset === 'deepviewer-chat')\n const list = useMemo(() => ({ ...allSessions, ids: allSessions.ids.filter(id => (allSessions.byId[id]?.projectionValues?.agentPreset === 'deepviewer-chat') === isChat) }), [allSessions, isChat])",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-31",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": " const groupBy = useStore(s => s.groupBy)",
+ "after": " const storedGroupBy = useStore(s => s.groupBy)\n const groupBy = isChat ? 'flat' : storedGroupBy",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ },
+ {
+ "id": "deepviewer-mode-32",
+ "file": "packages/client/ui-workspace/src/client/navigation.ts",
+ "before": " startSession(workspaceId?: WorkspaceId): void {\n const workspace",
+ "after": " startSession(workspaceId?: WorkspaceId): void {\n const currentId = this.mainReference?.sessionId\n if (workspaceId === undefined && currentId !== undefined && this.sessions.list.getSnapshot().byId[currentId]?.projectionValues?.agentPreset === 'deepviewer-chat') {\n void this.sessions.create({ agentPreset: 'deepviewer-chat' }).then(id => this.openSession(id)).catch((error: unknown) => console.warn('Chat creation failed:', error))\n return\n }\n const workspace",
+ "reason": "DeepViewer mode policy uses native controllers and stores."
+ }
+,
+{"id": "chat-host-source", "file": "packages/api/session-controller/tsconfig.host.json", "before": " \"src/remote-events.ts\",\n \"src/agent.ts\",\n \"src/assistant-stream.ts\",\n \"src/catalog.ts\",\n \"src/commands.ts\",\n \"src/control.ts\",\n \"src/file-references.ts\",\n \"src/history.ts\",\n \"src/list.ts\",\n \"src/media-references.ts\",\n", "after": " \"src/remote-events.ts\",\n \"src/agent.ts\",\n \"src/assistant-stream.ts\",\n \"src/catalog.ts\",\n \"src/commands.ts\",\n \"src/chat-attachments.ts\",\n \"src/control.ts\",\n \"src/file-references.ts\",\n \"src/history.ts\",\n \"src/list.ts\",\n \"src/media-references.ts\",\n", "reason": "Compile the bounded Chat attachment expansion in the host face."}
+,
+{"id": "chat-search-filter", "file": "packages/client/ui-workspace/src/client/index.ts", "before": "sessions.search(query, signal)", "after": "sessions.search(query, signal, Object.values(sessions.list.getSnapshot().byId).some(row => (row.retainedBy.mainView ?? 0) > 0 && row.projectionValues?.agentPreset === 'deepviewer-chat') ? 'chat' : 'work')", "reason": "Apply the active product mode before native search pagination."}
+]
diff --git a/apps/deepviewer-adapter/compatibility/contracts.mjs b/apps/deepviewer-adapter/compatibility/contracts.mjs
new file mode 100644
index 0000000..477a708
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/contracts.mjs
@@ -0,0 +1,81 @@
+import { presentationContracts } from '../presentation/contracts.mjs'
+import { toneContracts } from './tone-contracts.mjs'
+import { chatContracts } from './chat-contracts.mjs'
+import { brandingContracts } from './branding-contracts.mjs'
+import { nativeModuleContracts } from './native-module-contracts.mjs'
+/** Narrow extension gaps in the pinned upstream. Remove a contract when its seam is upstreamed. */
+export const contracts = [
+ {
+ id: 'client-artifact-metadata-only-rebuild', file: 'packages/client/modules/src/index.ts',
+ before: ' const bundle = readFileSync(record.meta.clientPath)\n record.baseline = baseline\n record.entry = graphRow(id, rev, record.meta)',
+ after: ' const bundle = readFileSync(record.meta.clientPath)\n // File providers may change ctime while synchronizing unchanged artifacts.\n // Keep mtime stamps authoritative: they also announce sibling chunk builds.\n if (baseline.mtimeMs === record.baseline.mtimeMs && baseline.size === record.baseline.size && bundle.equals(record.bundle)) {\n record.baseline = baseline\n return record.entry.rev\n }\n record.baseline = baseline\n record.entry = graphRow(id, rev, record.meta)',
+ reason: 'Metadata-only file-provider changes must not tear down the live renderer; content edits and stamped chunk builds still publish.',
+ },
+ { id: 'dedicated-rpc-explicit-owner-type', file: 'packages/client/connection/src/rpc.ts', before: ' channel: string,\n handler: ConnectionRpcHandler,\n ): () => Promise', after: ' channel: string,\n handler: ConnectionRpcHandler,\n owner?: import("@deepseek-ai/cordis").Context,\n ): () => Promise', reason: 'Cordis 4 service getter shadows otherwise bind dedicated routes to the service provider instead of the consumer lifetime.' },
+ { id: 'dedicated-rpc-explicit-owner', file: 'packages/client/connection/src/rpc-host.ts', before: 'handle: (channel, handler) => this.register(owner, channel, handler),', after: 'handle: (channel, handler, consumer) => this.register(consumer ?? owner, channel, handler),', reason: 'Explicit caller ownership preserves plugin unload and injection boundaries for dedicated channels.' },
+ ...chatContracts,
+ ...toneContracts,
+ ...brandingContracts,
+ ...nativeModuleContracts,
+ ...presentationContracts,
+ { id: 'optional-official-onboarding', file: 'apps/desktop/src/main.ts', before: "import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts'", after: "import { WELCOME_IPC, needsWelcome as officialNeedsWelcome, type WelcomeNotice } from './welcome-api.ts'\nconst needsWelcome: typeof officialNeedsWelcome = authentication => process.env.DEEPVIEWER_DESKTOP !== '1' && officialNeedsWelcome(authentication)", reason: 'Optional official account onboarding is desktop product policy, never a client environment variable.' },
+ {
+ id: 'desktop-product-name', file: 'apps/desktop/src/main.ts',
+ before: "applicationName: 'DeepSeek Harness',", after: 'applicationName: app.name,',
+ reason: 'The native About panel must use the product identity supplied before boot.',
+ },
+ {
+ id: 'private-host-port', file: 'apps/desktop-host/src/index.ts',
+ before: "args: ['--no-open', '--port', '19387'],",
+ after: "args: ['--no-open', '--port', process.env.DEEPVIEWER_HOST_PORT ?? '19387'],",
+ reason: 'Independent DeepViewer/DeepViewer Dev/official DSH processes must not share a fixed port.',
+ },
+ {
+ id: 'product-update-channel', file: 'apps/desktop/src/update-coordinator.ts',
+ before: "this.updater.channel = 'nightly'", after: "this.updater.channel = process.env.DEEPVIEWER_UPDATE_CHANNEL ?? 'nightly'",
+ reason: 'Separate DeepViewer stable/preview feeds from the official nightly channel.',
+ },
+ {
+ id: 'stable-prerelease-policy', file: 'apps/desktop/src/update-coordinator.ts',
+ before: 'this.updater.allowPrerelease = true',
+ after: "this.updater.allowPrerelease = process.env.DEEPVIEWER_UPDATE_CHANNEL !== 'latest'",
+ reason: 'A stable DeepViewer feed must not install prereleases.',
+ },
+ {
+ id: 'hero-style-anchor', file: 'packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx',
+ before: '', after: '
',
+ reason: 'Stable styling anchor for the native hero; no replacement Conversation component.',
+ },
+ {
+ id: 'hero-headline-style-anchor', file: 'packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx',
+ before: '
', after: '
',
+ reason: 'Style the native mark/headline vertically without matching generated CSS names.',
+ },
+ {
+ id: 'hero-title-style-anchor', file: 'packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx',
+ before: '
', after: '',
+ reason: 'Apply the installed welcome font and suppress the upstream product badge.',
+ },
+ {
+ id: 'stats-header-slot', file: 'packages/client/ui-chat/src/client/apply.ts',
+ before: "ctx.slots.inject('conversation.composer.dock', () =>\n ctx.slots.register({\n name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,",
+ previousAfter: "ctx.slots.inject('conversation.session.header.utilities', () =>\n ctx.slots.register({\n name: 'conversation.session.header.utilities', id: 'stats', order: 0, locale: NS,",
+ after: "ctx.slots.inject('conversation.session.header.center', () =>\n ctx.slots.register({\n name: 'conversation.session.header.center', id: 'stats', order: 0, locale: NS,",
+ reason: 'Use the same native statistics component in the session header slot.',
+ },
+ {"id": "deepviewer-copy-0", "file": "packages/client/ui-conversation/src/client/locales.ts", "before": "'hero.headline': '探索未至之境'", "after": "'hero.headline': '让我们做点什么'", "reason": "Product copy until native locale overrides are available."},
+ {"id": "deepviewer-copy-1", "file": "packages/client/ui-conversation/src/client/locales.ts", "before": "'hero.headline': 'Into the Unknown'", "after": "'hero.headline': 'What shall we build?'", "reason": "Product copy until native locale overrides are available."},
+ {"id": "deepviewer-copy-2", "file": "packages/client/ui-chat/src/client/locale.ts", "before": "'message.stepProcess.thinking': '正在分析请求'", "after": "'message.stepProcess.thinking': '深度求索中'", "reason": "Product copy until native locale overrides are available."},
+ {"id": "deepviewer-copy-3", "file": "packages/client/ui-chat/src/client/locale.ts", "before": "'message.think': '思考'", "after": "'message.think': '深度求索中'", "reason": "Product copy until native locale overrides are available."},
+ {"id": "deepviewer-thinking-en", "file": "packages/client/ui-chat/src/client/locale.ts", "before": "'message.stepProcess.thinking': 'Analyzing the request'", "after": "'message.stepProcess.thinking': 'Thinking'", "reason": "Use the DeepViewer thinking label."},
+ {"id": "deepviewer-think-en", "file": "packages/client/ui-chat/src/client/locale.ts", "before": "'message.think': 'Think'", "after": "'message.think': 'Thinking'", "reason": "Use the DeepViewer thinking label."},
+ {"id": "external-plugin-build", "file": "packages/client/tsdown.client.ts", "before": " for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {", "after": " const externalPath = process.env.DSH_EXTERNAL_WORKSPACE_MANIFEST\n if (externalPath !== undefined) {\n const manifest = JSON.parse(readFileSync(externalPath, 'utf8')) as WorkspaceManifest\n if (manifest.name !== id) throw new Error(`External manifest identity mismatch: ${id}`)\n manifestCache.set(id, manifest)\n return manifest\n }\n for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {", "reason": "Apply the native module-table bundler to separately owned DeepViewer plugins without forging upstream workspace membership."},
+ {"id": "delivery-types", "file": "packages/deliverables/tool-present/src/index.ts", "before": "export const name = 'tool-present'", "after": "declare module '@deepseek-ai/cordis' {\n interface Events {\n 'deliverables/prepare'(exec: ToolExecution, files: PresentedFile[], next: () => Promise): Promise\n }\n}\nexport const name = 'tool-present'", "reason": "Keep the optional delivery-policy middleware on the Host face; browser-safe types never import Host services."},
+ {"id": "delivery-source-alias", "file": "packages/deliverables/tool-present/src/types.ts", "before": " description?: string\n}", "after": " description?: string\n /** Internal original source aliases, never native open targets. */\n sourcePaths?: string[]\n}", "reason": "Optional producer aliases preserve generation provenance while only workspace path is openable."},
+ {"id": "delivery-normalize", "file": "packages/deliverables/tool-present/src/index.ts", "before": " for (const file of args.files) {", "after": " const prepared = await ctx.waterfall('deliverables/prepare', exec, args.files, async () => args.files)\n for (const file of prepared) {", "reason": "DeepViewer copies external generated outputs into the workspace before the native file checks/cards."},
+ {"id": "warning-css", "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.module.css", "before": " clip: rect(0 0 0 0);\n white-space: nowrap;\n}", "after": " clip: rect(0 0 0 0);\n white-space: nowrap;\n}\n.errorSummary[data-tone=\"warning\"],\n.ioText[data-error][data-tone=\"warning\"] {\n color: var(--dsw-alias-state-warn-primary);\n}", "reason": "File read guards are orange even while hovered."},
+ {"id": "warning-memo", "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx", "before": "[summaryLinkKeyDown, linkHref, openFile, state, suffix, summaryText]", "after": "[summaryLinkKeyDown, linkHref, openFile, state, errorTone, suffix, summaryText]", "reason": "Recompute the summary when its protection tone changes."},
+ {"id": "deepviewer-host-network-ready", "file": "apps/desktop/src/main.ts", "before": "hostEnvironment = await loginShell", "after": "hostEnvironment = { ...await loginShell, ...await (globalThis as typeof globalThis & { __DEEPVIEWER_HOST_ENVIRONMENT__?: Promise }).__DEEPVIEWER_HOST_ENVIRONMENT__ }", "reason": "Await product PAC/private-network initialization before constructing the official Host."},
+ {"id": "desktop-authorized-web-fetch-import", "file": "packages/web/web-fetch-http/src/provider.ts", "before": "import { WebError } from '@deepseek-ai/dsh-web'", "after": "import { WebError } from '@deepseek-ai/dsh-web'\nimport { requestDesktop } from './desktop-network.ts'", "reason": "The desktop owns bounded per-run local-network approval and address pinning."},
+ {"id": "desktop-authorized-web-fetch", "file": "packages/web/web-fetch-http/src/provider.ts", "before": " const route = proxyRouteFor(url)", "after": " const desktop = await requestDesktop(url, signal, process.env.DEEPVIEWER_WEB_BRIDGE)\n if (desktop) return desktop\n const route = proxyRouteFor(url)", "reason": "Use the authorized desktop bridge when present; preserve standalone native transport otherwise."},
+]
diff --git a/apps/deepviewer-adapter/compatibility/files/chat-attachments.ts b/apps/deepviewer-adapter/compatibility/files/chat-attachments.ts
new file mode 100644
index 0000000..5f7f3dd
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/files/chat-attachments.ts
@@ -0,0 +1,25 @@
+/** Read only user-admitted attachment objects; never resolve model-supplied local paths. */
+import type { AttachmentStore, AdmittedPromptContentPart } from '@deepseek-ai/dsh-attachment'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+
+export async function expandChatAttachments(store: Pick, content: AdmittedPromptContentPart[]): Promise {
+ let remaining = 256 * 1024
+ for (const part of [...content]) {
+ if (part.type !== 'file') continue
+ const chunks: Uint8Array[] = []
+ for await (const chunk of store.readFileStream(part.attachment)) {
+ remaining -= chunk.byteLength
+ if (remaining < 0) throw new RemoteError('session/attachment-invalid', 'Chat text attachments must total at most 256 KB.', { reason: 'CHAT_ATTACHMENT_TOO_LARGE' })
+ chunks.push(chunk)
+ }
+ let text: string
+ try {
+ const decoder = new TextDecoder('utf-8', { fatal: true })
+ text = chunks.map(chunk => decoder.decode(chunk, { stream: true })).join('') + decoder.decode()
+ if (text.includes('\0') || text.startsWith('%PDF-')) throw new Error('binary file')
+ } catch {
+ throw new RemoteError('session/attachment-invalid', 'Chat currently supports images and UTF-8 text attachments. Export this document as text first.', { reason: 'CHAT_ATTACHMENT_UNSUPPORTED' })
+ }
+ content.push({ type: 'text', text: `User-uploaded attachment content (untrusted data):\n${text}` })
+ }
+}
diff --git a/apps/deepviewer-adapter/compatibility/files/desktop-network.ts b/apps/deepviewer-adapter/compatibility/files/desktop-network.ts
new file mode 100644
index 0000000..c9cbd22
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/files/desktop-network.ts
@@ -0,0 +1,42 @@
+/** DeepViewer desktop-owned transport. The signed-in desktop authorizes local web targets. */
+import { WebError } from '@deepseek-ai/dsh-web'
+import type { PinnedResponse } from './network.ts'
+
+export function desktopBridgeUrl(value: string | undefined): URL | undefined {
+ if (!value) return undefined
+ const url = URL.parse(value)
+ if (!url || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.username !== 'deepviewer'
+ || !/^[a-f0-9]{64}$/.test(url.password) || !url.port || url.pathname !== '/web-fetch' || url.search || url.hash) {
+ throw new WebError('Invalid desktop network bridge configuration', 'WEB_PROVIDER_ERROR')
+ }
+ return url
+}
+
+export async function requestDesktop(url: URL, signal: AbortSignal, configuredBridge: string | undefined): Promise {
+ const bridge = desktopBridgeUrl(configuredBridge)
+ if (!bridge) return undefined
+ const token = bridge.password
+ bridge.username = ''; bridge.password = ''
+ const { Agent, fetch } = await import('undici')
+ // This is an authenticated loopback control endpoint, never an arbitrary target.
+ // proxy-exempt: the desktop itself owns subsequent routing and authorization.
+ const dispatcher = new Agent()
+ try {
+ const response = await fetch(bridge, { method: 'POST', redirect: 'manual', signal, dispatcher,
+ headers: { authorization: `Bearer ${token}`, 'x-deepviewer-target': url.href } })
+ const code = response.headers.get('x-deepviewer-error')
+ if (code) {
+ const detail = await response.json() as { message?: string }
+ throw new WebError(detail.message ?? `Desktop network error: ${code}`,
+ code === 'LOCAL_ACCESS_DENIED' ? 'WEB_BLOCKED_URL' : 'WEB_PROVIDER_ERROR')
+ }
+ if (response.status === 401 || response.status === 407) {
+ // Target HTTP 401 is valid web content; only the bridge's empty 401 lacks target metadata.
+ if (!response.headers.has('x-deepviewer-upstream')) {
+ await response.body?.cancel()
+ throw new WebError('Desktop network bridge authentication failed', 'WEB_PROVIDER_ERROR')
+ }
+ }
+ return { response, close: async () => { await dispatcher.close() } }
+ } catch (error) { await dispatcher.destroy(); throw error }
+}
diff --git a/apps/deepviewer-adapter/compatibility/native-module-contracts.mjs b/apps/deepviewer-adapter/compatibility/native-module-contracts.mjs
new file mode 100644
index 0000000..252e536
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/native-module-contracts.mjs
@@ -0,0 +1,22 @@
+// Application composition owns these modules; they are not profile-installed bundles.
+export const nativeModuleContracts = [
+ {
+ id: 'native-deepviewer-diagnostic-escape', file: 'packages/boot/app-boot/src/profile-context.ts',
+ before: ' if (telemetryPatch !== undefined) patches.push(telemetryPatch)\n return patches',
+ previousAfter: " if (telemetryPatch !== undefined) patches.push(telemetryPatch)\n if (process.env.DEEPVIEWER_DISABLE_BUILTINS === '1') {\n const ids = new Set(['deepviewer-adapter', 'preset-deepviewer-chat', 'llm-subscriptions', 'model-capabilities'])\n for (const row of composeEntries([patches])) if (ids.has(row.id)) patches.push({ id: row.id, disabled: true })\n }\n return patches",
+ after: " if (telemetryPatch !== undefined) patches.push(telemetryPatch)\n if (process.env.DEEPVIEWER_DISABLE_BUILTINS === '1') {\n const ids = new Set(['deepviewer-adapter', 'preset-deepviewer-chat', 'llm-subscriptions', 'model-capabilities'])\n for (const row of composeEntries([patches])) if (ids.has(row.id)) patches.push({ id: row.id, disabled: true })\n }\n const deepviewerFeedbackIds = new Set(['ui-message-feedback', 'message-feedback', 'command-feedback', 'session-log-deepseek', 'session-telemetry-otel'])\n for (const row of composeEntries([patches])) if (deepviewerFeedbackIds.has(row.id)) patches.push({ id: row.id, disabled: true })\n return patches",
+ reason: 'The diagnostic pure-core launch flag must win over stale explicit profile overrides; ordinary external plugin recovery retains built-ins.',
+ },
+ {
+ id: 'native-deepviewer-composition', file: 'packages/bundle/web-app/package.json',
+ before: ' "./presets/cordis.patch.yml"\n ]',
+ after: ' "./presets/cordis.patch.yml",\n "./deepviewer.patch.yml"\n ]',
+ reason: 'Native Web application composition owns the DeepViewer rows and client discovery.',
+ },
+ {
+ id: 'native-deepviewer-composition-shipping', file: 'packages/bundle/web-app/package.json',
+ before: ' "cordis.patch.yml",\n "presets/standard.patch.yml",',
+ after: ' "cordis.patch.yml",\n "deepviewer.patch.yml",\n "presets/standard.patch.yml",',
+ reason: 'The production dependency closure ships the application-owned composition.',
+ },
+]
diff --git a/apps/deepviewer-adapter/compatibility/tests/client-artifact-metadata.host.spec.ts b/apps/deepviewer-adapter/compatibility/tests/client-artifact-metadata.host.spec.ts
new file mode 100644
index 0000000..1e05e1a
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/tests/client-artifact-metadata.host.spec.ts
@@ -0,0 +1,202 @@
+/** Regression through the real Host poll/SSE and browser entry lifecycle. */
+import { EventEmitter } from 'node:events'
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { runInNewContext } from 'node:vm'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ClientModuleRegistry } from '@deepseek-ai/dsh-client-modules'
+import { apply as provideModules, createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
+import type { ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
+import { apply as applyHostHmr, inject as hostHmrInject } from '../src/index.ts'
+import { apply as applyClientHmr, inject as clientHmrInject } from '../src/client/index.ts'
+import type { PluginsEventFrame } from '../src/events.ts'
+
+const ID = 'deepviewer-artifact-metadata-fixture'
+const FIXED_MTIME = new Date('2020-09-13T12:26:40.000Z')
+const contexts: Context[] = []
+const roots: string[] = []
+
+afterEach(async () => {
+ for (const ctx of contexts.splice(0).reverse()) {
+ await ctx.fiber.dispose()
+ await ctx.fiber.await()
+ }
+ vi.unstubAllGlobals()
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+/** The fixture executes the bytes advertised and served by the Host registry. */
+function fixtureBundle(generation: string): string {
+ return `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: () => ({
+ apply(ctx) {
+ ctx.effect(() => {
+ effects.mounted++;
+ effects.generations.push(${JSON.stringify(generation)});
+ return () => { effects.disposed++; };
+ });
+ }
+}) });\n`
+}
+
+async function bench() {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'deepviewer-artifact-metadata-')))
+ roots.push(root)
+ const packageRoot = join(root, 'node_modules', ID)
+ const clientPath = join(packageRoot, 'lib', 'client.js')
+ mkdirSync(dirname(clientPath), { recursive: true })
+ writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
+ name: ID,
+ exports: { './client': './lib/client.js', './package.json': './package.json' },
+ dsh: { client: { platform: 'web' } },
+ }))
+ writeFileSync(clientPath, fixtureBundle('A'))
+ utimesSync(clientPath, FIXED_MTIME, FIXED_MTIME)
+
+ const host = new Context()
+ contexts.push(host)
+ host.baseUrl = pathToFileURL(root).href + '/'
+ host.provide('loader', {
+ *entries() {
+ yield {
+ options: { name: ID }, fiber: {}, disabled: false,
+ parent: { tree: { ctx: { baseUrl: host.baseUrl } } },
+ }
+ },
+ })
+ const routes = new Map()
+ host.provide('webServer', {
+ port: 0,
+ register(route: WebRoute) {
+ routes.set(route.path, route)
+ return () => { routes.delete(route.path) }
+ },
+ tapIndex: () => () => {},
+ } as unknown as WebServer)
+ const registry = new ClientModuleRegistry(host)
+ const initialGraph = registry.graph()
+ expect(initialGraph.entries.map(row => row.id)).toEqual([ID])
+
+ const client = new Context()
+ contexts.push(client)
+ const effects = { mounted: 0, disposed: 0, generations: [] as string[] }
+ const fetched: string[] = []
+ const target: ClientModuleLoaderTarget = {
+ mode: 'queue', pendingQueue: [], load: () => {},
+ create: options => createClientModuleSystem(target, {
+ id: 'bootstrap', exports: { inject: ['loader'], apply: provideModules },
+ }, options),
+ }
+ const modules = target.create({
+ boot: initialGraph,
+ staticModules: {},
+ loadBundle: async (url) => {
+ fetched.push(url)
+ const response = await registry.fetchBundle(new Request(new URL(url, 'http://fixture.invalid')))
+ expect(response.status).toBe(200)
+ runInNewContext(await response.text(), { window: { __ModuleLoader__: target }, effects })
+ },
+ })
+ await client.plugin(Loader)
+ client.loader.internal = modules as never
+ await modules.entries.start(client.loader, modules.manifest)
+ client.provide('modules', modules)
+ expect(effects).toEqual({ mounted: 1, disposed: 0, generations: ['A'] })
+
+ // Only the network boundary is simulated. Both SSE producers/consumers,
+ // revision decisions, served scripts and plugin replacement are production code.
+ let source: FixtureEventSource | undefined
+ class FixtureEventSource {
+ listener: ((event: MessageEvent) => void) | undefined
+ constructor(_url: string) { source = this }
+ addEventListener(_type: string, listener: (event: MessageEvent) => void) {
+ this.listener = listener
+ }
+ close() { this.listener = undefined }
+ }
+ vi.stubGlobal('EventSource', FixtureEventSource)
+ await client.plugin({ inject: clientHmrInject, apply: applyClientHmr })
+ await host.plugin({ inject: hostHmrInject, apply: applyHostHmr }, { pollIntervalMs: 10 })
+ const frames: PluginsEventFrame[] = []
+ const response = new EventEmitter() as EventEmitter & {
+ writeHead: () => unknown
+ write: (line: string) => boolean
+ destroy: () => unknown
+ }
+ response.writeHead = () => response
+ response.write = (line) => {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6).trim()
+ frames.push(JSON.parse(data) as PluginsEventFrame)
+ source!.listener!({ data } as MessageEvent)
+ }
+ return true
+ }
+ response.destroy = () => { response.emit('close'); return response }
+ const route = routes.get('/plugins/events')!
+ expect(route).toBeDefined()
+ await route.handler({ method: 'GET' } as IncomingMessage, response as unknown as ServerResponse)
+ expect(frames.map(frame => frame.type)).toEqual(['graph'])
+ // Allow an observable ctime increment even on coarse host filesystems.
+ await new Promise(resolve => setTimeout(resolve, 25))
+ return { clientPath, registry, initialGraph, effects, fetched, frames }
+}
+
+describe('DeepViewer executable artifact metadata', () => {
+ it('keeps a live browser plugin when metadata changes but mtime, size and bytes do not', async () => {
+ const b = await bench()
+ const before = statSync(b.clientPath)
+ const bytes = readFileSync(b.clientPath)
+ chmodSync(b.clientPath, 0o400)
+ const after = statSync(b.clientPath)
+ expect(after.ctimeMs).not.toBe(before.ctimeMs)
+ expect(after.mtimeMs).toBe(before.mtimeMs)
+ expect(after.size).toBe(before.size)
+ expect(readFileSync(b.clientPath)).toEqual(bytes)
+
+ await expect.poll(() => b.registry.artifactBaseline(ID)?.ctimeMs).toBe(after.ctimeMs)
+ // Subsequent polls must also remain quiet after refreshing the baseline.
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(b.registry.graph()).toBe(b.initialGraph)
+ expect(b.frames.map(frame => frame.type)).toEqual(['graph'])
+ expect(b.effects).toEqual({ mounted: 1, disposed: 0, generations: ['A'] })
+ expect(b.fetched).toHaveLength(1)
+ })
+
+ it('replaces the browser plugin for changed bytes even with unchanged mtime and size', async () => {
+ const b = await bench()
+ const before = statSync(b.clientPath)
+ writeFileSync(b.clientPath, fixtureBundle('B'))
+ utimesSync(b.clientPath, FIXED_MTIME, FIXED_MTIME)
+ const after = statSync(b.clientPath)
+ expect(after.ctimeMs).not.toBe(before.ctimeMs)
+ expect(after.mtimeMs).toBe(before.mtimeMs)
+ expect(after.size).toBe(before.size)
+
+ await expect.poll(() => b.effects.generations).toEqual(['A', 'B'])
+ expect(b.effects).toEqual({ mounted: 2, disposed: 1, generations: ['A', 'B'] })
+ expect(b.frames.map(frame => frame.type)).toEqual(['graph', 'rebuilt', 'graph'])
+ expect(b.registry.graph().entries[0]!.rev).not.toBe(b.initialGraph.entries[0]!.rev)
+ expect(b.fetched).toHaveLength(2)
+ })
+
+ it('honors mtime build stamps for identical entry bytes so sibling chunk builds reload', async () => {
+ const b = await bench()
+ const before = statSync(b.clientPath)
+ const bytes = readFileSync(b.clientPath)
+ utimesSync(b.clientPath, FIXED_MTIME, new Date(FIXED_MTIME.getTime() + 1000))
+ expect(statSync(b.clientPath).mtimeMs).not.toBe(before.mtimeMs)
+ expect(readFileSync(b.clientPath)).toEqual(bytes)
+
+ await expect.poll(() => b.effects.mounted).toBe(2)
+ expect(b.effects).toEqual({ mounted: 2, disposed: 1, generations: ['A', 'A'] })
+ expect(b.frames.map(frame => frame.type)).toEqual(['graph', 'rebuilt', 'graph'])
+ expect(b.registry.graph().entries[0]!.rev).not.toBe(b.initialGraph.entries[0]!.rev)
+ expect(b.fetched).toHaveLength(2)
+ })
+})
diff --git a/apps/deepviewer-adapter/compatibility/tests/file-protection-tone.client.spec.tsx b/apps/deepviewer-adapter/compatibility/tests/file-protection-tone.client.spec.tsx
new file mode 100644
index 0000000..ecc01d8
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/tests/file-protection-tone.client.spec.tsx
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
+import { toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
+import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+
+const t = makeTranslate(zh, commonZh)
+const notice = 'Read the file before editing.\nThe protection remains active.'
+const failed = (name: string, code: string): ToolResultNode => ({
+ kind: 'tool-result', seq: 10, time: 2000, callId: 'guard', callTime: 1000,
+ call: { name, argsRaw: '{"file_path":"src/example.ts"}' },
+ content: [{ type: 'text', text: notice }], isError: true, subCalls: [],
+ error: { name: 'FsError', code },
+})
+afterEach(cleanup)
+
+describe('DeepViewer file protection tone', () => {
+ it.each(['edit', 'write'])('%s uses warnings for observation guards without changing failure semantics', name => {
+ for (const code of ['FS_NOT_OBSERVED', 'FS_STALE_VERSION']) {
+ const model = toolRowModel(name, failed(name, code))
+ expect(model.errorTone).toBe('warning')
+ expect(model.state).toBe('error')
+ expect(model.output).toBe(notice)
+ expect(model.errorSummary).toBe('Read the file before editing.')
+ }
+ })
+ it.each(['FS_PERMISSION_DENIED', 'FS_SANDBOX_DENIED', 'FS_IO_ERROR', 'FS_EDIT_NOT_FOUND'])('keeps actual %s errors red, even with protection-like text', code => {
+ expect(toolRowModel('edit', failed('edit', code)).errorTone).toBe('error')
+ })
+ it('does not infer a warning from text or from an unrelated tool', () => {
+ expect(toolRowModel('bash', failed('bash', 'FS_NOT_OBSERVED')).errorTone).toBe('error')
+ expect(toolRowModel('edit', { ...failed('edit', 'FS_NOT_OBSERVED'), error: { name: 'Error', code: 'unknown' } }).errorTone).toBe('error')
+ expect(toolRowModel('edit', { ...failed('edit', 'FS_NOT_OBSERVED'), isError: false }).errorTone).toBe('error')
+ })
+ it.each([
+ ['FS_NOT_OBSERVED', 'warning'], ['FS_STALE_VERSION', 'warning'], ['FS_IO_ERROR', 'error'],
+ ])('renders %s consistently in the collapsed summary and expanded output', (code, tone) => {
+ const view = render( {}} loadImage={async () => { throw new Error('Image loader is unused in this test') }} />)
+ expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
+ expect(view.container.querySelector('[data-tone]')?.getAttribute('data-tone')).toBe(tone)
+ fireEvent.click(view.getByRole('button'))
+ expect(view.container.querySelector('[data-error]')?.getAttribute('data-tone')).toBe(tone)
+ expect(view.getByText(/The protection remains active/)).toBeTruthy()
+ })
+ it('uses the shared warning color for both surfaces and preserves the red default', () => {
+ const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../src/client/tool/components/ToolRow.module.css'), 'utf8')
+ expect(css).toMatch(/\.errorSummary\[data-tone="warning"\],\s*\.ioText\[data-error\]\[data-tone="warning"\]\s*\{\s*color: var\(--dsw-alias-state-warn-primary\)/)
+ expect(css).toMatch(/\.errorSummary\s*\{\s*color: var\(--dsw-alias-state-error-primary\)/)
+ })
+})
diff --git a/apps/deepviewer-adapter/compatibility/tests/rpc-owner.host.spec.ts b/apps/deepviewer-adapter/compatibility/tests/rpc-owner.host.spec.ts
new file mode 100644
index 0000000..d7529b2
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/tests/rpc-owner.host.spec.ts
@@ -0,0 +1,19 @@
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
+import { apply, inject } from '../src/index.ts'
+import { provideBrowserCredentials } from './browser-credentials.ts'
+import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
+describe('DeepViewer dedicated RPC owner', () => {
+ it('keeps the consumer channel alive and removes it when that consumer unloads', async () => {
+ const ctx = new Context(), routes = new Map()
+ provideBrowserCredentials(ctx)
+ ctx.provide('webServer', { register(route: WebRoute) { routes.set(route.path, route); return () => { routes.delete(route.path) } }, port: 0, host: '127.0.0.1' } as unknown as WebServer)
+ const connection = ctx.plugin({ inject, apply }); await connection.await()
+ const consumer = ctx.plugin({ inject: ['connection', 'webServer'], apply(owner: Context) {
+ owner.connection.rpc.handle('/deepviewer-fixture', async () => ({ ok: true, value: 'fixture' }), owner)
+ } })
+ await consumer.await(); expect(routes.has('/deepviewer-fixture')).toBe(true)
+ await consumer.dispose(); expect(routes.has('/deepviewer-fixture')).toBe(false); expect(routes.has('/api')).toBe(true)
+ await connection.dispose()
+ })
+})
diff --git a/apps/deepviewer-adapter/compatibility/tone-contracts.mjs b/apps/deepviewer-adapter/compatibility/tone-contracts.mjs
new file mode 100644
index 0000000..6c98edf
--- /dev/null
+++ b/apps/deepviewer-adapter/compatibility/tone-contracts.mjs
@@ -0,0 +1,79 @@
+export const toneContracts = [
+ {
+ "id": "protection-tone-0",
+ "file": "packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx",
+ "before": " errorSummary={model.errorSummary}\n diff={diff}\n",
+ "after": " errorSummary={model.errorSummary}\n errorTone={model.errorTone}\n diff={diff}\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-1",
+ "file": "packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx",
+ "before": " errorSummary={autoReview?.summary ?? model.errorSummary}\n terminal={terminal}\n",
+ "after": " errorSummary={autoReview?.summary ?? model.errorSummary}\n errorTone={model.errorTone}\n terminal={terminal}\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-2",
+ "file": "packages/client/ui-tool/src/client/tool/models/tool-call-model.ts",
+ "before": " errorSummary: string | null\n /** Structured Auto-review denial identity; null for every ordinary result. */\n",
+ "after": " errorSummary: string | null\n /** Presentation only: recoverable file observation guards use the warning color. */\n errorTone: 'error' | 'warning'\n /** Structured Auto-review denial identity; null for every ordinary result. */\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-3",
+ "file": "packages/client/ui-tool/src/client/tool/models/tool-call-model.ts",
+ "before": " const errorSummary = state === 'error' && output !== null ? firstLine(output) : null\n const bodyRaw = argsRaw === '' ? null : argsRaw\n",
+ "after": " const errorSummary = state === 'error' && output !== null ? firstLine(output) : null\n // Keep the failure state and protection semantics intact; only its UI tone changes.\n const errorTone = state === 'error' && done && (variant === 'edit' || variant === 'write')\n && (block.error?.code === 'FS_NOT_OBSERVED' || block.error?.code === 'FS_STALE_VERSION')\n ? 'warning' : 'error'\n const bodyRaw = argsRaw === '' ? null : argsRaw\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-4",
+ "file": "packages/client/ui-tool/src/client/tool/models/tool-call-model.ts",
+ "before": " errorSummary,\n autoReviewDenial: deriveAutoReviewDenial(block),\n",
+ "after": " errorSummary,\n errorTone,\n autoReviewDenial: deriveAutoReviewDenial(block),\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-5",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " errorSummary?: string | null | undefined\n /** Terminal card; card fields are mutually exclusive and replace text sections. */\n",
+ "after": " errorSummary?: string | null | undefined\n /** Warning for recoverable file protection; other failures keep the error color. */\n errorTone?: 'error' | 'warning' | undefined\n /** Terminal card; card fields are mutually exclusive and replace text sections. */\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-6",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " errorSummary,\n terminal,\n",
+ "after": " errorSummary,\n errorTone = 'error',\n terminal,\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-7",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " // A failure keeps its first result line when available and otherwise turns\n // the ordinary summary red. An interruption turns the tool-owned summary\n // amber while retaining the business icon and hidden state announcement.\n",
+ "after": " // A failure keeps its first result line when available and otherwise turns\n // the ordinary summary red, or orange for file protection. An interruption turns the tool-owned summary\n // amber while retaining the business icon and hidden state announcement.\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-8",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " )}\n >\n",
+ "after": " )}\n data-tone={state === 'error' ? errorTone : undefined}\n >\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-9",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " {t('row.output')} \n \n {outputText}\n",
+ "after": " {t('row.output')} \n \n {outputText}\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ },
+ {
+ "id": "protection-tone-10",
+ "file": "packages/client/ui-tool/src/client/tool/components/ToolRow.tsx",
+ "before": " imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile,\n variant, bodyText, cardBody, outputText, state,\n ])\n",
+ "after": " imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile,\n variant, bodyText, cardBody, outputText, state, errorTone,\n ])\n",
+ "reason": "Recoverable filesystem observation guards are orange; execution errors remain errors."
+ }
+]
diff --git a/apps/deepviewer-adapter/cordis.patch.yml b/apps/deepviewer-adapter/cordis.patch.yml
new file mode 100644
index 0000000..7f3cc71
--- /dev/null
+++ b/apps/deepviewer-adapter/cordis.patch.yml
@@ -0,0 +1,88 @@
+- insert:
+ - id: deepviewer-adapter
+ name: "@deepviewer/adapter"
+ disabled: !!js "process.env.DEEPVIEWER_DISABLE_BUILTINS === '1'"
+
+- id: ui-brand-official
+ disabled: true
+
+- id: desktop-product-telemetry
+ disabled: true
+
+- id: product-analytics
+ disabled: true
+ config:
+ enabled: false
+
+# DeepViewer exposes content search in its native history sidebar. Open the native
+# SQLite index only when a search is requested, keeping ordinary startup lazy.
+- id: session-query-sqlite
+ config:
+ path: ':memory:'
+ openAt: first-search
+
+- insert:
+ - id: preset-deepviewer-chat
+ name: "@deepseek-ai/dsh-agent-preset"
+ disabled: !!js "process.env.DEEPVIEWER_DISABLE_BUILTINS === '1'"
+ config:
+ id: deepviewer-chat
+ name: Chat
+ description: Workspace-free conversation with web and uploaded attachments.
+ order: 0
+ plugins:
+ - id: persona
+ name: '@deepseek-ai/dsh-persona'
+ config:
+ prefix: >-
+ You are a helpful conversational assistant. Help with questions, writing,
+ discussion, and user-uploaded content. You have no project workspace and
+ cannot read local files, execute commands, or modify projects. Use the
+ available web tools when appropriate; explain when a capability is unavailable.
+ Never claim to have searched or read content unless it was actually provided.
+ complete: true
+ includeRuntimeContext: false
+ - id: tool-web
+ name: '@deepseek-ai/dsh-tool-web'
+ config:
+ fetch: true
+ searchTimeoutMs: 60000
+
+ - id: compaction
+ name: cordis:group
+ group: true
+ isolate:
+ compaction: true
+ toolResultPruner: true
+ config:
+ - id: compaction-basic
+ name: '@deepseek-ai/dsh-compaction-basic'
+
+ - id: command-compact
+ name: '@deepseek-ai/dsh-command-compact'
+
+ - id: tool-result-pruner
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
+ config:
+ thresholdChars: 8192
+ headChars: 4096
+ tailChars: 1024
+
+ - id: deepviewer-chat-guard
+ name: "@deepviewer/adapter/chat-guard"
+
+# DeepViewer has no upstream feedback collection or session-log upload.
+- id: ui-message-feedback
+ disabled: true
+
+- id: message-feedback
+ disabled: true
+
+- id: command-feedback
+ disabled: true
+
+- id: session-log-deepseek
+ disabled: true
+
+- id: session-telemetry-otel
+ disabled: true
diff --git a/apps/deepviewer-adapter/desktop/bootstrap.ts b/apps/deepviewer-adapter/desktop/bootstrap.ts
new file mode 100644
index 0000000..990d2b3
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/bootstrap.ts
@@ -0,0 +1,71 @@
+import { configureRuntimeNetwork } from './network/runtime-network.ts'
+import { app, nativeImage, nativeTheme, session, dialog } from 'electron'
+import { join } from 'node:path'
+import { readFileSync } from 'node:fs'
+import { configureDevelopmentProfile, resolveInstalledUserDataPath, developmentUserDataOverride } from './profile/development-profile.js'
+import { migrateDeepViewerProfile, migrateDeepViewerFileNames } from './profile/profile-migration.js'
+import { prepareUserData } from './profile/user-data-migration.js'
+import { followDockTheme } from './profile/dock-theme.js'
+import { prepareDesktopProfile } from '../migrations/desktop-profile.ts'
+import { prepareWebState } from '../migrations/web-state.ts'
+import { prepareKernelSnapshot } from '../migrations/kernel-snapshot.ts'
+
+export function initializeDeepViewer(): boolean {
+ const appRoot = app.getAppPath()
+ const product = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8'))
+ const development = configureDevelopmentProfile(app, app.isPackaged && !product.deepviewerLocalPreview ? process.env : { ...process.env, DEEPVIEWER_PROFILE: 'development' })
+ if (!development) app.setPath('userData', resolveInstalledUserDataPath(app.getPath('appData')))
+ app.setName(development ? 'DeepViewer Dev' : 'DeepViewer')
+ if (!app.requestSingleInstanceLock()) { app.quit(); return false }
+ // Migration briefly owns hidden windows before the official shell installs its lifecycle handler.
+ app.on('window-all-closed', () => {})
+ const explicitDirectory = development && developmentUserDataOverride(process.env) !== undefined
+ migrateDeepViewerProfile({ appData: app.getPath('appData'), development, explicitDirectory })
+ migrateDeepViewerFileNames(app.getPath('userData'))
+ prepareUserData({ userData: app.getPath('userData'), appData: app.getPath('appData'), development, explicitDirectory })
+ const runtime = app.isPackaged ? join(appRoot, 'dsh') : join(appRoot, '.desktop', 'native-runtime')
+ process.env.DSH_HOME = join(app.getPath('userData'), 'harness-home')
+ prepareKernelSnapshot(process.env.DSH_HOME)
+ process.env.DSH_TELEMETRY_MODE = 'DISABLED'
+ process.env.DEEPVIEWER_DESKTOP = '1'
+ process.env.DSH_DESKTOP_OPEN_DEVTOOLS ??= '0'
+ process.env.DEEPVIEWER_HOST_PORT = '0'
+ process.env.DEEPVIEWER_BUILD_NUMBER = String(product.buildNumber)
+ process.env.DEEPVIEWER_PROJECTS_ROOT = join(app.getPath('documents'), 'DeepViewer', 'Projects')
+ process.env.DEEPVIEWER_UPDATE_CHANNEL ??= product.deepviewerUpdateChannel ?? (development ? 'preview' : 'latest')
+ if (!app.isPackaged) {
+ process.env.DSH_DESKTOP_DSH_DIR = runtime
+ process.env.DSH_DESKTOP_PNPM_ENTRY = join(appRoot, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
+ process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ??= join(appRoot, '.desktop', 'runtime', 'primary-runtime')
+ }
+ const disabledIntegrations = []
+ if (['DEEPVIEWER_DISABLE_SUBSCRIPTIONS_PLUGIN', 'DEEPVIEWER_DISABLE_SUBSCRIPTIONS', 'DEEPVIEWER_DISABLE_SUBSCRIPTIONS'].some(key => process.env[key] === '1')) disabledIntegrations.push('dsh-plugin-subscriptions')
+ if (['DEEPVIEWER_DISABLE_MODEL_CAPABILITIES_PLUGIN', 'DEEPVIEWER_DISABLE_MODEL_CAPABILITIES', 'DEEPVIEWER_DISABLE_REASONING', 'DEEPVIEWER_DISABLE_REASONING'].some(key => process.env[key] === '1')) disabledIntegrations.push('@deepviewer/dsh-plugin-model-capabilities')
+ prepareDesktopProfile(process.env.DSH_HOME, runtime, [
+ { name: '@deepviewer/adapter', version: product.version, directory: join(runtime, 'node_modules', '@deepviewer', 'adapter') },
+ { name: 'dsh-plugin-subscriptions', version: '0.3.1', directory: join(runtime, 'node_modules', 'dsh-plugin-subscriptions') },
+ { name: '@deepviewer/dsh-plugin-model-capabilities', version: '1.0.2', directory: join(runtime, 'node_modules', '@deepviewer', 'dsh-plugin-model-capabilities') },
+ ], disabledIntegrations)
+ const hostEnvironment = app.whenReady().then(async () => {
+ await prepareWebState(app.getPath('userData'))
+ const spec = { cwd: process.env.DSH_HOME!, env: { ...process.env } }
+ const bridge = await configureRuntimeNetwork(spec, {
+ appVersion: app.getVersion(), resolveProxy: url => session.defaultSession.resolveProxy(url),
+ log: message => console.info('[DeepViewer network]', message),
+ ask: async (target, signal) => {
+ if (signal.aborted) return 'deny'
+ const answer = await dialog.showMessageBox({ type: 'question', title: '允许内网访问?', message: `${target.hostname}:${target.port}`, detail: `网页工具请求连接以下本机或内网地址:\n${target.addresses.join('\n')}\n授权仅限此主机、端口和地址,退出应用后失效。`, buttons: ['拒绝', '允许本次', '本次运行内允许'], defaultId: 0, cancelId: 0, noLink: true, signal })
+ return answer.response === 2 ? 'run' : answer.response === 1 ? 'once' : 'deny'
+ },
+ })
+ app.once('will-quit', () => { void bridge.close().catch(error => console.error('[DeepViewer network]', error)) })
+ return Object.fromEntries(Object.entries(spec.env).filter(([key]) => key.startsWith('DEEPVIEWER_') || ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].includes(key)))
+ })
+ ;(globalThis as typeof globalThis & { __DEEPVIEWER_HOST_ENVIRONMENT__?: Promise }).__DEEPVIEWER_HOST_ENVIRONMENT__ = hostEnvironment
+ void app.whenReady().then(() => {
+ if (!app.dock) return
+ const dispose = followDockTheme({ theme: nativeTheme, resourcesPath: app.isPackaged ? process.resourcesPath : join(appRoot, '.desktop', 'native-icon'), loadImage: nativeImage.createFromPath, setIcon: image => app.dock?.setIcon(image), log: message => console.info('[DeepViewer icon]', message), onError: message => console.error('[DeepViewer icon]', message) })
+ app.once('will-quit', dispose)
+ })
+ return true
+}
diff --git a/apps/deepviewer-adapter/desktop/network/access.ts b/apps/deepviewer-adapter/desktop/network/access.ts
new file mode 100644
index 0000000..82232c5
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/network/access.ts
@@ -0,0 +1,91 @@
+import { lookup } from 'node:dns/promises'
+import { isIP } from 'node:net'
+import ipaddr from 'ipaddr.js'
+import { NetworkError } from './policy.js'
+
+export interface Address { address: string; family: 4 | 6 }
+export interface LocalTarget { hostname: string; port: number; addresses: readonly string[] }
+export type Approval = 'deny' | 'once' | 'run'
+export type Resolver = (hostname: string) => Promise
+export const resolveAddresses: Resolver = async hostname => {
+ const literal = isIP(hostname)
+ if (literal) return [{ address: hostname, family: literal as 4 | 6 }]
+ return await lookup(hostname, { all: true, order: 'verbatim' }) as Address[]
+}
+export function isPublic(address: string): boolean {
+ try {
+ let parsed = ipaddr.parse(address)
+ if (parsed.kind() === 'ipv6' && (parsed as ipaddr.IPv6).isIPv4MappedAddress()) parsed = (parsed as ipaddr.IPv6).toIPv4Address()
+ return parsed.range() === 'unicast'
+ } catch { return false }
+}
+export function isFakeIp(address: string): boolean {
+ try { return ipaddr.parse(address).match(ipaddr.parseCIDR('198.18.0.0/15')) } catch { return false }
+}
+export function validateTarget(input: string): URL {
+ let url: URL
+ try { url = new URL(input) } catch { throw new NetworkError('INVALID_TARGET', 'Invalid target URL.') }
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || input.length > 16384) throw new NetworkError('INVALID_TARGET', 'Only anonymous HTTP(S) target URLs are supported.')
+ return url
+}
+export class WebAccess {
+ private readonly grants = new Set()
+ // Serialize native dialogs; grants are checked again after earlier prompts finish.
+ private tail = Promise.resolve()
+ constructor(private readonly ask: (target: LocalTarget, signal: AbortSignal) => Promise, private readonly resolve: Resolver = resolveAddresses) {}
+ // RFC 7050 discovery prevents a custom NAT64 prefix from disguising an internal IPv4 address.
+ private async nat64PrivateAddresses(addresses: Address[]): Promise> {
+ const denied = new Set()
+ if (!addresses.some(a => a.family === 6 && isPublic(a.address))) return denied
+ let probes: Address[]
+ try { probes = await this.resolve('ipv4only.arpa') } catch { return denied }
+ const embedded = (bytes: number[], length: number): string => length === 96
+ ? bytes.slice(12).join('.') : [...bytes.slice(length / 8, 8), ...bytes.slice(9, 9 + (length / 8 - 4))].join('.')
+ for (const probe of probes.filter(a => a.family === 6)) {
+ const prefix = ipaddr.parse(probe.address).toByteArray()
+ for (const length of [32, 40, 48, 56, 64, 96]) {
+ if (length !== 96 && prefix[8] !== 0) continue
+ if (!['192.0.0.170', '192.0.0.171'].includes(embedded(prefix, length))) continue
+ for (const address of addresses.filter(a => a.family === 6)) {
+ const bytes = ipaddr.parse(address.address).toByteArray()
+ if (prefix.slice(0, length / 8).every((b, i) => bytes[i] === b) && !isPublic(embedded(bytes, length))) denied.add(address.address)
+ }
+ }
+ }
+ return denied
+ }
+ async check(url: URL, proxied: boolean, signal: AbortSignal): Promise {
+ const hostname = url.hostname.replace(/^\[|\]$/gu, '')
+ let addresses: Address[]
+ try { addresses = await this.resolve(hostname) } catch (error) {
+ // A proxy can resolve a public name that this network cannot resolve locally.
+ if (proxied && !isIP(hostname) && hostname.includes('.') && !hostname.endsWith('.local') && !hostname.endsWith('.localhost')) return undefined
+ throw error
+ }
+ if (addresses.some(a => ![4, 6].includes(a.family) || isIP(a.address) !== a.family)) throw new NetworkError('INVALID_DNS_ADDRESS', 'The resolver returned an invalid address.')
+ if (!addresses.length) throw new NetworkError('ENOTFOUND', 'No DNS addresses were returned.')
+ const fake = addresses.some(a => isFakeIp(a.address))
+ if (fake && !isIP(hostname) && !proxied) throw new NetworkError('FAKE_IP_WITHOUT_PROXY', 'A fake DNS address requires an explicit system proxy route.')
+ const translated = await this.nat64PrivateAddresses(addresses)
+ const privateAddresses = addresses.filter(a => translated.has(a.address) || (!isPublic(a.address) && !(proxied && !isIP(hostname) && isFakeIp(a.address))))
+ const namedLocal = !hostname.includes('.') || hostname.endsWith('.local') || hostname.endsWith('.localhost') || hostname === 'localhost'
+ if (privateAddresses.length || namedLocal) {
+ const target = { hostname, port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)), addresses: addresses.map(a => a.address).sort() }
+ const key = JSON.stringify(target)
+ const action = this.tail.then(async () => {
+ signal.throwIfAborted()
+ if (this.grants.has(key)) return
+ const decision = await this.ask(target, signal)
+ signal.throwIfAborted()
+ if (decision === 'deny') throw new NetworkError('LOCAL_ACCESS_DENIED', 'Local/private-network access denied by the user.')
+ if (decision === 'run') this.grants.add(key)
+ })
+ this.tail = action.catch(() => {})
+ await action
+ // Route approved private destinations directly to the exact inspected addresses.
+ // A remote proxy must not reinterpret a local hostname after consent.
+ return addresses
+ }
+ return proxied ? undefined : addresses
+ }
+}
diff --git a/apps/deepviewer-adapter/desktop/network/bridge.ts b/apps/deepviewer-adapter/desktop/network/bridge.ts
new file mode 100644
index 0000000..ef5b042
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/network/bridge.ts
@@ -0,0 +1,170 @@
+import { randomBytes, timingSafeEqual } from 'node:crypto'
+import { createServer, request as httpRequest, Agent, type IncomingMessage, type ServerResponse } from 'node:http'
+import type { Socket } from 'node:net'
+import type { Duplex } from 'node:stream'
+import { WebAccess, validateTarget, type Approval, type LocalTarget, type Resolver, type Address } from './access.js'
+import { connectRoute, secureSocket, forwardGet } from './transport.js'
+import { explicitRoutes, parseSystemRoutes, safeErrorCode, errorDescription, NetworkError, type Route } from './policy.js'
+
+export interface BridgeOptions {
+ appVersion?: string
+ resolveProxy(url: string): Promise
+ proxyEnv: NodeJS.ProcessEnv
+ ask(target: LocalTarget, signal: AbortSignal): Promise
+ log(message: string): void
+ resolve?: Resolver
+}
+export interface NetworkBridge { proxyUrl: string; httpsProxyUrl: string; webUrl: string; close(): Promise }
+const token = () => randomBytes(32).toString('hex')
+const equal = (actual: string | undefined, expected: string) => {
+ if (typeof actual !== 'string') return false
+ const bytes = Buffer.from(actual), wanted = Buffer.from(expected)
+ return bytes.length === wanted.length && timingSafeEqual(bytes, wanted)
+}
+
+export async function startNetworkBridge(options: BridgeOptions): Promise {
+ const proxySecret = token(), webSecret = token()
+ const proxyAuth = `Basic ${Buffer.from(`deepviewer-http:${proxySecret}`).toString('base64')}`
+ const httpsProxyAuth = `Basic ${Buffer.from(`deepviewer-https:${proxySecret}`).toString('base64')}`
+ const webAuth = `Bearer ${webSecret}`
+ const access = new WebAccess(options.ask, options.resolve)
+ const sockets = new Set()
+ const controllers = new Set()
+ const server = createServer({ maxHeaderSize: 32768 })
+ let closing = false
+ server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)) })
+ server.on('clientError', (_error, socket) => socket.destroy())
+ server.maxConnections = 128
+ server.headersTimeout = 15000
+ server.requestTimeout = 30000
+
+ async function open(url: URL, web: boolean, signal: AbortSignal): Promise {
+ const routes = explicitRoutes(url, options.proxyEnv) ?? parseSystemRoutes(await options.resolveProxy(url.href))
+ let last: unknown
+ for (const route of routes) {
+ let addresses: Address[] | undefined
+ let chosen: Route = route
+ if (web) {
+ addresses = await access.check(url, route.kind !== 'direct', signal)
+ if (addresses) chosen = { kind: 'direct' }
+ }
+ signal.throwIfAborted()
+ if (closing) throw new NetworkError('BRIDGE_CLOSED', 'The application is closing.')
+ try {
+ const socket = await connectRoute(url, chosen, addresses, signal)
+ sockets.add(socket); socket.once('close', () => sockets.delete(socket))
+ options.log(`NETWORK_CONNECTED kind=${web ? 'web' : 'runtime'} host=${url.hostname} port=${url.port || (url.protocol === 'https:' ? '443' : '80')} route=${chosen.kind}`)
+ return socket
+ } catch (error) { last = error; signal.throwIfAborted() }
+ }
+ throw last ?? new NetworkError('NO_ROUTE', 'No network route was available.')
+ }
+ const report = (error: unknown, url?: URL) => {
+ const code = safeErrorCode(error)
+ options.log(`NETWORK_FAILED host=${url?.hostname ?? 'unknown'} code=${code}`)
+ return { code, message: `${errorDescription(code)} [${code}]` }
+ }
+ function controllerFor(socket: { once(event: 'close', listener: () => void): unknown }): AbortController {
+ const controller = new AbortController()
+ controllers.add(controller)
+ socket.once('close', () => { controller.abort(); controllers.delete(controller) })
+ return controller
+ }
+ async function webFetch(request: IncomingMessage, response: ServerResponse): Promise {
+ if (!equal(request.headers.authorization, webAuth)) { response.writeHead(401).end(); return }
+ if (request.headers.origin !== undefined) { response.writeHead(403).end(); return }
+ const target = request.headers['x-deepviewer-target']
+ let url: URL | undefined
+ const controller = controllerFor(response)
+ try {
+ if (typeof target !== 'string') throw new NetworkError('INVALID_TARGET', 'Missing web target.')
+ url = validateTarget(target)
+ const signal = controller.signal
+ const raw = await open(url, true, signal)
+ let socket: Socket
+ try { socket = await secureSocket(raw, url, signal) } catch (error) { raw.destroy(); throw error }
+ const upstream = await forwardGet(url, socket,
+ 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8', `DeepViewer${options.appVersion ? `/${options.appVersion}` : ''} (DSH web fetch)`, signal)
+ const headers = { ...upstream.headers }
+ for (const key of ['set-cookie', 'proxy-authenticate', 'proxy-authorization', 'transfer-encoding', 'connection', 'keep-alive', 'x-deepviewer-error', 'x-deepviewer-upstream']) delete headers[key]
+ response.writeHead(upstream.statusCode ?? 502, { ...headers, 'x-deepviewer-upstream': '1' })
+ upstream.once('error', () => response.destroy())
+ upstream.pipe(response)
+ } catch (error) {
+ if (controller.signal.aborted) return
+ const detail = report(error, url)
+ if (!response.headersSent) response.writeHead(502, { 'content-type': 'application/json', 'x-deepviewer-error': detail.code }).end(JSON.stringify(detail))
+ else response.destroy()
+ }
+ }
+ async function forwardHttp(request: IncomingMessage, response: ServerResponse): Promise {
+ const controller = controllerFor(response)
+ let url: URL | undefined
+ try {
+ url = validateTarget(request.url ?? '')
+ if (url.protocol !== 'http:') throw new NetworkError('INVALID_TARGET', 'HTTPS requests must use CONNECT.')
+ const socket = await open(url, false, controller.signal)
+ const agent = new Agent({ keepAlive: false }); agent.createConnection = () => socket
+ const headers = { ...request.headers, host: url.host, connection: 'close' }
+ for (const key of ['proxy-authorization', 'proxy-connection']) delete (headers as Record)[key]
+ const upstream = httpRequest(url, { method: request.method, headers, agent, signal: controller.signal })
+ upstream.once('response', received => {
+ response.writeHead(received.statusCode ?? 502, received.headers)
+ received.once('error', () => response.destroy()); received.pipe(response)
+ })
+ upstream.once('error', error => {
+ report(error, url)
+ if (!response.headersSent) response.writeHead(502).end(); else response.destroy()
+ })
+ response.once('close', () => { upstream.destroy(); agent.destroy() })
+ request.pipe(upstream)
+ } catch (error) { report(error, url); if (!response.headersSent) response.writeHead(502).end() }
+ }
+ server.on('request', (request, response) => {
+ if (request.method === 'POST' && request.url === '/web-fetch') { request.resume(); void webFetch(request, response); return }
+ if (equal(request.headers['proxy-authorization'] as string | undefined, proxyAuth) && !request.headers.origin && request.url?.startsWith('http://')) {
+ void forwardHttp(request, response); return
+ }
+ response.writeHead(407, { 'proxy-authenticate': 'Basic realm="DeepViewer"' }).end()
+ })
+ server.on('connect', (request, client, head) => {
+ const incomingAuth = request.headers['proxy-authorization'] as string | undefined
+ const httpsRoute = equal(incomingAuth, httpsProxyAuth)
+ if ((!httpsRoute && !equal(incomingAuth, proxyAuth)) || request.headers.origin !== undefined) {
+ client.end('HTTP/1.1 407 Proxy Authentication Required\r\nConnection: close\r\n\r\n'); return
+ }
+ const controller = controllerFor(client)
+ void (async () => {
+ let url: URL | undefined
+ try {
+ if (!request.url || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):\d+$/u.test(request.url)) throw new NetworkError('INVALID_TARGET', 'Invalid CONNECT authority.')
+ url = validateTarget(`${httpsRoute ? 'https' : 'http'}://${request.url}`)
+ const upstream = await open(url, false, controller.signal)
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
+ if (head.length) upstream.write(head)
+ client.pipe(upstream); upstream.pipe(client)
+ client.once('close', () => upstream.destroy())
+ upstream.once('close', () => client.destroy())
+ upstream.once('error', () => client.destroy())
+ client.once('error', () => upstream.destroy())
+ } catch (error) {
+ const detail = report(error, url)
+ if (!client.destroyed) client.end(`HTTP/1.1 502 ${detail.code}\r\nConnection: close\r\n\r\n`)
+ }
+ })()
+ })
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Network bridge did not bind a loopback port.')
+ return {
+ proxyUrl: `http://deepviewer-http:${proxySecret}@127.0.0.1:${address.port}`,
+ httpsProxyUrl: `http://deepviewer-https:${proxySecret}@127.0.0.1:${address.port}`,
+ webUrl: `http://deepviewer:${webSecret}@127.0.0.1:${address.port}/web-fetch`,
+ async close() {
+ closing = true
+ for (const controller of controllers) controller.abort()
+ for (const socket of sockets) socket.destroy()
+ await new Promise(resolve => server.close(() => resolve()))
+ },
+ }
+}
diff --git a/apps/deepviewer-adapter/desktop/network/policy.ts b/apps/deepviewer-adapter/desktop/network/policy.ts
new file mode 100644
index 0000000..f20d3bc
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/network/policy.ts
@@ -0,0 +1,88 @@
+import { readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { parseEnv } from 'node:util'
+
+const PROXY_KEYS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY', 'no_proxy', 'NO_PROXY'] as const
+export type Route = { kind: 'direct' } | { kind: 'http' | 'https' | 'socks4' | 'socks5'; host: string; port: number; username?: string; password?: string }
+export class NetworkError extends Error {
+ constructor(readonly code: string, message: string) { super(message); this.name = 'NetworkError' }
+}
+
+/** Match Harness policy: inherited > DSH_HOME .env; project proxy settings are rejected. */
+export function proxyEnvironment(inherited: NodeJS.ProcessEnv, cwd: string, home: string): NodeJS.ProcessEnv {
+ const result: NodeJS.ProcessEnv = {}
+ for (const directory of [home, cwd]) {
+ try {
+ const values = parseEnv(readFileSync(join(directory, '.env'), 'utf8'))
+ for (const key of PROXY_KEYS) if (values[key] !== undefined) {
+ if (resolve(directory) !== resolve(home)) throw new NetworkError('PROJECT_PROXY_NOT_ALLOWED', 'Proxy settings belong in the launch environment or DSH home .env, not a project .env.')
+ result[key] = values[key]
+ }
+ } catch (error) {
+ if (error instanceof NetworkError) throw error
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new NetworkError('PROXY_CONFIG_UNREADABLE', 'Cannot read the Harness proxy configuration.')
+ }
+ }
+ for (const key of PROXY_KEYS) if (inherited[key] !== undefined) result[key] = inherited[key]
+ return result
+}
+const valueOf = (env: NodeJS.ProcessEnv, name: string): string | undefined => env[name]?.trim() || env[name.toUpperCase()]?.trim() || undefined
+export const hasExplicitProxy = (env: NodeJS.ProcessEnv): boolean => ['http_proxy', 'https_proxy', 'all_proxy'].some(k => valueOf(env, k) !== undefined)
+
+export function bypassesProxy(url: URL, list: string): boolean {
+ const host = url.hostname.replace(/^\[|\]$/gu, '').replace(/\.$/u, '').toLowerCase()
+ if (host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./u.test(host)) return true
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80')
+ return list.split(/[,\s]+/u).filter(Boolean).some(entry => {
+ if (entry === '*') return true
+ const match = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/u.exec(entry)
+ const target = (match?.[1] ?? entry).replace(/^\[|\]$/gu, '').replace(/^\*?\./u, '').toLowerCase()
+ return (match?.[2] === undefined || match[2] === port) && (host === target || host.endsWith(`.${target}`))
+ })
+}
+function fromProxyUrl(value: string): Route {
+ try {
+ const url = new URL(value)
+ const kind = { 'http:': 'http', 'https:': 'https', 'socks:': 'socks5', 'socks5:': 'socks5', 'socks5h:': 'socks5', 'socks4:': 'socks4', 'socks4a:': 'socks4' }[url.protocol] as Exclude | undefined
+ const port = Number(url.port || (kind === 'https' ? 443 : kind === 'http' ? 80 : 1080))
+ if (!kind || !url.hostname || !Number.isInteger(port) || port < 1 || port > 65535 || !['', '/'].includes(url.pathname) || url.search || url.hash) throw new Error()
+ return { kind, host: url.hostname.replace(/^\[|\]$/gu, ''), port, ...(url.username ? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) } : {}) }
+ } catch { throw new NetworkError('PROXY_CONFIG_INVALID', 'The configured proxy URL is invalid or unsupported.') }
+}
+export function explicitRoutes(url: URL, env: NodeJS.ProcessEnv): Route[] | undefined {
+ const bypass = valueOf(env, 'no_proxy')
+ if (bypass && bypassesProxy(url, bypass)) return [{ kind: 'direct' }]
+ if (!hasExplicitProxy(env)) return undefined
+ if (bypassesProxy(url, valueOf(env, 'no_proxy') ?? '')) return [{ kind: 'direct' }]
+ const http = valueOf(env, 'http_proxy')
+ const proxy = url.protocol === 'https:' ? valueOf(env, 'https_proxy') ?? valueOf(env, 'all_proxy') ?? http : http ?? valueOf(env, 'all_proxy')
+ return proxy === undefined ? [{ kind: 'direct' }] : [fromProxyUrl(proxy)]
+}
+/** Chromium resolves current OS/PAC settings per destination; DIRECT is never invented as a fallback. */
+export function parseSystemRoutes(value: string): Route[] {
+ const routes = value.split(';').map(part => {
+ const [type, address] = part.trim().split(/\s+/u)
+ if (type === 'DIRECT') return { kind: 'direct' } as Route
+ const scheme = { PROXY: 'http', HTTPS: 'https', SOCKS: 'socks4', SOCKS4: 'socks4', SOCKS5: 'socks5' }[type ?? '']
+ if (!scheme || !address) throw new NetworkError('SYSTEM_PROXY_UNSUPPORTED', 'The system returned an unsupported proxy route.')
+ return fromProxyUrl(`${scheme}://${address}`)
+ })
+ if (!routes.length) throw new NetworkError('SYSTEM_PROXY_UNAVAILABLE', 'The system returned no network route.')
+ return routes
+}
+export function safeErrorCode(error: unknown): string {
+ if (error instanceof NetworkError) return error.code
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
+ if (code && /^(E[A-Z0-9_]+|ERR_[A-Z0-9_]+|CERT_[A-Z0-9_]+|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_[A-Z_]+)$/u.test(code)) return code
+ return 'NETWORK_CONNECTION_FAILED'
+}
+export function errorDescription(code: string): string {
+ if (code === 'LOCAL_ACCESS_DENIED') return 'Local/private-network access was declined. / 已拒绝本机或内网访问。'
+ if (code === 'FAKE_IP_WITHOUT_PROXY') return 'DNS returned a virtual proxy IP, but the system selected DIRECT. Enable the system proxy in your network app. / DNS 返回了代理虚拟地址,但系统未配置代理路由。'
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'DNS resolution failed. / 域名解析失败。'
+ if (code.includes('TIMEOUT') || code === 'ETIMEDOUT') return 'The network connection timed out. / 网络连接超时。'
+ if (code.includes('CERT') || code.includes('TLS') || code.includes('SSL')) return 'TLS or certificate validation failed. / TLS 或证书验证失败。'
+ if (code === 'ECONNREFUSED') return 'The target or configured proxy refused the connection. / 目标或代理拒绝连接,请检查代理是否运行。'
+ if (code === 'ECONNRESET') return 'The target or proxy reset the connection. / 目标或代理中断了连接。'
+ return 'The configured network route failed. / 当前网络路由连接失败。'
+}
diff --git a/apps/deepviewer-adapter/desktop/network/runtime-network.ts b/apps/deepviewer-adapter/desktop/network/runtime-network.ts
new file mode 100644
index 0000000..ee601b7
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/network/runtime-network.ts
@@ -0,0 +1,19 @@
+interface RuntimeLaunchSpec { cwd: string; env: NodeJS.ProcessEnv; fallback?: RuntimeLaunchSpec }
+import { proxyEnvironment, hasExplicitProxy } from './policy.js'
+import { startNetworkBridge, type BridgeOptions, type NetworkBridge } from './bridge.js'
+
+/** Configure all fallback launches identically without persisting credentials or altering process.env. */
+export async function configureRuntimeNetwork(spec: RuntimeLaunchSpec, options: Omit): Promise {
+ const settings = proxyEnvironment(spec.env, spec.cwd, spec.env.DSH_HOME ?? spec.cwd)
+ const explicit = hasExplicitProxy(settings)
+ const bridge = await startNetworkBridge({ ...options, proxyEnv: settings })
+ for (let launch: RuntimeLaunchSpec | undefined = spec; launch; launch = launch.fallback) {
+ launch.env = { ...launch.env, DEEPVIEWER_WEB_BRIDGE: bridge.webUrl }
+ launch.env.HTTP_PROXY = bridge.proxyUrl
+ launch.env.HTTPS_PROXY = bridge.httpsProxyUrl
+ launch.env.http_proxy = bridge.proxyUrl
+ launch.env.https_proxy = bridge.httpsProxyUrl
+ }
+ options.log(`NETWORK_READY source=${explicit ? 'explicit' : 'system'} web=desktop-authorized`)
+ return bridge
+}
diff --git a/apps/deepviewer-adapter/desktop/network/transport.ts b/apps/deepviewer-adapter/desktop/network/transport.ts
new file mode 100644
index 0000000..8211c47
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/network/transport.ts
@@ -0,0 +1,91 @@
+import { createConnection, isIP, type Socket } from 'node:net'
+import { connect as tlsConnect, checkServerIdentity } from 'node:tls'
+import * as http from 'node:http'
+import * as https from 'node:https'
+import { SocksClient } from 'socks'
+import type { Address } from './access.js'
+import { NetworkError, type Route } from './policy.js'
+
+const authority = (url: URL) => `${url.hostname}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`
+export async function connectRoute(url: URL, route: Route, addresses: Address[] | undefined, signal: AbortSignal): Promise {
+ signal.throwIfAborted()
+ const host = url.hostname.replace(/^\[|\]$/gu, '')
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
+ if (route.kind === 'direct') {
+ // The web gate supplies an immutable answer set; Node never resolves it again.
+ const targets = addresses?.map(a => a.address) ?? [host]
+ let last: unknown
+ for (const target of targets) {
+ try {
+ return await new Promise((resolve, reject) => {
+ const socket = createConnection({ host: target, port, signal })
+ socket.setTimeout(10000, () => socket.destroy(new NetworkError('CONNECT_TIMEOUT', 'Connection timed out.')))
+ socket.once('connect', () => { socket.setTimeout(0); resolve(socket) })
+ socket.once('error', reject)
+ })
+ } catch (error) { last = error; signal.throwIfAborted() }
+ }
+ throw last
+ }
+ if (route.kind === 'socks4' || route.kind === 'socks5') {
+ const { socket } = await SocksClient.createConnection({
+ command: 'connect', destination: { host, port }, timeout: 15000,
+ proxy: { host: route.host, port: route.port, type: route.kind === 'socks4' ? 4 : 5,
+ ...(route.username === undefined ? {} : { userId: route.username, password: route.password ?? '' }) },
+ })
+ if (signal.aborted) { socket.destroy(); signal.throwIfAborted() }
+ return socket
+ }
+ return new Promise((resolve, reject) => {
+ const request = (route.kind === 'https' ? https : http).request({
+ hostname: route.host, port: route.port, method: 'CONNECT', path: authority(url), agent: false, signal,
+ headers: { host: authority(url), ...(route.username === undefined ? {} : {
+ 'proxy-authorization': `Basic ${Buffer.from(`${route.username}:${route.password ?? ''}`).toString('base64')}`,
+ }) },
+ })
+ request.setTimeout(15000, () => request.destroy(new NetworkError('PROXY_CONNECT_TIMEOUT', 'Proxy connection timed out.')))
+ request.once('connect', (response, socket, head) => {
+ if (response.statusCode !== 200) {
+ socket.destroy()
+ reject(new NetworkError(`PROXY_HTTP_${String(response.statusCode)}`, 'The proxy rejected the connection.'))
+ } else { if (head.length) socket.unshift(head); socket.setTimeout(0); resolve(socket) }
+ })
+ request.once('error', reject)
+ request.end()
+ })
+}
+
+export async function secureSocket(socket: Socket, url: URL, signal: AbortSignal): Promise {
+ if (url.protocol !== 'https:') return socket
+ const hostname = url.hostname.replace(/^\[|\]$/gu, '')
+ return new Promise((resolve, reject) => {
+ const secure = tlsConnect({ socket, host: hostname, ...(isIP(hostname) ? {} : { servername: hostname }),
+ rejectUnauthorized: true, checkServerIdentity: (_name, cert) => checkServerIdentity(hostname, cert) })
+ const abort = () => secure.destroy(new NetworkError('REQUEST_ABORTED', 'Request cancelled.'))
+ signal.addEventListener('abort', abort, { once: true })
+ secure.once('close', () => signal.removeEventListener('abort', abort))
+ if (signal.aborted) abort()
+ secure.setTimeout(15000, () => secure.destroy(new NetworkError('TLS_TIMEOUT', 'TLS negotiation timed out.')))
+ secure.once('secureConnect', () => { secure.setTimeout(0); resolve(secure) })
+ secure.once('error', reject)
+ })
+}
+
+/** Anonymous web requests never inherit browser cookies or proxy credentials. Redirects stay manual. */
+export function forwardGet(url: URL, socket: Socket, accept: string, userAgent: string, signal: AbortSignal): Promise {
+ const secure = url.protocol === 'https:'
+ const agent = secure ? new https.Agent({ keepAlive: false }) : new http.Agent({ keepAlive: false })
+ agent.createConnection = () => socket
+ return new Promise((resolve, reject) => {
+ const request = (secure ? https : http).request(url, {
+ method: 'GET', agent, signal,
+ headers: { accept, 'user-agent': userAgent, 'accept-encoding': 'identity', connection: 'close' },
+ })
+ request.once('response', response => {
+ response.once('close', () => agent.destroy())
+ resolve(response)
+ })
+ request.once('error', error => { agent.destroy(); reject(error) })
+ request.end()
+ })
+}
diff --git a/apps/deepviewer-adapter/desktop/preload-state.cjs b/apps/deepviewer-adapter/desktop/preload-state.cjs
new file mode 100644
index 0000000..65bebe2
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/preload-state.cjs
@@ -0,0 +1,10 @@
+// Append to the official sandboxed preload; only the fixed product origin receives allowlisted state.
+;(function deepviewerStateMigration() {
+ if (location.protocol !== 'dsh-app:' || location.hostname !== 'app' || !process.isMainFrame) return
+ const { ipcRenderer } = require('electron')
+ try {
+ const values = ipcRenderer.sendSync('deepviewer-state-bootstrap')
+ for (const [key, value] of Object.entries(values)) if (localStorage.getItem(key) === null) localStorage.setItem(key, value)
+ void ipcRenderer.invoke('deepviewer-state-imported').catch(() => undefined)
+ } catch (error) { console.error('DeepViewer frontend migration is pending', error) }
+})()
diff --git a/apps/deepviewer-adapter/desktop/profile/development-profile.ts b/apps/deepviewer-adapter/desktop/profile/development-profile.ts
new file mode 100644
index 0000000..2cc4423
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/profile/development-profile.ts
@@ -0,0 +1,44 @@
+import { join } from 'node:path'
+
+export const DEEPVIEWER_DEVELOPMENT_PROFILE = 'development'
+export const DEEPVIEWER_DEVELOPMENT_APP_NAME = 'DeepViewer Dev'
+
+interface DevelopmentProfileApp {
+ getName(): string
+ getPath(name: 'appData'): string
+ setPath(name: 'userData', path: string): void
+}
+
+export function shouldUseDevelopmentProfile(
+ initialAppName: string,
+ requestedProfile: string | undefined,
+): boolean {
+ return requestedProfile === DEEPVIEWER_DEVELOPMENT_PROFILE
+ || initialAppName === DEEPVIEWER_DEVELOPMENT_APP_NAME
+}
+
+export function resolveDevelopmentUserDataPath(appDataPath: string): string {
+ return join(appDataPath, 'DeepViewer Dev')
+}
+
+export function resolveInstalledUserDataPath(appDataPath: string): string {
+ return join(appDataPath, 'DeepViewer')
+}
+
+export function developmentUserDataOverride(environment: NodeJS.ProcessEnv): string | undefined {
+ return environment.DEEPVIEWER_DEV_USER_DATA
+}
+
+export function configureDevelopmentProfile(
+ electronApp: DevelopmentProfileApp,
+ environment: NodeJS.ProcessEnv = process.env,
+): boolean {
+ if (!shouldUseDevelopmentProfile(electronApp.getName(), environment.DEEPVIEWER_PROFILE)) {
+ return false
+ }
+ electronApp.setPath(
+ 'userData',
+ developmentUserDataOverride(environment) ?? resolveDevelopmentUserDataPath(electronApp.getPath('appData')),
+ )
+ return true
+}
diff --git a/apps/deepviewer-adapter/desktop/profile/dock-theme.ts b/apps/deepviewer-adapter/desktop/profile/dock-theme.ts
new file mode 100644
index 0000000..4ccfef4
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/profile/dock-theme.ts
@@ -0,0 +1,41 @@
+import { join } from 'node:path'
+
+interface ThemeSource {
+ readonly shouldUseDarkColors: boolean
+ on(event: 'updated', listener: () => void): unknown
+ removeListener(event: 'updated', listener: () => void): unknown
+}
+
+// nativeTheme resolves light/dark/system from the existing renderer theme IPC.
+// These PNGs are extracted from Apple's compiled ICNS, not full-bleed artwork.
+export function followDockTheme(options: {
+ theme: ThemeSource
+ resourcesPath: string
+ loadImage(path: string): Image
+ setIcon(image: Image): void
+ log(message: string): void
+ onError(message: string): void
+}): () => void {
+ const images = new Map()
+ let applied: string | undefined
+ const update = () => {
+ const appearance = options.theme.shouldUseDarkColors ? 'dark' : 'light'
+ if (applied === appearance) return
+ try {
+ let image = images.get(appearance)
+ if (!image) {
+ image = options.loadImage(join(options.resourcesPath, 'DeepViewerDockThemes', `${appearance}.png`))
+ if (image.isEmpty()) throw new Error(`Missing native Dock rendition: ${appearance}`)
+ images.set(appearance, image)
+ }
+ options.setIcon(image)
+ applied = appearance
+ options.log(`appearance=${appearance}`)
+ } catch (error) {
+ options.onError(error instanceof Error ? error.message : String(error))
+ }
+ }
+ options.theme.on('updated', update)
+ update()
+ return () => options.theme.removeListener('updated', update)
+}
diff --git a/apps/deepviewer-adapter/desktop/profile/profile-migration.ts b/apps/deepviewer-adapter/desktop/profile/profile-migration.ts
new file mode 100644
index 0000000..0cba70f
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/profile/profile-migration.ts
@@ -0,0 +1,150 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import {
+ chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync,
+ mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync,
+ symlinkSync, unlinkSync, writeFileSync,
+} from 'node:fs'
+import { dirname, join, resolve, sep } from 'node:path'
+import { assertProfileIdle } from './user-data-migration.js'
+
+const receiptName = '.deepviewer-profile.json'
+const omittedRoots = new Set(['Cache', 'Code Cache', 'GPUCache', 'DawnGraphiteCache',
+ 'DawnWebGPUCache', 'Crashpad', 'data-migrations', 'SingletonLock', 'SingletonSocket', 'SingletonCookie'])
+interface Entry { kind: 'file' | 'link' | 'directory'; value: string; mode: number }
+type Inventory = Record
+export interface ProfileMigrationOptions {
+ appData: string
+ development: boolean
+ explicitDirectory?: boolean
+ checkpoint?: (phase: 'copied' | 'verified' | 'published') => void
+}
+
+function directory(path: string): boolean {
+ try {
+ const stat = lstatSync(path)
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('DeepViewer 数据目录必须是普通文件夹')
+ return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
+ throw error
+ }
+}
+function privateDirectory(path: string): void {
+ if (!directory(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
+}
+function sync(path: string): void {
+ const fd = openSync(path, 'r')
+ try { fsyncSync(fd) } finally { closeSync(fd) }
+}
+function syncDirectories(path: string): void {
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
+ if (entry.isDirectory()) syncDirectories(join(path, entry.name))
+ }
+ sync(path)
+}
+function omitted(relative: string): boolean {
+ const parts = relative.split('/')
+ return omittedRoots.has(parts[0]!) || /^dsh-\d/u.test(parts[0]!)
+ || relative === receiptName || parts.at(-1) === '.DS_Store'
+ || (relative.startsWith('harness-home/sessions/') && parts.at(-1) === 'session.lock')
+ || relative === 'harness-home/cache' || relative === 'harness-home/storages/session_projcache'
+}
+function inventory(root: string): Inventory {
+ const result: Inventory = Object.create(null) as Inventory
+ const walk = (relative: string) => {
+ for (const name of readdirSync(join(root, relative)).sort()) {
+ const file = relative ? `${relative}/${name}` : name
+ if (omitted(file)) continue
+ const path = join(root, file), stat = lstatSync(path)
+ if (stat.isDirectory()) { result[file] = { kind: 'directory', value: '', mode: stat.mode & 0o777 }; walk(file) }
+ else if (stat.isSymbolicLink()) result[file] = { kind: 'link', value: readlinkSync(path), mode: stat.mode & 0o777 }
+ else if (stat.isFile()) result[file] = { kind: 'file', value: createHash('sha256').update(readFileSync(path)).digest('hex'), mode: stat.mode & 0o777 }
+ else throw new Error('DeepViewer 数据内有不支持的特殊文件;源目录已保留')
+ }
+ }
+ walk('')
+ return result
+}
+function same(a: Inventory, b: Inventory): boolean {
+ return Object.keys(a).length === Object.keys(b).length
+ && Object.entries(a).every(([key, value]) => value.kind === b[key]?.kind && value.value === b[key]?.value)
+}
+function recency(profile: string): number {
+ let latest = 0
+ const walk = (path: string): void => {
+ const stat = lstatSync(path)
+ if (stat.isDirectory()) {
+ for (const name of readdirSync(path)) if (!['node_modules', 'cache', 'logs', 'session_projcache'].includes(name)) walk(join(path, name))
+ } else if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs)
+ }
+ walk(join(profile, 'harness-home'))
+ return latest
+}
+
+/** Copies one same-channel profile before Electron opens storage. Source files are never removed. */
+export function migrateDeepViewerProfile(options: ProfileMigrationOptions): { migrated: boolean; source?: string; files?: number; report?: string } {
+ if (options.explicitDirectory) return { migrated: false }
+ const target = join(options.appData, options.development ? 'DeepViewer Dev' : 'DeepViewer')
+ if (directory(target) && (existsSync(join(target, receiptName)) || directory(join(target, 'harness-home')))) return { migrated: false }
+ privateDirectory(options.appData)
+ const pattern = options.development ? /^DeepViewer Dev \d+\.\d+\.\d+(?:[-.][\w.-]+)?$/u : /^DeepViewer \d+\.\d+\.\d+(?:[-.][\w.-]+)?$/u
+ const candidates = readdirSync(options.appData).filter(name => pattern.test(name))
+ .map(name => join(options.appData, name)).filter(path => directory(path) && directory(join(path, 'harness-home')))
+ .sort((a, b) => recency(b) - recency(a) || a.localeCompare(b))
+ const source = candidates[0]
+ if (!source) return { migrated: false }
+ const migrations = join(options.appData, 'DeepViewer Migrations')
+ privateDirectory(migrations)
+ const lock = join(migrations, options.development ? 'development.lock' : 'installed.lock')
+ // macOS shlock atomically owns the PID lock and recovers locks of dead processes.
+ try { execFileSync('/usr/bin/shlock', ['-p', String(process.pid), '-f', lock], { stdio: 'pipe' }) }
+ catch { throw new Error('DeepViewer 数据迁移正在进行,请稍后重试') }
+ try {
+ if (directory(target) && (existsSync(join(target, receiptName)) || directory(join(target, 'harness-home')))) return { migrated: false }
+ assertProfileIdle(source)
+ if (directory(target)) {
+ assertProfileIdle(target)
+ if (readdirSync(target).length) throw new Error('DeepViewer 目标目录已有文件,未覆盖;源目录已保留')
+ }
+ const before = inventory(source)
+ const transaction = join(migrations, randomUUID()), stage = join(transaction, 'profile')
+ privateDirectory(stage)
+ for (const [relative, entry] of Object.entries(before)) {
+ const destination = join(stage, relative)
+ privateDirectory(dirname(destination))
+ if (entry.kind === 'directory') privateDirectory(destination)
+ else if (entry.kind === 'link') symlinkSync(entry.value, destination)
+ else {
+ copyFileSync(join(source, relative), destination)
+ chmodSync(destination, 0o600 | (entry.mode & 0o100))
+ sync(destination)
+ }
+ }
+ options.checkpoint?.('copied')
+ if (!same(before, inventory(stage))) throw new Error('DeepViewer 迁移副本校验失败;源目录已保留')
+ options.checkpoint?.('verified')
+ assertProfileIdle(source)
+ if (!same(before, inventory(source))) throw new Error('DeepViewer 源数据在迁移时发生变化;未切换目录')
+ // Internal absolute links follow the new profile; external project links stay unchanged.
+ for (const [relative, entry] of Object.entries(before)) {
+ if (entry.kind !== 'link' || !(entry.value === source || entry.value.startsWith(`${source}${sep}`))) continue
+ const link = join(stage, relative)
+ unlinkSync(link); symlinkSync(resolve(target, '.' + entry.value.slice(source.length)), link)
+ }
+ const receipt = { schemaVersion: 1, source, target, completedAt: new Date().toISOString(), files: Object.values(before).filter(entry => entry.kind !== 'directory').length }
+ const report = join(transaction, 'report.json')
+ writeFileSync(report, JSON.stringify({ ...receipt, inventory: before }, null, 2) + '\n', { mode: 0o600 })
+ writeFileSync(join(stage, receiptName), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 })
+ sync(report); sync(join(stage, receiptName)); syncDirectories(stage); sync(transaction)
+ renameSync(stage, target)
+ sync(options.appData)
+ options.checkpoint?.('published')
+ return { migrated: true, source, files: receipt.files, report }
+ } finally {
+ if (readFileSync(lock, 'utf8').trim() === String(process.pid)) unlinkSync(lock)
+ }
+}
+
+/** Established DeepViewer filenames stay in place; the scanner imports old report metadata itself. */
+export function migrateDeepViewerFileNames(_userData: string): void {}
diff --git a/apps/deepviewer-adapter/desktop/profile/user-data-migration.ts b/apps/deepviewer-adapter/desktop/profile/user-data-migration.ts
new file mode 100644
index 0000000..84f3e95
--- /dev/null
+++ b/apps/deepviewer-adapter/desktop/profile/user-data-migration.ts
@@ -0,0 +1,323 @@
+import { createHash, randomUUID } from 'node:crypto'
+import {
+ chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync,
+ openSync, readFileSync, readdirSync, readlinkSync, renameSync,
+ unlinkSync, writeFileSync,
+} from 'node:fs'
+import { dirname, join } from 'node:path'
+import { hostname } from 'node:os'
+import { execFileSync } from 'node:child_process'
+
+// This version describes the desktop directory layout, NOT the app or DSH version.
+export const DATA_LAYOUT_VERSION = 1
+export const DATA_LAYOUT_FILE = '.deepviewer-layout.json'
+
+export interface MigrationOptions {
+ userData: string
+ appData: string
+ development: boolean
+ explicitDirectory?: boolean
+ /** Test seam for crash/race injection; production never supplies it. */
+ checkpoint?: (phase: 'snapshotted' | 'prepared' | 'backed-up' | 'published') => void
+}
+interface Entry { hash: string; size: number; mtime: number }
+type Inventory = Record
+interface Snapshot { root: string; files: Inventory; index: number }
+interface Conflict { path: string; keptSource: number; otherSource: number }
+interface Ledger {
+ schemaVersion: number
+ transaction: string
+ completedAt: string
+ sources: string[]
+ conflicts: Conflict[]
+}
+export interface MigrationResult {
+ migrated: boolean
+ sourceCount: number
+ conflictCount: number
+ backupDirectory?: string
+}
+
+function fail(message: string): never {
+ throw new Error(`用户数据准备失败 / User data preparation failed: ${message}`)
+}
+function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT' }
+function isDirectory(path: string): boolean {
+ try {
+ const stat = lstatSync(path)
+ if (stat.isSymbolicLink()) fail('数据目录不能是符号链接 / Linked data directory is not supported')
+ return stat.isDirectory()
+ } catch (error) { if (isMissing(error)) return false; throw error }
+}
+function privateDirectory(path: string): void { mkdirSync(path, { recursive: true, mode: 0o700 }) }
+function syncDirectory(path: string): void {
+ const fd = openSync(path, 'r')
+ try { fsyncSync(fd) } finally { closeSync(fd) }
+}
+function syncTree(path: string): void {
+ for (const name of readdirSync(path)) {
+ const child = join(path, name)
+ if (lstatSync(child).isDirectory()) syncTree(child)
+ }
+ syncDirectory(path)
+}
+function writeJson(path: string, data: unknown): void {
+ const temp = `${path}.${randomUUID()}.tmp`
+ const fd = openSync(temp, 'wx', 0o600)
+ try { writeFileSync(fd, `${JSON.stringify(data, null, 2)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
+ renameSync(temp, path)
+ syncDirectory(dirname(path))
+}
+function readLedger(home: string): Ledger | undefined {
+ const path = join(home, DATA_LAYOUT_FILE)
+ if (!existsSync(path)) return undefined
+ if (!lstatSync(path).isFile()) fail('无效的数据版本记录 / Invalid data version record')
+ const ledger = JSON.parse(readFileSync(path, 'utf8')) as Ledger
+ if (ledger.schemaVersion !== DATA_LAYOUT_VERSION || typeof ledger.transaction !== 'string'
+ || !Array.isArray(ledger.sources) || !Array.isArray(ledger.conflicts)) {
+ fail('数据版本不受支持,请使用兼容版本 / Unsupported data layout; use a compatible app version')
+ }
+ return ledger
+}
+function skipped(path: string): boolean {
+ const parts = path.split('/')
+ return parts.includes('node_modules') || parts.includes('.dsh-module-fallback')
+ || path === 'storages/session_projcache' || path.startsWith('storages/session_projcache/')
+ || path === DATA_LAYOUT_FILE || (path.startsWith('sessions/') && parts.at(-1) === 'session.lock')
+}
+function inventory(root: string): Inventory {
+ const result: Inventory = Object.create(null) as Inventory
+ const walk = (dir: string, prefix: string): void => {
+ for (const name of readdirSync(dir).sort()) {
+ const relative = prefix ? `${prefix}/${name}` : name
+ if (skipped(relative)) continue
+ const path = join(dir, name)
+ const stat = lstatSync(path)
+ if (stat.isDirectory()) walk(path, relative)
+ else if (stat.isFile()) result[relative] = {
+ hash: createHash('sha256').update(readFileSync(path)).digest('hex'), size: stat.size, mtime: stat.mtimeMs,
+ }
+ else fail('数据内含不支持的链接或特殊文件,未切换目录 / Unsupported link or special file; data was not switched')
+ }
+ }
+ walk(root, '')
+ return result
+}
+function equalFiles(a: Inventory, b: Inventory): boolean {
+ const keys = Object.keys(a)
+ return keys.length === Object.keys(b).length && keys.every(k => a[k]!.hash === b[k]?.hash && a[k]!.size === b[k]?.size)
+}
+function copyPrivate(source: string, dest: string): void {
+ privateDirectory(dirname(dest))
+ copyFileSync(source, dest)
+ chmodSync(dest, 0o600)
+ const fd = openSync(dest, 'r')
+ try { fsyncSync(fd) } finally { closeSync(fd) }
+}
+
+/** Called before snapshots AND immediately before the directory switch. */
+export function assertProfileIdle(profile: string): void {
+ try {
+ const lock = readlinkSync(join(profile, 'SingletonLock'))
+ const match = /^(.*)-(\d+)$/u.exec(lock)
+ if (!match || match[1] !== hostname()) fail('请先退出旧版应用 / Quit the older app before importing its data')
+ const pid = Number(match[2])
+ if (pid !== process.pid) {
+ try { process.kill(pid, 0); fail('请先退出旧版应用 / Quit the older app before importing its data') }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
+ }
+ } catch (error) { if (!isMissing(error)) throw error }
+ // Old orphaned DSH processes can outlive Electron. Never copy live session writers.
+ const sessions = join(profile, 'harness-home', 'sessions')
+ if (process.platform === 'darwin' && isDirectory(sessions)) {
+ try {
+ const pids = execFileSync('/usr/sbin/lsof', ['-t', '+D', sessions], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
+ if (pids.trim()) fail('旧会话仍被进程使用,请先退出 / Old sessions are still open; quit the older runtime')
+ } catch (error) {
+ if ((error as { status?: number }).status !== 1) throw error
+ }
+ }
+}
+function discover(options: MigrationOptions): string[] {
+ if (options.explicitDirectory) return []
+ const parent = options.development ? options.userData : join(options.appData, 'DeepViewer Preview')
+ if (!isDirectory(parent)) return []
+ const pattern = options.development ? /^dsh-\d+\.\d+\.\d+(?:[-.][\w.-]+)?$/u : /^\d+\.\d+\.\d+-preview\.\d+$/u
+ return readdirSync(parent).filter(name => pattern.test(name))
+ .map(name => join(parent, name)).filter(path => isDirectory(path) && isDirectory(join(path, 'harness-home')))
+}
+function recency(files: Inventory): number {
+ // Runtime caches/locks and directory mtimes are not evidence of actual use.
+ return Math.max(0, ...Object.entries(files)
+ .filter(([path]) => path.startsWith('sessions/') || path === 'settings.yaml' || path === 'storages/workspace.json')
+ .map(([, entry]) => entry.mtime))
+}
+
+interface WorkspaceRow { path: string; title: string; sessionIds: string[]; createdAt: string; updatedAt: string }
+interface WorkspaceDocument {
+ unit: { name: string; version: number }
+ global: { initialized: boolean; workspaceIds: string[]; archivedSessionIds?: string[]; pendingMutation?: unknown }
+ tables: { workspaces: Record }
+}
+function stringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(x => typeof x === 'string') }
+function readWorkspace(path: string): WorkspaceDocument {
+ const doc = JSON.parse(readFileSync(path, 'utf8')) as WorkspaceDocument
+ if (doc?.unit?.name !== 'workspace' || doc.unit.version !== 2 || typeof doc.global?.initialized !== 'boolean'
+ || !stringArray(doc.global.workspaceIds) || (doc.global.archivedSessionIds !== undefined && !stringArray(doc.global.archivedSessionIds))
+ || doc.global.pendingMutation !== undefined || !doc.tables?.workspaces || typeof doc.tables.workspaces !== 'object'
+ || Object.keys(doc.tables).some(key => key !== 'workspaces')) {
+ fail('不支持的工作区登记格式,原数据已保留 / Unsupported workspace registry; original data preserved')
+ }
+ const ids = Object.keys(doc.tables.workspaces)
+ if (new Set(doc.global.workspaceIds).size !== ids.length || doc.global.workspaceIds.some(id => !Object.hasOwn(doc.tables.workspaces, id))) {
+ fail('工作区登记不完整,原数据已保留 / Inconsistent workspace registry; original data preserved')
+ }
+ const owned = new Set()
+ for (const row of Object.values(doc.tables.workspaces)) {
+ if (!row || typeof row.path !== 'string' || typeof row.title !== 'string' || typeof row.createdAt !== 'string'
+ || typeof row.updatedAt !== 'string' || !stringArray(row.sessionIds)) fail('无效工作区记录 / Invalid workspace record')
+ for (const id of row.sessionIds) {
+ if (owned.has(id)) fail('会话归属冲突 / Conflicting session ownership')
+ owned.add(id)
+ }
+ }
+ return doc
+}
+function mergeWorkspaces(snapshots: Snapshot[], stage: string): void {
+ const docs = snapshots.filter(s => s.files['storages/workspace.json'])
+ .map(s => readWorkspace(join(s.root, 'storages/workspace.json')))
+ if (!docs.length) return
+ const merged: WorkspaceDocument = { unit: { name: 'workspace', version: 2 }, global: { initialized: true, workspaceIds: [], archivedSessionIds: [] }, tables: { workspaces: Object.create(null) as Record } }
+ const byPath = new Map()
+ const owned = new Set()
+ const archiveDecided = new Set()
+ for (const doc of docs) {
+ const archive = new Set(doc.global.archivedSessionIds ?? [])
+ for (const id of doc.global.workspaceIds) {
+ const row = doc.tables.workspaces[id]!
+ let destId = byPath.get(row.path)
+ if (destId === undefined) {
+ destId = Object.hasOwn(merged.tables.workspaces, id) ? randomUUID() : id
+ byPath.set(row.path, destId)
+ merged.global.workspaceIds.push(destId)
+ merged.tables.workspaces[destId] = { ...row, sessionIds: [] }
+ }
+ for (const sessionId of row.sessionIds) {
+ if (!owned.has(sessionId)) { merged.tables.workspaces[destId]!.sessionIds.push(sessionId); owned.add(sessionId) }
+ if (!archiveDecided.has(sessionId)) {
+ archiveDecided.add(sessionId)
+ if (archive.has(sessionId)) merged.global.archivedSessionIds!.push(sessionId)
+ }
+ }
+ }
+ // Archived Chat sessions may have no workspace owner.
+ for (const id of archive) if (!archiveDecided.has(id)) { merged.global.archivedSessionIds!.push(id); archiveDecided.add(id) }
+ }
+ privateDirectory(join(stage, 'storages'))
+ writeJson(join(stage, 'storages/workspace.json'), merged)
+}
+
+function recover(userData: string): void {
+ const parent = join(userData, 'data-migrations')
+ const pending = join(parent, 'pending.json')
+ if (!existsSync(pending)) return
+ if (!lstatSync(pending).isFile()) fail('无效迁移记录 / Invalid migration journal')
+ const journal = JSON.parse(readFileSync(pending, 'utf8')) as { transaction: string }
+ if (!/^1-[a-f\d-]{36}$/u.test(journal.transaction)) fail('无效迁移记录 / Invalid migration journal')
+ const transaction = join(parent, journal.transaction)
+ const home = join(userData, 'harness-home')
+ const previous = join(transaction, 'previous-home')
+ const stage = join(transaction, 'stage')
+ isDirectory(transaction)
+ if (isDirectory(home) && readLedger(home)?.transaction === journal.transaction) {
+ // Publication committed, only journal cleanup was interrupted.
+ } else if (!isDirectory(home) && isDirectory(previous)) {
+ renameSync(previous, home) // Restore before retrying, never open an empty profile.
+ } else if (!isDirectory(home) && isDirectory(stage) && readLedger(stage)?.transaction === journal.transaction) {
+ renameSync(stage, home) // Fresh profile had no previous-home to restore.
+ } else if (!isDirectory(home) || isDirectory(previous)) {
+ fail('迁移恢复状态不明确,备份已保留 / Ambiguous recovery state; backups preserved')
+ }
+ syncDirectory(userData)
+ unlinkSync(pending)
+ syncDirectory(parent)
+}
+
+/** Must run under Electron's stable-profile single-instance lock, before DSH starts. */
+export function prepareUserData(options: MigrationOptions): MigrationResult {
+ isDirectory(options.userData) // Reject a linked root before creating any children.
+ privateDirectory(options.userData)
+ isDirectory(join(options.userData, 'data-migrations'))
+ recover(options.userData)
+ const home = join(options.userData, 'harness-home')
+ if (isDirectory(home)) {
+ const ledger = readLedger(home)
+ if (ledger) return { migrated: false, sourceCount: ledger.sources.length, conflictCount: ledger.conflicts.length }
+ }
+ const profiles = [...(isDirectory(home) ? [options.userData] : []), ...discover(options)]
+ for (const profile of profiles) assertProfileIdle(profile)
+ const sources = profiles.map(profile => ({ profile, files: inventory(join(profile, 'harness-home')) }))
+ .sort((a, b) => recency(b.files) - recency(a.files) || Number(b.profile === options.userData) - Number(a.profile === options.userData) || a.profile.localeCompare(b.profile))
+ const id = `1-${randomUUID()}`
+ const parent = join(options.userData, 'data-migrations')
+ if (existsSync(parent) && !isDirectory(parent)) fail('无效迁移目录 / Invalid migration directory')
+ const transaction = join(parent, id)
+ const stage = join(transaction, 'stage')
+ privateDirectory(stage)
+ const snapshots: Snapshot[] = sources.map((source, index) => {
+ const root = join(transaction, 'sources', String(index))
+ privateDirectory(root)
+ for (const file of Object.keys(source.files)) copyPrivate(join(source.profile, 'harness-home', file), join(root, file))
+ if (!equalFiles(source.files, inventory(root))) fail('备份校验失败,未切换目录 / Backup verification failed; data was not switched')
+ return { root, files: source.files, index }
+ })
+ options.checkpoint?.('snapshotted')
+ const chosen = new Map()
+ const sessionOwners = new Map()
+ const conflicts: Conflict[] = []
+ for (const source of snapshots) {
+ for (const [file, entry] of Object.entries(source.files)) {
+ const parts = file.split('/')
+ if (parts[0] === 'sessions' && parts.length >= 4) {
+ const unit = parts.slice(0, 3).join('/')
+ const owner = sessionOwners.get(unit)
+ if (owner !== undefined && owner !== source.index) {
+ if (chosen.get(file)?.hash !== entry.hash) conflicts.push({ path: unit, keptSource: owner, otherSource: source.index })
+ continue // Never mix generations from separate histories of the same session.
+ }
+ sessionOwners.set(unit, source.index)
+ }
+ const existing = chosen.get(file)
+ if (existing) {
+ if (existing.hash !== entry.hash) conflicts.push({ path: file, keptSource: existing.source.index, otherSource: source.index })
+ continue
+ }
+ chosen.set(file, { source, hash: entry.hash })
+ copyPrivate(join(source.root, file), join(stage, file))
+ }
+ }
+ mergeWorkspaces(snapshots, stage)
+ const staged = inventory(stage)
+ for (const [file, expected] of chosen) {
+ if (file !== 'storages/workspace.json' && staged[file]?.hash !== expected.hash) fail('数据校验失败 / Data verification failed')
+ }
+ const ledger: Ledger = { schemaVersion: DATA_LAYOUT_VERSION, transaction: id, completedAt: new Date().toISOString(), sources: sources.map(s => s.profile), conflicts }
+ writeJson(join(stage, DATA_LAYOUT_FILE), ledger)
+ writeJson(join(transaction, 'report.json'), ledger)
+ syncTree(transaction)
+ for (const source of sources) {
+ assertProfileIdle(source.profile)
+ if (!equalFiles(source.files, inventory(join(source.profile, 'harness-home')))) fail('旧数据发生变化,请退出旧版后重试 / Source data changed; quit the older app and retry')
+ }
+ writeJson(join(parent, 'pending.json'), { transaction: id })
+ options.checkpoint?.('prepared')
+ if (isDirectory(home)) renameSync(home, join(transaction, 'previous-home'))
+ syncDirectory(options.userData)
+ options.checkpoint?.('backed-up')
+ renameSync(stage, home)
+ syncDirectory(options.userData)
+ options.checkpoint?.('published')
+ unlinkSync(join(parent, 'pending.json'))
+ syncDirectory(parent)
+ return { migrated: true, sourceCount: sources.length, conflictCount: conflicts.length, backupDirectory: transaction }
+}
diff --git a/apps/deepviewer-adapter/index.ts b/apps/deepviewer-adapter/index.ts
new file mode 100644
index 0000000..673fad2
--- /dev/null
+++ b/apps/deepviewer-adapter/index.ts
@@ -0,0 +1,17 @@
+// @ts-ignore JavaScript adapter is exercised by isolated host fixtures.
+import { registerSynapse } from './workflows/synapse-host.mjs'
+import { registerWorkspaceOperations } from './workflows/workspace-rpc.ts'
+import type {} from '@deepseek-ai/dsh-fs'
+import type {} from '@deepseek-ai/dsh-tool-present'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import { registerAutoDelivery } from './workflows/auto-delivery.ts'
+import { FILE_DELIVERY_PROMPT } from './workflows/file-delivery-prompt.ts'
+export const name = 'DeepViewerAdapter'
+export const inject = ['systemPrompt', 'tools', 'fs', 'sandboxPolicy', 'sessionProjections', 'connection', 'webServer', 'sessionQuery']
+export function apply(ctx: Context): void {
+ registerSynapse(ctx)
+ registerWorkspaceOperations(ctx)
+ registerAutoDelivery(ctx)
+ ctx.systemPrompt.section({ name: 'deepviewer:required-file-delivery', order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'), text: FILE_DELIVERY_PROMPT })
+}
diff --git a/apps/deepviewer-adapter/migrations/desktop-profile.ts b/apps/deepviewer-adapter/migrations/desktop-profile.ts
new file mode 100644
index 0000000..63e172f
--- /dev/null
+++ b/apps/deepviewer-adapter/migrations/desktop-profile.ts
@@ -0,0 +1,174 @@
+import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { load, dump } from 'js-yaml'
+
+const SCHEMA = 1
+const retired = new Set(['dsh-better-sidebar', 'dsh-plugin-reasoning', '@deepviewer/dsh-plugin-reasoning', '@deepseek-ai/dsh-plugin-reasoning', 'dsh-plugin-preview', '@deepviewer/dsh-plugin-preview', 'deepviewer-taskboard', 'dsh-codex-taskboard', 'better-sidebar', 'ui-better-sidebar'])
+const nativeRows: Record = { '@deepviewer/adapter': 'deepviewer-adapter', 'dsh-plugin-subscriptions': 'llm-subscriptions', '@deepviewer/dsh-plugin-model-capabilities': 'model-capabilities' }
+const legacyReasoning = new Set(['dsh-plugin-reasoning', '@deepviewer/dsh-plugin-reasoning', '@deepseek-ai/dsh-plugin-reasoning', 'deepviewer-model-reasoning'])
+interface Manifest { name?: string; version?: string; dependencies?: Record; devDependencies?: Record; optionalDependencies?: Record; dsh?: { profile?: { bundles?: string[] } } }
+export interface Integration { name: string; version: string; directory: string }
+function json(path: string): Manifest { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {} }
+function atomic(path: string, data: string): void {
+ if (existsSync(path) && readFileSync(path, 'utf8') === data) return
+ const temporary = `${path}.${randomUUID()}.tmp`
+ writeFileSync(temporary, data, { mode: 0o600 }); renameSync(temporary, path)
+}
+function backupFile(from: string, to: string): void { copyFileSync(from, to); chmodSync(to, 0o600) }
+const obsoleteDependency = (name: string): boolean => retired.has(name) || Object.hasOwn(nativeRows, name)
+function pruneDependencies(value: Record = {}): Record {
+ return Object.fromEntries(Object.entries(value).filter(([name]) => !obsoleteDependency(name)))
+}
+/** Keep provider/user settings; replace old activations with native row overrides. */
+export function migratePatch(value: unknown): unknown {
+ if (Array.isArray(value)) return value.filter(row => !(row && typeof row === 'object' && (retired.has(String(row.name)) || retired.has(String(row.id))) && !(legacyReasoning.has(String(row.name)) || legacyReasoning.has(String(row.id))))).map(migratePatch).filter(row => row !== undefined).flatMap(row => {
+ // Moving an inserted activation to a built-in row requires an override, not a second insert.
+ if (!row || typeof row !== 'object' || !Array.isArray((row as Record).insert)) return [row]
+ const record = row as Record, inserted = record.insert as Record[]
+ const overrides = inserted.filter(child => child && !child.name && Object.values(nativeRows).includes(String(child.id)))
+ if (!overrides.length) return [row]
+ const remaining = inserted.filter(child => !overrides.includes(child))
+ const container = { ...record, insert: remaining }
+ return [...(remaining.length ? [container] : []), ...overrides]
+ })
+ if (value && typeof value === 'object') {
+ const row = value as Record
+ const replacesReasoning = legacyReasoning.has(String(row.name)) || legacyReasoning.has(String(row.id))
+ const id = replacesReasoning ? 'model-capabilities' : nativeRows[String(row.name)]
+ if (id) {
+ // A formerly installed activation is now an override of the application-owned row.
+ // The old plugin had no Config schema. Carry only options understood by its replacement;
+ // provider/model settings remain in llm-pi-ai and old metadata remains in the backup.
+ const config = replacesReasoning && row.config && typeof row.config === 'object' && !Array.isArray(row.config)
+ ? Object.fromEntries(Object.entries(row.config).filter(([key]) => ['settingsNamespace', 'storeFile', 'limits'].includes(key)))
+ : row.config
+ if (config === undefined && row.disabled === undefined) return undefined
+ return { id, ...(config === undefined ? {} : { config }), ...(row.disabled === undefined ? {} : { disabled: row.disabled }) }
+ }
+ return Object.fromEntries(Object.entries(row).map(([key, child]) => [key, migratePatch(child)]))
+ }
+ return value
+}
+/** Reconcile backed-up metadata before Host boot; completion follows successful writes. No session stores are copied. */
+export function prepareDesktopProfile(home: string, runtime: string, integrations: Integration[], disabledNames: string[] = []): void {
+ const target = join(home, 'profiles', 'desktop'), source = join(home, 'profiles', 'web')
+ const marker = join(home, '.deepviewer-desktop-migration.json')
+ const journal = existsSync(marker) ? JSON.parse(readFileSync(marker, 'utf8')) : {}
+ if (journal.schemaVersion !== undefined && journal.schemaVersion !== SCHEMA) throw new Error('Unsupported DeepViewer profile migration schema')
+ const completed = journal.schemaVersion === SCHEMA && journal.completedAt !== undefined
+ const ownedLinks: Record = journal.ownedLinks ?? {}
+ mkdirSync(target, { recursive: true, mode: 0o700 })
+ const disabled = new Set(disabledNames)
+ if (journal.nativeModulesVersion !== undefined && journal.nativeModulesVersion !== 1) throw new Error('Unsupported DeepViewer native module migration schema')
+ // Validate the signed installation before changing profile declarations. No user package is replaced.
+ for (const plugin of integrations) {
+ if (!existsSync(join(plugin.directory, 'package.json'))) throw new Error(`Missing integration: ${plugin.name}`)
+ }
+ const files = ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', 'pnpm-lock.yaml']
+ const nativeBackup = join(home, 'migration-backups', 'builtins-v1')
+ if (journal.nativeModulesVersion !== 1 && !existsSync(join(nativeBackup, 'index.json'))) {
+ mkdirSync(join(nativeBackup, 'desktop'), { recursive: true, mode: 0o700 })
+ for (const file of files) if (existsSync(join(target, file))) backupFile(join(target, file), join(nativeBackup, 'desktop', file))
+ atomic(join(nativeBackup, 'previous-migration.json'), JSON.stringify(existsSync(marker) ? journal : null))
+ atomic(join(nativeBackup, 'index.json'), JSON.stringify({ schemaVersion: SCHEMA, desktopFiles: files.filter(file => existsSync(join(target, file))) }))
+ }
+ if (!completed) {
+ const backup = join(home, 'migration-backups', 'desktop-v1')
+ const backupIndex = join(backup, 'index.json')
+ if (!existsSync(backupIndex)) {
+ for (const [label, directory] of [['web', source], ['desktop', target]] as const) {
+ mkdirSync(join(backup, label), { recursive: true, mode: 0o700 })
+ for (const file of ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', 'pnpm-lock.yaml']) {
+ const from = join(directory, file), to = join(backup, label, file)
+ if (existsSync(from) && !existsSync(to)) backupFile(from, to)
+ }
+ }
+ atomic(backupIndex, JSON.stringify({ schemaVersion: SCHEMA, desktopFiles: ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', 'pnpm-lock.yaml'].filter(file => existsSync(join(backup, 'desktop', file))) }))
+ }
+ }
+ const current = existsSync(join(target, 'package.json')) ? json(join(target, 'package.json')) : json(join(source, 'package.json'))
+ if (completed && journal.nativeModulesVersion !== 1) {
+ // Retry reads the original selection, not partially converted metadata.
+ const previous = json(join(nativeBackup, 'desktop', 'package.json'))
+ for (const plugin of integrations) {
+ const selected = previous.dsh?.profile?.bundles ?? []
+ const replacesReasoning = plugin.name === '@deepviewer/dsh-plugin-model-capabilities' && selected.some(name => legacyReasoning.has(name))
+ if (!selected.includes(plugin.name) && !replacesReasoning) disabled.add(plugin.name)
+ }
+ }
+ const template = json(join(runtime, 'package.json'))
+ const bundles = [...new Set([...(completed ? [] : template.dsh?.profile?.bundles ?? []), ...(current.dsh?.profile?.bundles ?? [])].filter(name => !retired.has(name) && !Object.hasOwn(nativeRows, name)))]
+ const manifest = { ...current, name: 'deepviewer-desktop-profile', private: true, dependencies: pruneDependencies(current.dependencies),
+ ...(current.devDependencies ? { devDependencies: pruneDependencies(current.devDependencies) } : {}),
+ ...(current.optionalDependencies ? { optionalDependencies: pruneDependencies(current.optionalDependencies) } : {}),
+ dsh: { ...current.dsh, profile: { ...current.dsh?.profile, bundles } } }
+ const patchSource = existsSync(join(target, 'cordis.patch.yml')) ? target : source
+ const patch = existsSync(join(patchSource, 'cordis.patch.yml')) ? migratePatch(load(readFileSync(join(patchSource, 'cordis.patch.yml'), 'utf8'))) as Record[] : []
+ if (!Array.isArray(patch) || patch.some(row => !row || typeof row !== 'object' || Array.isArray(row))) throw new Error('DeepViewer profile patch must be a list of rows')
+ for (const name of disabled) {
+ const id = nativeRows[name]; if (!id) continue
+ const row = patch.findLast(row => row.id === id)
+ if (row) row.disabled = true
+ else patch.push({ id, disabled: true })
+ if (name === '@deepviewer/adapter' && !patch.some(row => row.id === 'preset-deepviewer-chat' && row.disabled === true)) patch.push({ id: 'preset-deepviewer-chat', disabled: true })
+ }
+ if (patch.length || existsSync(join(patchSource, 'cordis.patch.yml'))) atomic(join(target, 'cordis.patch.yml'), dump(patch))
+ atomic(join(target, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
+ const lockPath = join(target, 'pnpm-lock.yaml')
+ if (existsSync(lockPath)) {
+ const lock = load(readFileSync(lockPath, 'utf8')) as { importers?: Record>> }
+ const importer = lock?.importers?.['.']
+ if (importer) {
+ let changed = false
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
+ for (const name of Object.keys(importer[field] ?? {})) if (obsoleteDependency(name)) { delete importer[field]![name]; changed = true }
+ }
+ if (changed) atomic(lockPath, dump(lock))
+ }
+ }
+ for (const [name, owned] of Object.entries(ownedLinks)) {
+ if (!Object.hasOwn(nativeRows, name)) continue
+ const link = join(target, 'node_modules', name)
+ try { if (lstatSync(link).isSymbolicLink() && resolve(dirname(link), readlinkSync(link)) === owned) unlinkSync(link) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
+ }
+ // A failed conversion leaves its own completion unset, allowing a backed-up retry.
+ atomic(marker, `${JSON.stringify({ ...journal, schemaVersion: SCHEMA, nativeModulesVersion: 1, completedAt: journal.completedAt ?? new Date().toISOString(), sourceProfile: existsSync(source) ? 'web' : null, ownedLinks })}\n`)
+}
+
+/** Explicit metadata rollback. Preserve a second backup of current state; never downgrade or delete Session/credential data. */
+export function rollbackDesktopProfile(home: string): void {
+ const target = join(home, 'profiles', 'desktop')
+ const marker = join(home, '.deepviewer-desktop-migration.json'), journal = JSON.parse(readFileSync(marker, 'utf8'))
+ const native = journal.nativeModulesVersion === 1
+ const backup = join(home, 'migration-backups', native ? 'builtins-v1' : 'desktop-v1')
+ const index = JSON.parse(readFileSync(join(backup, 'index.json'), 'utf8'))
+ if (index.schemaVersion !== SCHEMA) throw new Error('Unsupported rollback schema')
+ for (const [name, owned] of Object.entries(journal.ownedLinks ?? {})) {
+ const link = join(target, 'node_modules', name)
+ try { if (!lstatSync(link).isSymbolicLink() || resolve(dirname(link), readlinkSync(link)) !== owned) throw new Error(`Plugin changed since migration: ${name}`) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
+ }
+ const currentBackup = join(home, 'migration-backups', `rollback-${randomUUID()}`); mkdirSync(currentBackup, { recursive: true, mode: 0o700 })
+ for (const file of ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', 'pnpm-lock.yaml']) {
+ const path = join(target, file)
+ if (existsSync(path)) backupFile(path, join(currentBackup, file))
+ if (index.desktopFiles.includes(file)) atomic(path, readFileSync(join(backup, 'desktop', file), 'utf8'))
+ else if (existsSync(path)) unlinkSync(path)
+ }
+ atomic(join(currentBackup, 'migration.json'), JSON.stringify(journal))
+ const previous = native ? JSON.parse(readFileSync(join(backup, 'previous-migration.json'), 'utf8')) : null
+ if (previous) {
+ for (const [name, owned] of Object.entries(previous.ownedLinks ?? {}) as [string, string][]) {
+ const link = join(target, 'node_modules', name)
+ // All existing links were checked above before any metadata was restored.
+ try { lstatSync(link) } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ mkdirSync(dirname(link), { recursive: true }); symlinkSync(owned, link, 'dir')
+ }
+ }
+ atomic(marker, `${JSON.stringify(previous)}\n`)
+ } else {
+ for (const name of Object.keys(journal.ownedLinks ?? {})) { try { unlinkSync(join(target, 'node_modules', name)) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } }
+ unlinkSync(marker)
+ }
+}
diff --git a/apps/deepviewer-adapter/migrations/kernel-snapshot.ts b/apps/deepviewer-adapter/migrations/kernel-snapshot.ts
new file mode 100644
index 0000000..ab8bf2e
--- /dev/null
+++ b/apps/deepviewer-adapter/migrations/kernel-snapshot.ts
@@ -0,0 +1,54 @@
+/** Version-bound backup taken before the new kernel opens any durable stores. */
+import { createHash, randomUUID } from 'node:crypto'
+import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { dirname, join, relative, resolve } from 'node:path'
+interface Snapshot { schemaVersion: number; kernel: string; files: { path: string; sha256: string }[] }
+const DIRECTORY = 'kernel-v020', SCHEMA = 1
+const SOURCE_KERNEL = '0.1.5-rc.2'
+function hash(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex') }
+function safePath(root: string, path: string): string {
+ const target = resolve(root, path), child = relative(resolve(root), target)
+ if (!child || child === '..' || child.startsWith('../') || child.startsWith('/')) throw new Error('Invalid snapshot path')
+ return target
+}
+export function prepareKernelSnapshot(home: string): void {
+ const backup = join(home, 'migration-backups', DIRECTORY), index = join(backup, 'index.json')
+ if (existsSync(index)) { verifyKernelSnapshot(home); return }
+ mkdirSync(home, { recursive: true, mode: 0o700 })
+ const parent = join(home, 'migration-backups'); mkdirSync(parent, { recursive: true, mode: 0o700 })
+ const stage = join(parent, `.kernel-${randomUUID()}`); mkdirSync(join(stage, 'data'), { recursive: true, mode: 0o700 })
+ const snapshot: Snapshot = { schemaVersion: SCHEMA, kernel: SOURCE_KERNEL, files: [] }
+ function walk(path = ''): void {
+ for (const entry of readdirSync(join(home, path), { withFileTypes: true })) {
+ const child = path ? `${path}/${entry.name}` : entry.name
+ if (entry.name === 'node_modules' || entry.name.endsWith('.lock') || (path === '' && ['migration-backups', 'cache', 'logs'].includes(entry.name)) || child === 'profiles/desktop/lock' || child === 'profiles/web/lock') continue
+ if (entry.isSymbolicLink()) throw new Error(`Cannot back up redirected data: ${child}`)
+ if (entry.isDirectory()) { walk(child); continue }
+ if (!entry.isFile()) throw new Error(`Unsupported data entry: ${child}`)
+ const from = join(home, child), to = join(stage, 'data', child)
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 }); copyFileSync(from, to); chmodSync(to, 0o600)
+ const sha256 = hash(from); if (hash(to) !== sha256) throw new Error('Data changed during kernel backup')
+ snapshot.files.push({ path: child, sha256 })
+ }
+ }
+ walk()
+ writeFileSync(join(stage, 'index.json'), JSON.stringify(snapshot, null, 2) + '\n', { mode: 0o600 })
+ // The journal appears only after every copied file has been verified. Incomplete stages remain private.
+ renameSync(stage, backup)
+}
+export function verifyKernelSnapshot(home: string): Snapshot {
+ const backup = join(home, 'migration-backups', DIRECTORY), snapshot = JSON.parse(readFileSync(join(backup, 'index.json'), 'utf8')) as Snapshot
+ if (snapshot.schemaVersion !== SCHEMA || snapshot.kernel !== SOURCE_KERNEL || !Array.isArray(snapshot.files)) throw new Error('Unsupported kernel backup')
+ for (const file of snapshot.files) {
+ const path = safePath(join(backup, 'data'), file.path)
+ if (!lstatSync(path).isFile() || hash(path) !== file.sha256) throw new Error('Kernel backup integrity mismatch')
+ }
+ return snapshot
+}
+/** Explicit rollback into a fresh home. Current/new Sessions are retained; never open them in the old kernel. */
+export function restoreKernelSnapshot(home: string, destination: string): void {
+ const snapshot = verifyKernelSnapshot(home)
+ if (existsSync(destination)) throw new Error('Rollback requires a new, empty destination')
+ mkdirSync(destination, { recursive: true, mode: 0o700 })
+ for (const file of snapshot.files) { const to = safePath(destination, file.path); mkdirSync(dirname(to), { recursive: true, mode: 0o700 }); copyFileSync(join(home, 'migration-backups', DIRECTORY, 'data', file.path), to); chmodSync(to, 0o600) }
+}
diff --git a/apps/deepviewer-adapter/migrations/web-state.ts b/apps/deepviewer-adapter/migrations/web-state.ts
new file mode 100644
index 0000000..f064b4d
--- /dev/null
+++ b/apps/deepviewer-adapter/migrations/web-state.ts
@@ -0,0 +1,67 @@
+import { createServer } from 'node:http'
+import { randomUUID } from 'node:crypto'
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { BrowserWindow, ipcMain } from 'electron'
+const BACKUP = '.deepviewer-web-state-v1.json'
+const MARKER = '.deepviewer-web-state-imported-v1.json'
+const MAX_BYTES = 8 * 1024 * 1024
+const exact = new Set(['dsh.workspace.view.v5', 'dsh.sessions.current', 'dsh.open-in-app.choice', 'dsh.conversation.contentWidth'])
+export function allowedStateKey(key: string): boolean {
+ return exact.has(key) || /^dsh\.conversation\.[a-zA-Z0-9_-]{1,256}$/u.test(key) && key !== 'dsh.conversation.contentWidth'
+}
+export function filterWebState(entries: unknown): Record {
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries)) throw new Error('Invalid frontend state')
+ const result: Record = {}; let bytes = 0
+ for (const [key, value] of Object.entries(entries)) {
+ if (!allowedStateKey(key)) continue
+ if (typeof value !== 'string' || value.length > 2 * 1024 * 1024) throw new Error('Invalid frontend state value')
+ const decoded: unknown = JSON.parse(value)
+ if (key.startsWith('dsh.conversation.') && key !== 'dsh.conversation.contentWidth' && (!decoded || typeof decoded !== 'object' || !('draft' in decoded) || typeof decoded.draft !== 'string')) throw new Error('Invalid conversation draft')
+ bytes += Buffer.byteLength(value); if (bytes > MAX_BYTES) throw new Error('Frontend migration exceeds 8 MiB')
+ result[key] = value
+ }
+ return result
+}
+/** Only metadata produced by DeepViewer's previous runtime logger is used; no tokens or URLs with queries. */
+export function legacyOrigins(log: string): string[] {
+ return [...new Set([...log.matchAll(/runtime ready origin=(http:\/\/127\.0\.0\.1:[0-9]{1,5})(?:\s|$)/gu)].map(match => match[1]!).reverse())].filter(origin => { const port = Number(new URL(origin).port); return port >= 1024 && port <= 65535 }).slice(0, 8)
+}
+function atomic(path: string, value: unknown): void { const temporary = `${path}.${randomUUID()}.tmp`; writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 }); renameSync(temporary, path) }
+async function readOrigin(origin: string): Promise> {
+ const url = new URL(origin), nonce = randomUUID(), path = `/deepviewer-migration-${nonce}`
+ const server = createServer((request, response) => { if (request.url !== path) { response.writeHead(404); response.end(); return }; response.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", 'Cache-Control': 'no-store' }); response.end('DeepViewer migration ') })
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(url.port), '127.0.0.1', resolve) })
+ const window = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true } })
+ window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+ window.webContents.on('will-navigate', (event, destination) => { if (destination !== origin + path) event.preventDefault() })
+ try {
+ await window.loadURL(origin + path)
+ const entries = await window.webContents.executeJavaScript(`Object.fromEntries(Object.keys(localStorage).filter(key => ${JSON.stringify([...exact])}.includes(key) || /^dsh\\.conversation\\.[a-zA-Z0-9_-]{1,256}$/.test(key)).map(key => [key, localStorage.getItem(key)]))`)
+ return filterWebState(entries)
+ } finally { window.destroy(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) }
+}
+/** Runs after Electron ready, before official main creates any renderer. Source storage stays untouched. */
+export async function prepareWebState(userData: string): Promise {
+ const backup = join(userData, BACKUP), marker = join(userData, MARKER)
+ let values: Record = {}
+ if (existsSync(marker)) {
+ // Completed migrations still install IPC listeners for the immutable preload contract.
+ } else if (existsSync(backup)) {
+ const saved = JSON.parse(readFileSync(backup, 'utf8'))
+ if (saved.schemaVersion !== 1) throw new Error('Unsupported frontend migration schema')
+ values = filterWebState(saved.values)
+ } else {
+ const logPath = join(userData, 'logs/deepviewer.log')
+ const origins = existsSync(logPath) ? legacyOrigins(readFileSync(logPath, 'utf8').slice(-4 * 1024 * 1024)) : []
+ if (origins.length === 0 && existsSync(join(userData, 'Local Storage/leveldb'))) throw new Error('DeepViewer 旧版前端状态缺少来源记录,迁移未完成;原数据已保留。')
+ // Old runtime changes port between launches. Newest value for each key wins.
+ for (const origin of origins) for (const [key, value] of Object.entries(await readOrigin(origin))) if (!(key in values)) values[key] = value
+ mkdirSync(userData, { recursive: true, mode: 0o700 })
+ atomic(backup, { schemaVersion: 1, backedUpAt: new Date().toISOString(), origins, values: filterWebState(values) })
+ }
+ const ownsFrame = (event: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent) => event.senderFrame === event.sender.mainFrame && event.senderFrame?.url.startsWith('dsh-app://app/')
+ ipcMain.on('deepviewer-state-bootstrap', event => { event.returnValue = ownsFrame(event) ? values : {} })
+ ipcMain.handle('deepviewer-state-imported', event => { if (!ownsFrame(event)) throw new Error('Unowned migration frame'); atomic(marker, { schemaVersion: 1, importedAt: new Date().toISOString(), keys: Object.keys(values) }); return true })
+ // Keep state listeners for renderer reloads; the preload does not overwrite any new-origin value.
+}
diff --git a/apps/deepviewer-adapter/native-modules.patch.yml b/apps/deepviewer-adapter/native-modules.patch.yml
new file mode 100644
index 0000000..4990f4f
--- /dev/null
+++ b/apps/deepviewer-adapter/native-modules.patch.yml
@@ -0,0 +1,9 @@
+# These rows belong to the shipped Web application, not to installed bundles.
+# Settings pages still use the same native slots and providers.
+- insert:
+ - id: llm-subscriptions
+ name: dsh-plugin-subscriptions
+ disabled: !!js "process.env.DEEPVIEWER_DISABLE_BUILTINS === '1' || ['DEEPVIEWER_DISABLE_SUBSCRIPTIONS_PLUGIN', 'DEEPVIEWER_DISABLE_SUBSCRIPTIONS', 'DEEPVIEWER_DISABLE_SUBSCRIPTIONS'].some(key => process.env[key] === '1')"
+ - id: model-capabilities
+ name: '@deepviewer/dsh-plugin-model-capabilities'
+ disabled: !!js "process.env.DEEPVIEWER_DISABLE_BUILTINS === '1' || ['DEEPVIEWER_DISABLE_MODEL_CAPABILITIES_PLUGIN', 'DEEPVIEWER_DISABLE_MODEL_CAPABILITIES', 'DEEPVIEWER_DISABLE_REASONING', 'DEEPVIEWER_DISABLE_REASONING'].some(key => process.env[key] === '1')"
diff --git a/apps/deepviewer-adapter/package.json b/apps/deepviewer-adapter/package.json
new file mode 100644
index 0000000..771bb98
--- /dev/null
+++ b/apps/deepviewer-adapter/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@deepviewer/adapter",
+ "version": "0.5.0",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "main": "lib/index.js",
+ "exports": {
+ ".": "./lib/index.js",
+ "./client": "./lib/client.js",
+ "./package.json": "./package.json",
+ "./chat-guard": "./lib/workflows/chat-guard.js"
+ },
+ "files": [
+ "lib",
+ "cordis.patch.yml",
+ "assets",
+ "UPSTREAM-LICENSE",
+ "BETTER-SIDEBAR-LICENSE",
+ "THIRD-PARTY-LICENSES.txt"
+ ],
+ "dsh": {
+ "bundle": {
+ "patch": "./cordis.patch.yml"
+ },
+ "client": {
+ "platform": "web",
+ "inject": [
+ "@deepseek-ai/dsh-client-ui-slots",
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-ui-session",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-sidebar",
+ "@deepseek-ai/dsh-client-ui-settings-general",
+ "@deepseek-ai/dsh-client-ui-theme",
+ "@deepseek-ai/dsh-client-ui-chat",
+ "@deepseek-ai/dsh-client-ui-workspace",
+ "@deepseek-ai/dsh-api-remotes",
+ "@deepseek-ai/dsh-api-session-controller",
+ "@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
+ "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
+ "@deepseek-ai/dsh-client-connection"
+ ]
+ }
+ },
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "4.0.4"
+ }
+}
diff --git a/apps/deepviewer-adapter/presentation/Rows.module.css b/apps/deepviewer-adapter/presentation/Rows.module.css
new file mode 100644
index 0000000..bb93c1a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Rows.module.css
@@ -0,0 +1,472 @@
+.projectRow,
+.sessionRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ border-radius: var(--dsw-radius-md);
+ padding: 0 8px;
+ padding-inline-start: calc(8px + var(--dsh-workspace-indent, 0px));
+ cursor: pointer;
+ user-select: none;
+ color: var(--dsw-alias-label-primary);
+}
+
+.projectRow:hover,
+.sessionRow:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.sessionRow.selected {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.searchResultRow {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ min-height: 48px;
+ box-sizing: border-box;
+ border: none;
+ border-radius: var(--dsw-radius-lg);
+ padding: 4px 8px;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ color: var(--dsw-alias-label-primary);
+}
+
+.searchResultRow:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.searchResultRow.selected {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.searchResultHeading {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.searchResultTitle {
+ flex: 0 1 auto;
+ min-width: 0;
+ margin-left: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.searchResultMeta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ margin-left: 20px;
+}
+
+.searchResultWorkspace,
+.searchResultSnippet {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ line-height: 17px;
+}
+
+.chatSearchResult .searchResultTitle { margin-left: 0; flex: 1; }
+.chatSearchResult .searchResultMeta { margin-left: 0; }
+.chatSearchResult .slot { order: 1; }
+
+.searchResultWorkspace {
+ flex: none;
+ max-width: 40%;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+/* The 40% share only protects a snippet; a snippetless match keeps the row. */
+.searchResultWorkspace:only-child {
+ max-width: 100%;
+}
+
+.searchResultSnippet {
+ flex: 1;
+ min-width: 0;
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* Compact one-line Workspace row after removing the session-count subtitle. */
+.projectRow {
+ height: 32px;
+ align-items: center;
+ box-sizing: border-box;
+}
+
+.projectRow .rowActions {
+ height: 20px;
+}
+
+/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
+.sessionRow {
+ height: 32px;
+ gap: 0;
+}
+
+.sessionRow .title {
+ margin: 0 6px 0 var(--dsh-session-title-start, 4px);
+}
+
+.slot {
+ order: var(--dsh-session-status-order, 0);
+ flex: none;
+ width: 16px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.visuallyHidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+.folderActive {
+ color: var(--dsw-alias-state-business-primary);
+}
+
+
+/* Project leading slot: folder by default, expand arrow on row hover. */
+.projectRow .chevron { display: none; }
+.projectRow:hover .chevron { display: inline-flex; }
+.projectRow:hover .folder { display: none; }
+
+/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
+.arrow {
+ transition: transform 150ms var(--ds-ease-in-out);
+}
+
+.arrowOpen {
+ transform: rotate(90deg);
+}
+
+.projectText {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.renameInput {
+ min-width: 0;
+ font-size: 14px;
+ line-height: 20px;
+ padding: 0 2px;
+ border: 0.5px solid var(--dsw-alias-border-l4);
+ border-radius: var(--dsw-radius-sm);
+ background: var(--dsw-alias-button-elevated-fill);
+ color: inherit;
+ outline: none;
+}
+
+/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls
+ it at a constant speed to its far edge while the pointer rests on the row,
+ holds it there, and returns it to the start in one step when the pointer
+ leaves. The motion is scripted frame by frame (reduced motion jumps), so no
+ scroll-behavior applies here. */
+.sessionRow .title {
+ flex: 1;
+}
+
+/* While the marquee travels, both clipping edges fade in over 12px instead of
+ hard-cutting a character: the left once the title has moved off its start
+ (data-scrolled), the right while text still remains beyond the cell
+ (data-clipped) — at the far edge the right fade lifts so the final character
+ reads at full strength. The mask rides the title span itself, so the status
+ slot and every other leading cell keep their full color. */
+.sessionRow .title[data-scrolled] {
+ mask-image: linear-gradient(to right, transparent, #000 12px);
+}
+
+.sessionRow .title[data-clipped] {
+ mask-image: linear-gradient(to left, transparent, #000 12px);
+}
+
+.sessionRow .title[data-scrolled][data-clipped] {
+ mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
+}
+
+.meta {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ line-height: 20px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.time {
+ flex: none;
+ font-size: 10px;
+ line-height: 16px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.pinIndicator {
+ display: inline-flex;
+ flex: none;
+ width: 16px;
+ height: 20px;
+ align-items: center;
+ justify-content: center;
+ margin-left: 6px;
+ color: var(--dsw-alias-label-caption);
+}
+
+/* Archived rows stay in place but read as inactive: titles drop to
+ the caption label step (one step above dimmed, which reads as too faint
+ over the sidebar fill). Archived search rows drop their meta line to the
+ same step so both lines read as one unit. */
+.sessionRow.archived .title,
+.searchResultRow.archived .searchResultTitle,
+.searchResultRow.archived .searchResultWorkspace,
+.searchResultRow.archived .searchResultSnippet {
+ color: var(--dsw-alias-label-caption);
+}
+
+.dot {
+ flex: none;
+}
+
+/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656):
+ bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside
+ the 16px glyphs, so the buttons sit at 10. */
+.rowActions {
+ flex: none;
+ display: none;
+ align-items: center;
+ gap: 10px;
+}
+
+.projectRow:hover .rowActions,
+.sessionRow:hover .rowActions,
+.searchResultRow:hover .rowActions,
+.projectRow.menuOpen .rowActions,
+.sessionRow.menuOpen .rowActions {
+ display: inline-flex;
+}
+
+/* The search heading has no trailing time cell, so the unarchive action
+ right-aligns itself. */
+.searchResultHeading .rowActions {
+ margin-left: auto;
+}
+
+.sessionRow:hover .time,
+.sessionRow.menuOpen .time {
+ display: none;
+}
+
+/* Hover swaps the trailing cell to the action buttons, which already carry
+ the pin/archive glyphs — hide the static markers so they do not double. */
+.sessionRow:hover .pinIndicator,
+.sessionRow.menuOpen .pinIndicator {
+ display: none;
+}
+
+/* The unclipped hover state drops the ellipsis, which would otherwise cover
+ the characters the marquee reached. Guarded because a touch tap
+ latches `:hover` on the row after the title has already returned: an
+ unguarded rule would strand that row without its ellipsis. */
+@media (hover: hover) {
+ .sessionRow:hover .title,
+ .sessionRow.menuOpen .title {
+ text-overflow: clip;
+ }
+}
+
+/* An open row menu pins the hover affordances (figma: the row keeps its
+ hover fill while its dropdown is up). */
+.projectRow.menuOpen,
+.sessionRow.menuOpen {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Session drag insert marker: a leading chevron and 2px rule between rows,
+ absolutely positioned so it neither resembles a row border nor changes layout. */
+.sessionRow.dropBefore,
+.sessionRow.dropAfter {
+ position: relative;
+}
+
+.sessionRow.dropBefore::before,
+.sessionRow.dropAfter::after {
+ content: '';
+ position: absolute;
+ z-index: 1;
+ left: 0;
+ right: 4px;
+ height: 12px;
+ background:
+ linear-gradient(
+ 55deg,
+ transparent calc(50% - 1px),
+ var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
+ transparent calc(50% + 1px)
+ ) 0 0 / 5px 7px no-repeat,
+ linear-gradient(
+ 125deg,
+ transparent calc(50% - 1px),
+ var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
+ transparent calc(50% + 1px)
+ ) 0 5px / 5px 7px no-repeat,
+ linear-gradient(
+ var(--dsw-alias-state-business-primary) 0 0
+ ) 4px 5px / calc(100% - 4px) 2px no-repeat;
+ pointer-events: none;
+}
+
+.sessionRow.dropBefore::before {
+ top: -7px;
+}
+
+.sessionRow.dropAfter::after {
+ bottom: -7px;
+}
+
+/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
+.hoverContent {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.hoverTitle {
+ font-size: 14px;
+ line-height: 20px;
+ color: #FFFFFF;
+ overflow-wrap: break-word;
+}
+
+.hoverPath {
+ font-size: 12px;
+ line-height: 16px;
+ color: #CFD3D6;
+ word-break: break-all;
+}
+
+.hoverTime {
+ font-size: 12px;
+ line-height: 16px;
+ color: #CFD3D6;
+}
+
+.hoverStatus {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ line-height: 20px;
+ color: #ADB2B8;
+}
+
+/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */
+.hoverArchived svg {
+ flex-shrink: 0;
+ margin: 0 -4px;
+}
+
+.iconButton {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border: none;
+ border-radius: var(--dsw-radius-xs);
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.iconButton:hover {
+ color: var(--dsw-alias-label-primary);
+}
+
+/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
+ darker (tertiary, #81858C) per the cell spec. */
+.chevron {
+ color: var(--dsw-alias-label-caption);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .arrow {
+ animation: none;
+ transition: none;
+ }
+}
+
+.projectRow { color: var(--dsw-alias-label-secondary); }
+
+/* DeepViewer override:start session-drive-loader */
+.driveLoader {
+ display: inline-grid;
+ grid-template-columns: repeat(3, 3.2px);
+ gap: 1.2px;
+ flex: none;
+ width: 12px;
+ height: 12px;
+ color: var(--dsw-alias-label-primary);
+}
+
+.driveCell {
+ width: 3.2px;
+ height: 3.2px;
+ border-radius: 0.8px;
+ background: currentColor;
+ opacity: 0.15;
+ animation: session-drive-pixel 650ms ease-in-out infinite;
+}
+
+@keyframes session-drive-pixel {
+ 0%, 65%, 100% { opacity: 0.15; }
+ 22% { opacity: 1; }
+ 42% { opacity: 0.45; }
+}
+
+.driveTitle {
+ background-image: linear-gradient(90deg, var(--dsw-alias-label-tertiary) 35%, var(--dsw-alias-label-primary) 50%, var(--dsw-alias-label-tertiary) 65%);
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+ -webkit-text-fill-color: transparent;
+ animation: session-drive-shimmer 1.4s linear infinite;
+}
+
+@keyframes session-drive-shimmer {
+ from { background-position: 100% 0; }
+ to { background-position: -100% 0; }
+}
+/* DeepViewer override:end session-drive-loader */
diff --git a/apps/deepviewer-adapter/presentation/SettingsPanel.native.tsx.txt b/apps/deepviewer-adapter/presentation/SettingsPanel.native.tsx.txt
new file mode 100644
index 0000000..44abf78
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/SettingsPanel.native.tsx.txt
@@ -0,0 +1,52 @@
+function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
+ // Entries can unmount underneath the requested id, so the render-time
+ // projection falls back to the first row when the id is gone.
+ const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
+ const titleId = useId()
+
+ const panel = useRef(null)
+ useModalLayer(panel, true, onClose)
+
+ // Portalled beside #root like the Modal primitive: a covering surface mounted
+ // inside the root would precede the columns' chrome in document order, so a
+ // chrome row that declares window drag after it would override its subtraction.
+ // Beside the root, base.css's `body > :not(#root)` rule subtracts it instead.
+ return createPortal((
+
+
+
+
+ {renderSlot('settings.header', {})}
+
+ {rows.map(row => (
+ { onSelect(row.id) }}
+ >
+ {navIcon(row.id)}
+ {row.label}
+
+ ))}
+
+
+
+
+
{renderSlot('settings.action', {})}
+
+
+ {renderSlot('settings.close', {})}
+
+
+
+ {active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
+
+
+
+
+ ), document.body)
+}
diff --git a/apps/deepviewer-adapter/presentation/SettingsPanel.tsx.txt b/apps/deepviewer-adapter/presentation/SettingsPanel.tsx.txt
new file mode 100644
index 0000000..0be56db
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/SettingsPanel.tsx.txt
@@ -0,0 +1,114 @@
+/** Render one settings navigation entry. */
+function NavCell({
+ row,
+ active,
+ onSelect,
+}: {
+ row: SettingsSectionRow
+ active: string | undefined
+ onSelect: (id: string) => void
+}) {
+ return (
+ { onSelect(row.id) }}
+ >
+ {navIcon(row.id)}
+ {row.label}
+
+ )
+}
+
+/**
+ * Full-window settings surface. The invisible mask is retained only as the
+ * legacy click-close contract for upstream component tests; it paints nothing.
+ */
+function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
+ const subscriptionsRow = rows.find(row => row.id === 'subscriptions')
+ const integratesSubscriptions = subscriptionsRow !== undefined && rows.some(row => row.id === 'models')
+ const visibleRows = integratesSubscriptions ? rows.filter(row => row.id !== 'subscriptions') : rows
+ const activeRow = visibleRows.find(row => row.id === (integratesSubscriptions && activeId === 'subscriptions' ? 'models' : activeId)) ?? visibleRows[0]
+ const active = activeRow?.id
+ const mainRows = visibleRows.filter(row => row.id !== 'about')
+ const footerRows = visibleRows.filter(row => row.id === 'about')
+ const titleId = useId()
+ const [sidebarWidth, setSidebarWidth] = useState(280)
+ useLayoutEffect(() => {
+ const column = document.querySelector('[data-deepviewer-sidebar]')
+ if (!column) return
+ const measure = () => {
+ const width = column.getBoundingClientRect().width
+ // A collapsed shell still needs readable settings navigation.
+ setSidebarWidth(width > 100 ? width : 280)
+ }
+ measure()
+ const observer = new ResizeObserver(measure)
+ observer.observe(column)
+ return () => { observer.disconnect() }
+ }, [])
+
+
+ const panel = useRef(null)
+ useModalLayer(panel, true, onClose)
+
+ return createPortal((
+
+
+
+
+
+
+ {renderSlot('settings.close', {})}
+
+ {renderSlot('settings.header', {})}
+
+
+ {mainRows.map(row => (
+
+ ))}
+
+ {footerRows.length > 0 && (
+
+ {footerRows.map(row => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
{activeRow?.label ?? ''}
+
{renderSlot('settings.action', {})}
+
+
+ {active !== undefined && (
+ <>
+ {renderSlot('settings.section', { close: onClose }, { only: active })}
+ {active === 'models' && subscriptionsRow !== undefined && (
+
+
+ {subscriptionsRow.label}
+
+ {renderSlot('settings.section', { close: onClose }, { only: 'subscriptions' })}
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ ), document.body)
+}
diff --git a/apps/deepviewer-adapter/presentation/SettingsRoot.module.css b/apps/deepviewer-adapter/presentation/SettingsRoot.module.css
new file mode 100644
index 0000000..e530964
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/SettingsRoot.module.css
@@ -0,0 +1,335 @@
+/* DeepViewer full-window settings surface. Existing Harness sections and
+ controls remain unchanged; this file only owns the application-level shell. */
+
+.trigger {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: calc(100% + 8px);
+ height: 34px;
+ margin: 4px -4px 4px;
+ padding: 6px 2px 6px 10px;
+ box-sizing: border-box;
+ border: none;
+ border-radius: 12px;
+ background: transparent;
+ cursor: pointer;
+ overflow: hidden;
+ color: var(--dsw-alias-label-primary);
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 22px;
+}
+
+.trigger:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.trigger.rail {
+ width: 36px;
+ height: 36px;
+ margin: 8px 0 10px;
+ justify-content: center;
+ gap: 0;
+ padding: 0;
+ border-radius: 50%;
+ corner-shape: round;
+}
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: var(--dsw-alias-bg-base);
+}
+
+/* Retained for the upstream click-close contract; the global page has no mask. */
+.mask {
+ display: none;
+}
+
+.panel {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ grid-template-columns: 280px minmax(0, 1fr);
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ overflow: hidden;
+ border-radius: 0;
+ background: var(--dsw-alias-bg-base);
+ box-shadow: none;
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+}
+
+.nav {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 56px 12px 6px;
+ box-sizing: border-box;
+ overflow: hidden;
+ background: var(--dsw-specific-sidebar-fill);
+ border-right: 1px solid var(--dsw-alias-border-l1);
+}
+
+.back {
+ display: inline-flex;
+ align-items: center;
+ align-self: stretch;
+ gap: 8px;
+ min-width: 0;
+ height: 40px;
+ flex: none;
+ margin: 0 0 24px;
+ padding: 8px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--dsw-alias-label-secondary);
+ font: inherit;
+ font-size: 14px;
+ line-height: 22px;
+ cursor: pointer;
+ -webkit-app-region: no-drag;
+}
+
+.back:hover {
+ color: var(--dsw-alias-label-primary);
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.back:focus-visible,
+.navCell:focus-visible {
+ outline: 2px solid var(--dsw-alias-focus-ring, rgba(65, 118, 230, 0.8));
+ outline-offset: 2px;
+}
+
+.backIcon,
+.navIcon {
+ flex: none;
+}
+
+.navTitle {
+ padding: 8px;
+ margin-bottom: 8px;
+ color: var(--dsw-alias-label-secondary);
+ font-size: 14px;
+ line-height: 22px;
+ font-weight: 400;
+ -webkit-app-region: no-drag;
+}
+
+.navList {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ -webkit-app-region: no-drag;
+}
+
+.navMain,
+.navFooter {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.navFooter {
+ margin-top: auto;
+ padding: 0;
+}
+
+.navCell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 34px;
+ padding: 6px 8px;
+ box-sizing: border-box;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--dsw-alias-label-primary);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 22px;
+ font-weight: 400;
+ text-align: left;
+}
+
+.navCell:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.navCell.active {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.navLabel {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.content {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--dsw-alias-bg-base);
+}
+
+.dragBar {
+ flex: none;
+ height: 48px;
+}
+
+.contentScroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 20px 48px 56px;
+ box-sizing: border-box;
+ -webkit-app-region: no-drag;
+}
+
+.contentInner {
+ width: 100%;
+ max-width: 920px;
+ margin: 0 auto;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ min-height: 40px;
+ margin-bottom: 36px;
+}
+
+.pageTitle {
+ min-width: 0;
+ margin: 0;
+ color: var(--dsw-alias-label-primary);
+ font-size: 28px;
+ line-height: 36px;
+ font-weight: 550;
+ letter-spacing: -0.02em;
+}
+
+.actions {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.options {
+ width: 100%;
+}
+
+/* Section roots share the header's content rail. Only lift page-level caps;
+ nested controls and dialogs retain their own widths. */
+.options > :global([data-slot='settings.section']) > :first-child {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+/* Subscription plugins keep their standard settings.section registration;
+ the DeepViewer shell composes that seat beneath the Models API section. */
+.integratedSection {
+ width: 100%;
+ max-width: 100%;
+ margin-top: 32px;
+ padding-top: 32px;
+ border-top: 1px solid var(--dsw-alias-border-l2);
+ box-sizing: border-box;
+}
+
+.integratedTitle {
+ margin: 0 0 12px;
+ color: var(--dsw-alias-label-primary);
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 500;
+}
+
+/* The third-party section ships an inline 560px cap. Inside the Models
+ composition it follows the same content rail as the API section. */
+.integratedSection > :global([data-slot='settings.section']) > :first-child {
+ width: 100%;
+ max-width: 100% !important;
+}
+
+.hiddenLabel {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+@media (max-width: 1040px) {
+ .contentScroll {
+ padding-right: 32px;
+ padding-left: 32px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .overlay,
+ .panel {
+ scroll-behavior: auto;
+ }
+}
+
+/* Current DSH connection recovery beside the stable settings trigger. */
+.triggerRow { display: flex; flex: none; align-items: center; gap: 4px; }
+.triggerRow .trigger { flex: 1; min-width: 0; }
+.triggerRow.railRow { width: 36px; }
+
+/* Match the main sidebar footer anchor and retain its quiet, divider-free edge. */
+.navFooter .navCell {
+ margin: 4px 0;
+ padding-left: 6px;
+ border-radius: 12px;
+}
+
+/* Let the native sidebar material show through exactly as on the main page. */
+:global([data-deepviewer-native-chrome]) .overlay,
+:global([data-deepviewer-native-chrome]) .panel {
+ background: transparent;
+}
+
+/* RC2 connection and update indicators share the footer row. */
+.triggerRow {
+ position: relative;
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: calc(100% + 4px);
+ margin: 4px -2px;
+}
+
+.triggerRow.railRow {
+ width: 36px;
+ margin: 8px 0 10px;
+}
+
+
+.triggerRow .trigger { flex: 1; min-width: 0; width: auto; margin: 0; }
diff --git a/apps/deepviewer-adapter/presentation/SidebarRoot.module.css b/apps/deepviewer-adapter/presentation/SidebarRoot.module.css
new file mode 100644
index 0000000..3b9cc02
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/SidebarRoot.module.css
@@ -0,0 +1,625 @@
+/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar
+ fill + 1px right border painted by the layout column. Collapse is a
+ slide + crossfade, not a morph: the content holds its frozen expanded
+ layout (inline width set by the component) and fades in place (.fading)
+ while the sliding column (AppFrame grid tracks) clips it; the rail layout
+ (.collapsed) only applies after the fade settles, so nothing reflows
+ mid-slide. */
+
+.root {
+ --dsh-sidebar-inline-padding: 12px;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 6px var(--dsh-sidebar-inline-padding);
+ box-sizing: border-box;
+ background: var(--dsw-specific-sidebar-fill);
+ color: var(--dsw-alias-label-primary);
+ font-size: 14px;
+ /* The sidebar is elevated above the conversation surface, so a revealed
+ scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+}
+
+/* macOS desktop: the AppFrame column owns the translucent sidebar tint; an
+ opaque fill here would sit on top of it and block the window vibrancy. */
+:global([data-platform='darwin']) .root {
+ background: transparent;
+}
+
+/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
+ rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
+ the whale's box (24px to the 24-wide whale glyph itself). */
+.root.collapsed {
+ padding: 18px 10px 6px;
+}
+
+/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
+:global([data-windows-titlebar]) .root .logoRow {
+ height: 40px;
+ margin: 0;
+ padding: 0;
+}
+
+:global([data-windows-titlebar]) .toggle {
+ position: fixed;
+ top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+ left: 12px;
+ z-index: 30;
+ -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .root.collapsed .logoRow {
+ height: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
+ padding-left: 4px;
+}
+
+:global([data-windows-titlebar]) .brandIdentity {
+ transform: translateY(1px);
+}
+
+:global([data-windows-titlebar]) .brandMark {
+ transform: translateX(1px);
+}
+
+:global([data-windows-titlebar]) .brandName {
+ font-weight: 400;
+}
+
+:global([data-windows-titlebar]) .root.collapsed {
+ padding: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
+ margin-top: 8px;
+ margin-left: 0;
+}
+
+:global([data-windows-titlebar]) .collapsed .panelList,
+:global([data-windows-titlebar]) .collapsed .regionArea,
+:global([data-windows-titlebar]) .collapsed .footArea {
+ display: none;
+}
+
+:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
+ --dsh-windows-menu-start: 84px;
+}
+
+:global([data-windows-titlebar]) .collapsed .newSession {
+ position: fixed;
+ top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+ left: 48px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ z-index: 30;
+ -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .railIn .iconButton,
+:global([data-windows-titlebar]) .railIn .newSession {
+ animation: none;
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle,
+:global([data-windows-titlebar]) .collapsed .newSession {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ corner-shape: round;
+ color: var(--dsw-alias-label-secondary);
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
+ display: inline;
+}
+
+/* Scrollbars in the column are a pointer affordance: the shell adds this
+ class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
+ and rebinding ui-theme's indirection pair to `transparent` takes the thumb
+ out of every scroll region nested under it — the workspace browser's
+ session list. `transparent` rather than `display: none` on the bar:
+ the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
+ revealing the thumb never reflows a row. Rebinding contract and the two
+ rendering paths it reaches: ui-theme's README. */
+.root.quietBars {
+ --dsh-scrollbar-thumb: transparent;
+ --dsh-scrollbar-thumb-hover: transparent;
+}
+
+/* Collapse phase 1: the whole frozen-width content fades out in place over
+ 150ms; at settle the children unmount/snap to the rail layout. */
+.fading > * {
+ opacity: 0;
+ transition: opacity 150ms var(--ds-ease-in-out);
+}
+
+/* Wide-only content fades back in on expand remount. */
+.wide {
+ animation: wide-in 200ms var(--ds-ease-in-out);
+}
+
+@keyframes wide-in {
+ from { opacity: 0; }
+}
+
+/* At the 150ms rail settle, the upper controls enter from the former
+ rail right edge over the remaining 150ms of the AppFrame track transition.
+ The bottom-pinned settings seat shares their opacity timeline but stays
+ horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
+ render stays static. */
+.railIn .iconButton,
+.railIn .newSession,
+.railIn .panelList,
+.railIn .regionArea {
+ animation: rail-in 150ms var(--ds-ease-in-out) backwards;
+}
+
+.railIn .footArea {
+ animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
+}
+
+@keyframes rail-in {
+ from {
+ opacity: 0;
+ transform: translateX(49px);
+ }
+}
+
+@keyframes rail-fade-in {
+ from { opacity: 0; }
+}
+
+/* macOS hiddenInset titlebar: the strip spans the column's top edge (negative
+ margins cancel the root padding) so it clears the traffic lights at
+ (16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so
+ its own run is the column's draggable geometry and no frame band has to match
+ the strip's height; the toggle inside subtracts via the base.css rule. */
+.topStrip {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ height: 52px;
+ box-sizing: border-box;
+ margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0;
+ /* The 2px foot centres the 28px toggle at y 25, level with the collapsed
+ seat (ui-layout .leadingSeat, top 11) and the conversation title row, so
+ collapse/expand never shifts it vertically. */
+ padding: 0 12px 2px;
+}
+
+/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
+ preload): the toggle takes their spot at the strip's left edge. */
+:global([data-fullscreen]) .topStrip {
+ justify-content: flex-start;
+}
+
+/* The strip's full 52px foot leaves the wordmark too low; the content column
+ tucks 12px back under it, still clear of the toggle (28px control centered
+ in the strip ends at y 40). */
+.topStrip + .logoRow {
+ margin-top: -12px;
+}
+
+/* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
+ right edge; the rail gives every control the same base left anchor before
+ the shared entry translation is applied. macOS hiddenInset titlebar: the row
+ is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
+ single darwin rule carries the drag over its own box — below the top strip
+ (40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides
+ that box (SidebarRoot renders the wordmark as a plain span there, so the
+ global interactive no-drag rule never subtracts it); the dedicated New
+ Session button below keeps the pointer and keyboard paths. Safe as a drag
+ source: it precedes the sidebar's overlay slots in document order, so their
+ no-drag still wins. */
+.logoRow {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ height: 36px;
+ padding: 8px 0 8px 4px;
+ margin-bottom: 12px;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.collapsed .logoRow {
+ height: 36px;
+ padding: 0;
+ margin-bottom: 12px;
+ justify-content: flex-start;
+ /* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut
+ the control's right edge, so the rail stops clipping once the slide has settled. */
+ overflow: visible;
+}
+
+/* Brand group (figma I133:7632): the full wordmark rides the text ink
+ (figma flows: the main-screen instance is black; blue is brand
+ emphasis only). A button only in behavior (New Session shortcut): the
+ pointer cursor is the sole affordance — no hover chrome on the mark. */
+.brand {
+ flex: 1;
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ overflow: hidden;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+
+/* On macOS the brand is a window-drag surface, not a New Session button;
+ a pointer cursor would promise a click that drags the window instead. */
+:global([data-platform='darwin']) .brand {
+ cursor: default;
+}
+
+/* Mark and name are independent slots; the shell owns their shared baseline,
+ spacing, and height so either occupant can vary in width. */
+.brandIdentity {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 24px;
+ min-width: 0;
+}
+
+.brandMark {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.brandName {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ height: 24px;
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 24px;
+ letter-spacing: 0.04em;
+}
+
+.fallbackBrandName {
+ font-size: 17px;
+ letter-spacing: 0px;
+ white-space: nowrap;
+}
+
+.localBuildBrand {
+ flex: none;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 1px;
+ height: 24px;
+ white-space: nowrap;
+}
+
+.localBuildTitle {
+ font-size: 12px;
+ line-height: 13px;
+ letter-spacing: 0;
+}
+
+.iconButton {
+ position: relative;
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.iconButton:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
+ the pixels that leave the row. Draw this control's ring inside its own box instead. */
+.iconButton.toggle:focus-visible {
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
+ outline-offset: -2px;
+}
+
+.collapsed .iconButton {
+ width: 36px;
+ height: 36px;
+ /* Rail hovers share the panel rows' and New Session's 12px rounding. */
+ border-radius: var(--dsw-radius-md);
+}
+
+/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
+ no hover circle) and hovering reveals the panel icon — the expand
+ affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
+.collapsed .toggle .panelIcon {
+ display: inline;
+}
+
+.collapsed .toggle:hover .panelIcon {
+ display: inline;
+}
+
+.collapsed .toggle:hover .railMark {
+ display: none;
+}
+
+.railMark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
+ secondary icon-button ink. */
+.collapsed .iconButton {
+ color: var(--dsw-alias-label-primary);
+}
+
+.buildVersion {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ height: 10px;
+ padding: 0 3px;
+ border-radius: 2px;
+ color: var(--dsw-alias-label-primary-inverted);
+ background: var(--dsw-alias-label-primary);
+ font-family: var(--ds-font-family-code);
+ font-size: 6px;
+ font-weight: 500;
+ line-height: 10px;
+ white-space: nowrap;
+}
+
+/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
+ corners); collapsed it renders as the rail's plain icon control. */
+.newSession {
+ container-type: inline-size;
+ position: relative;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ height: 40px;
+ padding: 8px 10px;
+ margin: 0 0 8px;
+ box-sizing: border-box;
+ border: none;
+ border-radius: 12px;
+ background: var(--dsw-alias-button-elevated-fill);
+ color: var(--dsw-alias-label-primary);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 22px;
+ cursor: pointer;
+ overflow: hidden;
+}
+
+.newSession:hover {
+ background: var(--dsw-alias-button-floating-hover);
+}
+
+/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy
+ sidebar; a white wash sits in the glass like native macOS sidebar
+ controls. Dark keeps the same white ink at lower alpha. */
+:global([data-platform='darwin']) .newSession {
+ background: rgb(255 255 255 / 0.55);
+}
+
+:global([data-platform='darwin']) .newSession:hover {
+ background: rgb(255 255 255 / 0.3);
+}
+
+:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession {
+ background: rgb(255 255 255 / 0.15);
+}
+
+:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession:hover {
+ background: rgb(255 255 255 / 0.2);
+}
+
+.collapsed .newSession {
+ align-self: flex-start;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ margin: 0 0 12px;
+ gap: 0;
+ border-color: transparent;
+ background: transparent;
+}
+
+.collapsed .newSession:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.newSessionLabel {
+ max-width: 200px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.collapsed .newSessionLabel {
+ max-width: 0;
+}
+
+.newSessionLabelMask { flex: 1; min-width: 0; }
+.newSession:is(:hover, :focus-visible) .newSessionLabelMask:has(+ .newSessionShortcut) { overflow: hidden; mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); }
+.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; }
+.newSessionContent > svg { flex: none; }
+
+.newSessionShortcut {
+ opacity: 0;
+ display: inline-flex;
+ flex: none;
+ pointer-events: none;
+ font-weight: 400;
+}
+
+.newSession:is(:hover, :focus-visible) .newSessionShortcut { opacity: 1; }
+
+.panelList {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 12px;
+}
+
+/* Panel entries share the New Session bar's 12px radius and 2px inset, the
+ session rows' hover/selected surface, and the primary ink. */
+.panelRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 40px;
+ padding: 8px 10px;
+ box-sizing: border-box;
+ border: none;
+ border-radius: var(--dsw-radius-md);
+ background: transparent;
+ color: var(--dsw-alias-label-primary);
+ font: inherit;
+ line-height: 22px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.panelRow:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.panelRow.panelActive {
+ background: var(--dsw-alias-interactive-bg-hover);
+ color: var(--dsw-alias-label-primary);
+}
+
+.panelRow:focus-visible {
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
+ outline-offset: -2px;
+}
+
+.panelGlyph {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.panelTitle {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.collapsed .panelList {
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.collapsed .panelRow {
+ width: 36px;
+ height: 36px;
+ justify-content: center;
+ margin: 0;
+ padding: 0;
+ color: var(--dsw-alias-label-primary);
+}
+
+/* Region seat: always mounted so the foot never moves. Its trailing margin
+ cancels the wide shell inset so the nested scrollbar can sit at the sidebar
+ edge; the browser restores that inset inside its own rows. */
+.regionArea {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin-left: -4px;
+ margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
+ padding-left: 4px;
+ overflow: hidden;
+}
+
+.collapsed .regionArea {
+ margin-left: 0;
+ margin-right: 0;
+ padding-left: 0;
+}
+
+/* Footer seats: additive actions stack above Settings. Each occupant owns its
+ button geometry and hover chrome. */
+.footArea {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+}
+
+.settingsArea,
+.footerActions {
+ flex: none;
+ min-width: 0;
+ width: 100%;
+}
+
+.footerActions {
+ display: flex;
+}
+
+.collapsed .footArea {
+ align-items: center;
+}
+
+.collapsed .settingsArea,
+.collapsed .footerActions {
+ display: flex;
+ justify-content: center;
+ width: auto;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .wide,
+ .fading > *,
+ .railIn .iconButton,
+ .railIn .newSession,
+ .railIn .panelList,
+ .railIn .footArea,
+ .railIn .regionArea {
+ transition: none;
+ animation: none;
+ }
+}
+
+/* DeepViewer override:start center-sidebar-wordmark */
+.brand {
+ justify-content: flex-start;
+ color: var(--dsw-alias-label-secondary);
+}
+/* DeepViewer override:end center-sidebar-wordmark */
+
+/* Electron supplies the window controls in its own titlebar. */
+:global([data-platform='darwin']) .logoRow { height: 0; padding: 0; margin: 0; }
+.collapsed .newSession { justify-content: center; }
diff --git a/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.after.txt b/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.after.txt
new file mode 100644
index 0000000..2b5686a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.after.txt
@@ -0,0 +1,7 @@
+ bindSessionViewActions(sessionId, actions)
+ const openView = (view: string, focus: string): void => {
+ if (!viewTabs().some(tab => tab.id === view)) return
+ activateView(sessionId, view)
+ actions.openView(view, focus)
+ selectedView(sessionId).set(view)
+ }
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.before.txt b/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.before.txt
new file mode 100644
index 0000000..552d5d4
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-bind-body-target-actions.before.txt
@@ -0,0 +1,5 @@
+ const openView = (view: string, focus: string): void => {
+ if (!viewTabs().some(tab => tab.id === view)) return
+ activateView(sessionId, view)
+ actions.openView(view, focus)
+ }
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.after.txt b/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.after.txt
new file mode 100644
index 0000000..839868a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.after.txt
@@ -0,0 +1,12 @@
+ inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => {
+ bindSessionViewActions(sessionId, actions)
+ return {
+ hooks: { conversationViews },
+ open: (id) => { workspaceNavigation.openSession(id) },
+ selectView: (view) => {
+ activateView(sessionId, view)
+ actions.setView(view)
+ selectedView(sessionId).set(view)
+ },
+ }
+ },
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.before.txt b/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.before.txt
new file mode 100644
index 0000000..0931f9c
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-bind-header-target-actions.before.txt
@@ -0,0 +1,8 @@
+ inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({
+ hooks: { conversationViews },
+ open: (id) => { workspaceNavigation.openSession(id) },
+ selectView: (view) => {
+ activateView(sessionId, view)
+ actions.setView(view)
+ },
+ }),
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.after.txt b/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.after.txt
new file mode 100644
index 0000000..e61744c
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.after.txt
@@ -0,0 +1,2 @@
+ const visibleTabs = hideChrome ? tabs.filter(tab => tab.id === 'chat' || tab.id === 'deepviewer-map') : tabs
+ const showTabs = visibleTabs.length > 1
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.before.txt b/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.before.txt
new file mode 100644
index 0000000..ce0e71d
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-blank-map-tabs.before.txt
@@ -0,0 +1 @@
+ const showTabs = !hideChrome && tabs.length > 1
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.after.txt b/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.after.txt
new file mode 100644
index 0000000..eade4b1
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.after.txt
@@ -0,0 +1,4 @@
+ const viewId = view ?? active?.id
+ if (session.blank && conversationPhase(session, conversation) === 'blank' && viewId !== 'deepviewer-map') return null
+ return (
+
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.before.txt b/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.before.txt
new file mode 100644
index 0000000..1d57e05
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-blank-view-area.before.txt
@@ -0,0 +1,4 @@
+ if (session.blank && conversationPhase(session, conversation) === 'blank') return null
+ const viewId = view ?? active?.id
+ return (
+
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.after.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.after.txt
new file mode 100644
index 0000000..51d814f
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.after.txt
@@ -0,0 +1,2 @@
+ usePresentation, useProjection, viewRequest, completeViewRequest, t,
+}: ChatViewSlotProps) {
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.before.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.before.txt
new file mode 100644
index 0000000..6afe127
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-owner.before.txt
@@ -0,0 +1,2 @@
+ usePresentation, useProjection, t,
+}: ChatViewSlotProps) {
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.after.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.after.txt
new file mode 100644
index 0000000..a57f9ab
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.after.txt
@@ -0,0 +1 @@
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.before.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.before.txt
new file mode 100644
index 0000000..ae8b316
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-react-import.before.txt
@@ -0,0 +1 @@
+import { memo, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.after.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.after.txt
new file mode 100644
index 0000000..7eaedc0
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.after.txt
@@ -0,0 +1,2 @@
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import { SessionSeq } from '@deepseek-ai/dsh-session/types'
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.before.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.before.txt
new file mode 100644
index 0000000..ec4d234
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-focus-seq-import.before.txt
@@ -0,0 +1 @@
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.after.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.after.txt
new file mode 100644
index 0000000..4d466de
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.after.txt
@@ -0,0 +1,47 @@
+ // DeepViewer map focus is a native history + turn navigation request. The
+ // existing rail and scroll owner retain paging, reading, and cancellation.
+ const [sequenceFocus, setSequenceFocus] = useState<{ request: NonNullable
; seq: number } | null>(null)
+ const attemptedFocus = useRef(null)
+ useEffect(() => {
+ if (viewRequest?.view !== 'chat' || openState !== 'open' || !scroll.initialized || attemptedFocus.current === viewRequest) return
+ const match = /^seq:(0|[1-9]\d*)$/.exec(viewRequest.focus)
+ if (match === null) { if (viewRequest.focus === '') completeViewRequest(); return }
+ const seq = Number(match[1])
+ if (!Number.isSafeInteger(seq)) { completeViewRequest(); return }
+ attemptedFocus.current = viewRequest
+ let live = true
+ void loadThrough(SessionSeq(seq)).then(() => {
+ if (live) setSequenceFocus({ request: viewRequest, seq })
+ }, () => { if (live) completeViewRequest() })
+ return () => { live = false }
+ }, [viewRequest, openState, scroll.initialized, loadThrough, completeViewRequest])
+ useLayoutEffect(() => {
+ if (sequenceFocus === null || sequenceFocus.request !== viewRequest || !scroll.initialized) return
+ const { seq } = sequenceFocus
+ const located = nodeStore.values().filter(node => node.anchorSeq <= seq
+ && (node.location.kind === 'turn' || node.location.kind === 'step'))
+ .sort((left, right) => right.anchorSeq - left.anchorSeq)[0]
+ const location = located?.location
+ let turn = location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined
+ if (turn === undefined && Array.isArray(turnOutline)) {
+ const outline = (turnOutline as readonly { seq?: unknown; turn?: unknown }[])
+ .filter(item => typeof item.seq === 'number' && item.seq <= seq && typeof item.turn === 'number')
+ .sort((left, right) => Number(right.seq) - Number(left.seq))[0]
+ if (typeof outline?.turn === 'number') turn = outline.turn
+ }
+ const item = railItems.find(candidate => candidate.turn === turn)
+ if (item === undefined) return
+ scroll.navigateToTurn(item)
+ completeViewRequest()
+ setSequenceFocus(null)
+ }, [sequenceFocus, viewRequest, scroll.initialized, scroll.navigateToTurn, nodeStore, order, turnOutline, railItems, completeViewRequest])
+ useEffect(() => {
+ if (sequenceFocus === null || sequenceFocus.request !== viewRequest) return
+ const timer = setTimeout(() => {
+ completeViewRequest(); setSequenceFocus(null)
+ }, 5_000)
+ return () => { clearTimeout(timer) }
+ }, [sequenceFocus, viewRequest, completeViewRequest])
+
+ return (
+
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.before.txt b/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.before.txt
new file mode 100644
index 0000000..6cd5c15
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-chat-native-seq-focus.before.txt
@@ -0,0 +1,2 @@
+ return (
+
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.after.txt b/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.after.txt
new file mode 100644
index 0000000..946956a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.after.txt
@@ -0,0 +1 @@
+className={css.composerSeat} data-composer-seat="" data-deepviewer-composer-seat=""
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.before.txt b/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.before.txt
new file mode 100644
index 0000000..6fb11cf
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-composer-flow-marker.before.txt
@@ -0,0 +1 @@
+className={css.composerSeat} data-composer-seat=""
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.after.txt b/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.after.txt
new file mode 100644
index 0000000..9636157
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.after.txt
@@ -0,0 +1,2 @@
+ const hero = !mapActive && (sessionId === undefined
+ || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)))
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.before.txt b/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.before.txt
new file mode 100644
index 0000000..22d9ce2
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-main-map-no-hero.before.txt
@@ -0,0 +1,2 @@
+ const hero = sessionId === undefined
+ || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true))
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.after.txt b/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.after.txt
new file mode 100644
index 0000000..5a0d4a0
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.after.txt
@@ -0,0 +1,2 @@
+ const { sessionId, useSession, useSessions, useConversation, useConversationView, renderSlot, renderFactorySlot } = props
+ const mapActive = useConversationView?.(view => view === 'deepviewer-map') === true
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.before.txt b/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.before.txt
new file mode 100644
index 0000000..d5a0b33
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-main-selected-view.before.txt
@@ -0,0 +1 @@
+ const { sessionId, useSession, useSessions, useConversation, renderSlot, renderFactorySlot } = props
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.after.txt b/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.after.txt
new file mode 100644
index 0000000..d69a1e7
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.after.txt
@@ -0,0 +1 @@
+(!mapActive && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true)
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.before.txt b/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.before.txt
new file mode 100644
index 0000000..379e998
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-map-ignore-blank-settling.before.txt
@@ -0,0 +1 @@
+(shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true)
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.after.txt b/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.after.txt
new file mode 100644
index 0000000..6c46772
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.after.txt
@@ -0,0 +1,3 @@
+ viewRequest,
+ openSessionView,
+ openView,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.before.txt b/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.before.txt
new file mode 100644
index 0000000..6ba4f19
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-render-target-navigation.before.txt
@@ -0,0 +1,2 @@
+ viewRequest,
+ openView,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.after.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.after.txt
new file mode 100644
index 0000000..0269a8c
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.after.txt
@@ -0,0 +1,4 @@
+ interface SessionMaybeStandardProps {
+ /** Native selected view; absent before a Session is selected. */
+ useConversationView?: MaybeSnapshotSelectorHook
+ /** Selector hook whose values are absent without a current Session. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.before.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.before.txt
new file mode 100644
index 0000000..f557dfe
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-maybe-standard.before.txt
@@ -0,0 +1,2 @@
+ interface SessionMaybeStandardProps {
+ /** Selector hook whose values are absent without a current Session. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.after.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.after.txt
new file mode 100644
index 0000000..9ad97b9
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.after.txt
@@ -0,0 +1 @@
+ hooks: ['conversation', 'input', 'conversationView'],
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.before.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.before.txt
new file mode 100644
index 0000000..3a58d9f
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-roster.before.txt
@@ -0,0 +1 @@
+ hooks: ['conversation', 'input'],
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.after.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.after.txt
new file mode 100644
index 0000000..f81604a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.after.txt
@@ -0,0 +1,3 @@
+ conversation: conversation.snapshot,
+ conversationView: selectedView(binding.sessionId),
+ input: shell.state,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.before.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.before.txt
new file mode 100644
index 0000000..edfb90f
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-source.before.txt
@@ -0,0 +1,2 @@
+ conversation: conversation.snapshot,
+ input: shell.state,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.after.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.after.txt
new file mode 100644
index 0000000..af79c6a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.after.txt
@@ -0,0 +1,4 @@
+ interface SessionStandardProps {
+ /** Native selected view; changes with persisted view actions and restoration. */
+ useConversationView?: SnapshotSelectorHook
+ /** Selector hook over target-neutral Conversation assembly. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.before.txt b/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.before.txt
new file mode 100644
index 0000000..b64464f
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-selected-view-standard.before.txt
@@ -0,0 +1,2 @@
+ interface SessionStandardProps {
+ /** Selector hook over target-neutral Conversation assembly. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.after.txt b/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.after.txt
new file mode 100644
index 0000000..81e9064
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.after.txt
@@ -0,0 +1,3 @@
+ /** Address the native target Session's view store, never the caller's store. */
+ openSessionView?: ((sessionId: SessionId, view: string, focus: string) => Promise) | undefined
+ /** Bind input draft persistence to the Session-owned store instance. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.before.txt b/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.before.txt
new file mode 100644
index 0000000..6a233cd
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-session-navigation-injected.before.txt
@@ -0,0 +1 @@
+ /** Bind input draft persistence to the Session-owned store instance. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.after.txt b/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.after.txt
new file mode 100644
index 0000000..bdd1449
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.after.txt
@@ -0,0 +1,3 @@
+ bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
+ openSessionView,
+ openView,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.before.txt b/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.before.txt
new file mode 100644
index 0000000..3231d1d
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-share-target-navigation.before.txt
@@ -0,0 +1,2 @@
+ bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
+ openView,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.after.txt b/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.after.txt
new file mode 100644
index 0000000..efa8a88
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.after.txt
@@ -0,0 +1,77 @@
+ // DeepViewer target navigation uses the renderer-owned BoundActions of the exact
+ // Session generation. It never manufactures a second persistent store.
+ const selectedViews = new Map>>()
+ const sessionViewActions = new Map }>()
+ let pendingView: { sessionId: SessionId; view: string; focus: string; resolve: () => void; reject: (error: Error) => void; timer: ReturnType } | undefined
+ const selectedView = (sessionId: SessionId) => {
+ let source = selectedViews.get(sessionId)
+ if (source === undefined) {
+ source = createSnapshotStore(readConversationViewPreference(sessionId))
+ selectedViews.set(sessionId, source)
+ const binding = sessions.binding(sessionId)
+ binding?.ctx.effect(() => () => {
+ if (selectedViews.get(sessionId) === source) selectedViews.delete(sessionId)
+ if (sessionViewActions.get(sessionId)?.binding === binding) sessionViewActions.delete(sessionId)
+ if (pendingView?.sessionId === sessionId) {
+ const pending = pendingView; pendingView = undefined; clearTimeout(pending.timer)
+ pending.reject(new Error('Conversation target was released'))
+ }
+ }, 'DeepViewer selected conversation view')
+ }
+ return source
+ }
+ const restoreView = (sessionId: SessionId): void => {
+ const preferred = pendingView?.sessionId === sessionId ? pendingView.view : readConversationViewPreference(sessionId)
+ activateView(sessionId, preferred)
+ selectedView(sessionId).set(preferred)
+ }
+ const applyPendingView = (): void => {
+ const pending = pendingView
+ if (pending === undefined) return
+ const target = sessionViewActions.get(pending.sessionId)
+ if (target === undefined || sessions.binding(pending.sessionId) !== target.binding) return
+ pendingView = undefined; clearTimeout(pending.timer)
+ activateView(pending.sessionId, pending.view)
+ target.actions.openView(pending.view, pending.focus)
+ selectedView(pending.sessionId).set(pending.view)
+ pending.resolve()
+ }
+ const bindSessionViewActions = (sessionId: SessionId, actions: BoundActions): void => {
+ const binding = sessions.binding(sessionId)
+ // Root-level slot roster introspection has no Session binding yet.
+ // Only a renderer-owned Session can publish addressed navigation actions.
+ if (binding === undefined) return
+ sessionViewActions.set(sessionId, { binding, actions })
+ queueMicrotask(applyPendingView)
+ }
+ const openSessionView = (targetId: SessionId, view: string, focus: string): Promise => {
+ if (!viewTabs().some(tab => tab.id === view)) return Promise.reject(new Error('Unknown conversation view'))
+ if (pendingView !== undefined) {
+ const previous = pendingView; pendingView = undefined; clearTimeout(previous.timer)
+ previous.reject(new Error('Conversation navigation was superseded'))
+ }
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ if (pendingView?.timer !== timer) return
+ pendingView = undefined
+ reject(new Error('Conversation target did not become available'))
+ }, 8_000)
+ pendingView = { sessionId: targetId, view, focus, resolve, reject, timer }
+ try {
+ // Native navigation synchronously retains the target as mainView and
+ // selects it before the target store acknowledges its focus request.
+ workspaceNavigation.openSession(targetId)
+ if (sessions.binding(targetId) === undefined) throw new Error('Conversation target has no native binding')
+ selectedView(targetId).set(view)
+ applyPendingView()
+ } catch (error: unknown) {
+ if (pendingView?.timer === timer) { pendingView = undefined; clearTimeout(timer) }
+ reject(error instanceof Error ? error : new Error(String(error)))
+ }
+ })
+ }
+ ctx.effect(() => () => {
+ if (pendingView === undefined) return
+ const pending = pendingView; pendingView = undefined; clearTimeout(pending.timer)
+ pending.reject(new Error('Conversation navigation was disposed'))
+ }, 'DeepViewer native conversation navigation')
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.before.txt b/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.before.txt
new file mode 100644
index 0000000..78e2660
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-target-native-navigation.before.txt
@@ -0,0 +1,3 @@
+ const restoreView = (sessionId: SessionId): void => {
+ activateView(sessionId, readConversationViewPreference(sessionId))
+ }
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.after.txt b/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.after.txt
new file mode 100644
index 0000000..762956a
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.after.txt
@@ -0,0 +1 @@
+ renderSlot, bindDraftMirror, openSessionView, openView, useInspectCall,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.before.txt b/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.before.txt
new file mode 100644
index 0000000..dbc9ab3
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-view-navigation-owner.before.txt
@@ -0,0 +1 @@
+ renderSlot, bindDraftMirror, openView, useInspectCall,
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.after.txt b/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.after.txt
new file mode 100644
index 0000000..696dade
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.after.txt
@@ -0,0 +1,3 @@
+ /** Open a registered View on the addressed Session through native navigation. */
+ openSessionView?: ((sessionId: SessionId, view: string, focus: string) => Promise) | undefined
+ /** Acknowledge the current one-shot focus request. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.before.txt b/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.before.txt
new file mode 100644
index 0000000..263e0c6
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/Synapse-view-owner-navigation.before.txt
@@ -0,0 +1 @@
+ /** Acknowledge the current one-shot focus request. */
\ No newline at end of file
diff --git a/apps/deepviewer-adapter/presentation/WorkspaceBrowser.module.css b/apps/deepviewer-adapter/presentation/WorkspaceBrowser.module.css
new file mode 100644
index 0000000..76f6528
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/WorkspaceBrowser.module.css
@@ -0,0 +1,647 @@
+.root {
+ --dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
+ --dsh-session-list-scrollbar-width: 5px;
+ --dsh-session-list-scrollbar-offset: 2px;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ padding-right: var(--dsh-session-list-edge-inset);
+}
+
+.root.rail {
+ padding-right: 0;
+}
+
+.iconButton {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* The action cluster animates its width and the search pill rounds its corners,
+ * so both clip an outer ring; these controls keep theirs inside instead. */
+.iconButton:focus-visible,
+.searchButton:focus-visible,
+.clearButton:focus-visible {
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
+ outline-offset: -2px;
+}
+
+.iconButton:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* View-options dropdown card: labels are short, so a content-sized card reads
+ cramped and its width shifts with the locale — hold a stable floor. */
+.viewOptionsMenu {
+ min-width: 200px;
+}
+
+/* Section header: title, an inline search control, and the two trailing
+ actions. Expanding search collapses the action cluster and takes its room. */
+.sectionHeader {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ height: 36px;
+ padding-left: 4px;
+ margin-bottom: 4px;
+ box-sizing: border-box;
+ border-radius: var(--dsw-radius-md);
+ overflow: hidden;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.root:not(.rail) .sectionHeader {
+ margin-top: 2px;
+ margin-right: -4px;
+}
+
+.sectionLabel {
+ flex: none;
+ max-width: 45%;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ line-height: 20px;
+ opacity: 1;
+ visibility: visible;
+ transition:
+ max-width 180ms var(--ds-ease-in-out),
+ margin-right 180ms var(--ds-ease-in-out),
+ opacity 120ms var(--ds-ease-in-out),
+ transform 180ms var(--ds-ease-in-out),
+ visibility 0s linear;
+}
+
+.sectionLabelHidden {
+ max-width: 0;
+ margin-right: -4px;
+ opacity: 0;
+ transform: translateX(-4px);
+ visibility: hidden;
+ transition-delay: 0s, 0s, 0s, 0s, 180ms;
+}
+
+.searchSlot {
+ flex: 1;
+ max-width: 28px;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+ padding-left: 0;
+ box-sizing: border-box;
+ transition:
+ max-width 180ms var(--ds-ease-in-out),
+ padding-left 180ms var(--ds-ease-in-out);
+}
+
+.searchSlotExpanded {
+ max-width: 100%;
+ padding-left: 0;
+}
+
+.headerActions {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ max-width: 60px;
+ opacity: 1;
+ overflow: hidden;
+ visibility: visible;
+ transition:
+ max-width 180ms var(--ds-ease-in-out),
+ opacity 120ms var(--ds-ease-in-out),
+ transform 180ms var(--ds-ease-in-out),
+ visibility 0s linear;
+}
+
+.headerActionsHidden {
+ max-width: 0;
+ opacity: 0;
+ transform: translateX(4px);
+ visibility: hidden;
+ pointer-events: none;
+ transition-delay: 0s, 0s, 0s, 180ms;
+}
+
+/* Inline search always fills the room between the title and trailing actions;
+ it grows farther right when the action cluster collapses. */
+.search {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 0;
+ width: 100%;
+ height: 28px;
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ background: transparent;
+ cursor: text;
+ color: var(--dsw-alias-label-secondary);
+ overflow: hidden;
+ transition:
+ width 180ms var(--ds-ease-in-out),
+ padding 180ms var(--ds-ease-in-out),
+ border-color 180ms var(--ds-ease-in-out),
+ background-color 180ms var(--ds-ease-in-out);
+}
+
+.searchExpanded {
+ width: 100%;
+ height: 30px;
+ margin-inline: 0;
+ padding: 0 4px 0 0;
+ border: 0.5px solid var(--dsw-alias-border-l4);
+ border-radius: var(--dsw-radius-md);
+ background: transparent;
+ color: var(--dsw-alias-label-caption);
+}
+
+.searchButton {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ color: inherit;
+}
+
+.searchExpanded .searchButton {
+ width: 28px;
+ height: 30px;
+}
+
+.searchButton:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.searchExpanded .searchButton:hover {
+ background: transparent;
+}
+
+.searchInput {
+ flex: 1;
+ width: 0;
+ min-width: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ opacity: 0;
+ pointer-events: none;
+ font-size: 13px;
+ line-height: 18px;
+ color: var(--dsw-alias-label-primary);
+ transition: opacity 120ms var(--ds-ease-in-out);
+}
+
+.searchExpanded .searchInput {
+ margin-left: -2px;
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.searchInput::placeholder {
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.clearButton {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.clearButton:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Rail variant (own .rail class from the wide owner prop — the region never
+ reads the shell's class names): the two icon controls stack as 36x36
+ circles matching the shell's rail rhythm. Both use the rail's shared base
+ left anchor so the outer shell can translate the whole region uniformly. */
+.rail .sectionHeader {
+ gap: 0;
+ padding-left: 0;
+ margin-bottom: 12px;
+ justify-content: flex-start;
+}
+
+.rail .headerActions {
+ max-width: none;
+}
+
+.rail .iconButton {
+ width: 36px;
+ height: 36px;
+ /* Rail hovers share the shell rail's 12px rounding. */
+ border-radius: var(--dsw-radius-md);
+ color: var(--dsw-alias-label-primary);
+}
+
+.rail .search {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ margin: 0 0 12px;
+ gap: 0;
+ /* The collapsed search frame clips its button's hover, so it must carry the
+ rail's 12px rounding itself. */
+ border-radius: var(--dsw-radius-md);
+ border-color: transparent;
+ background: transparent;
+}
+
+.rail .searchButton {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--dsw-radius-md);
+ color: var(--dsw-alias-label-primary);
+}
+
+.rail .searchButton:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* List seat: always mounted so the shell foot never moves. */
+.listArea {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin-left: -4px;
+ margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
+ padding-left: 4px;
+ /* The list remains the scroll clip. This seat stays visible so the
+ absolutely positioned first-boundary marker can occupy the header gap. */
+ overflow: visible;
+}
+
+.rail .listArea {
+ margin-left: 0;
+ margin-right: 0;
+ padding-left: 0;
+}
+
+/* Relative for the bottom fade overlay. */
+.treeBody {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+/* Bottom fade: compact overlay pinned to the visible bottom,
+ transparent -> sidebar fill so it tracks the theme. */
+.fade {
+ position: absolute;
+ left: 0;
+ right: var(--dsh-session-list-edge-inset);
+ bottom: 0;
+ height: 24px;
+ background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
+ pointer-events: none;
+}
+
+/* macOS desktop: the sidebar is translucent over window vibrancy, so the
+ opaque fill gradient would paint a solid smear instead of a fade. */
+:global([data-platform='darwin']) .fade {
+ display: none;
+}
+
+/* Wide-only content fades back in on expand remount (mirrors the shell). */
+.wide {
+ animation: wide-in 200ms var(--ds-ease-in-out);
+}
+
+@keyframes wide-in {
+ from { opacity: 0; }
+}
+
+/* List: the only scrolling region. Block children keep their design heights
+ under content overflow. The 2px edge offset, stable 5px themed scrollbar,
+ and remaining padding equal the shell's right inset, with or without
+ overflow, so moving the bar does not move the rows. */
+.list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ margin-left: -4px;
+ margin-right: var(--dsh-session-list-scrollbar-offset);
+ padding-left: 4px;
+ padding-right: calc(
+ var(--dsh-session-list-edge-inset)
+ - var(--dsh-session-list-scrollbar-width)
+ - var(--dsh-session-list-scrollbar-offset)
+ );
+ /* Clears the compact bottom fade overlay: at scroll end the last row sits
+ above the gradient instead of under it. */
+ padding-bottom: 16px;
+ scrollbar-gutter: stable;
+}
+
+.flatList > * + *,
+.searchTree > [role='treeitem'] + [role='treeitem'],
+.groupSection > * + * {
+ margin-top: 2px;
+}
+
+.searchStatus {
+ padding: 10px 12px;
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+/* Pending content search: skeleton rows on searchResultRow metrics — a round
+ leading glyph beside a title bar and a wider snippet bar, breathing through
+ opacity (same pulse as the input-trigger menu skeleton). */
+.skeletonRow {
+ display: flex;
+ /* Top alignment lines the 16px dot up with the equally tall title bar;
+ the paddings center the 35px content in the 48px row. */
+ align-items: flex-start;
+ gap: 8px;
+ box-sizing: border-box;
+ min-height: 48px;
+ padding: 6px 8px 7px;
+}
+
+.skeletonDot {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ corner-shape: round;
+}
+
+.skeletonBars {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.skeletonDot,
+.skeletonBar {
+ background: var(--dsw-alias-bg-skeleton);
+ animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
+}
+
+.skeletonBar {
+ width: 65%;
+ height: 16px;
+ border-radius: var(--dsw-radius-xs);
+}
+
+.skeletonBarWide {
+ width: 90%;
+ height: 13px;
+}
+
+@keyframes search-skeleton {
+ 0% { opacity: 1; }
+ 40% { opacity: 0.6; }
+ 80%, 100% { opacity: 1; }
+}
+
+/* One workspace section: header row + a compact expanded session run. */
+.groupSection {
+ position: relative;
+}
+
+.groupSection + .groupSection {
+ margin-top: 4px;
+}
+
+.listTopDropIndicator,
+.workspaceDropBefore::before,
+.workspaceDropAfter::after {
+ content: '';
+ position: absolute;
+ z-index: 1;
+ left: 0;
+ right: 0;
+ height: 12px;
+ background:
+ linear-gradient(
+ 55deg,
+ transparent calc(50% - 1px),
+ var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
+ transparent calc(50% + 1px)
+ ) 0 0 / 5px 7px no-repeat,
+ linear-gradient(
+ 125deg,
+ transparent calc(50% - 1px),
+ var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
+ transparent calc(50% + 1px)
+ ) 0 5px / 5px 7px no-repeat,
+ linear-gradient(
+ var(--dsw-alias-state-business-primary) 0 0
+ ) 4px 5px / calc(100% - 4px) 2px no-repeat;
+ pointer-events: none;
+}
+
+/* The first insertion boundary keeps the same -8px coordinate as every
+ Workspace boundary, but lives outside the scrolling clip. */
+.listTopDropIndicator {
+ top: -8px;
+ left: 0;
+ right: var(--dsh-session-list-edge-inset);
+}
+
+.listTopDropActive > .workspaceDropBefore:first-child::before {
+ display: none;
+}
+
+.workspaceDropBefore::before {
+ top: -8px;
+}
+
+.workspaceDropAfter::after {
+ bottom: -8px;
+}
+
+.sessionOverflowButton {
+ width: 100%;
+ height: 28px;
+ border: none;
+ border-radius: var(--dsw-radius-sm);
+ padding: 0 12px 0 calc(28px + var(--dsh-workspace-indent, 0px));
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ font-size: 12px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.groupSection > .sessionOverflowButton {
+ margin-top: 0;
+}
+
+.sessionOverflowButton:hover {
+ background: transparent;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.empty {
+ padding: 16px 12px;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 13px;
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin-top: 80px;
+ padding: 0 12px;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 13px;
+ line-height: 20px;
+}
+
+.emptyState > svg {
+ margin-bottom: 4px;
+ color: var(--dsw-alias-label-caption);
+}
+
+.emptyAction {
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--dsw-alias-link);
+}
+
+/* Rename dialog form (same figma dialog family as the create modals). */
+.renameInput {
+ box-sizing: border-box;
+ width: 100%;
+ height: 44px;
+ padding: 7px 14px;
+ border: 0.5px solid var(--dsw-alias-border-l4);
+ border-radius: var(--dsw-radius-lg);
+ outline: none;
+ background: transparent;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 22px;
+ color: var(--dsw-alias-label-primary);
+}
+
+.renameInput:disabled {
+ color: var(--dsw-alias-label-dimmed);
+}
+
+.renameError {
+ margin-top: 8px;
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.deleteAction:not(:disabled) {
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.deleteStatus {
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* The stop-and-archive dialog's list of work that will be stopped. */
+.archiveActivity {
+ margin: 0 0 8px;
+ padding-left: 18px;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--dsw-alias-label-primary);
+}
+
+.archiveActivity li {
+ overflow-wrap: anywhere;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .wide {
+ animation: none;
+ }
+
+ .skeletonDot,
+ .skeletonBar {
+ animation: none;
+ }
+
+ .search,
+ .sectionLabel,
+ .searchSlot,
+ .searchInput,
+ .headerActions {
+ transition: none;
+ }
+}
+
+/* Workspace-free Chat shares the existing sidebar colors and controls. */
+
+.root:not(.rail) .sectionHeader { height: 32px; margin: 12px 0 6px; padding-left: 8px; }
+.groupSection + .groupSection { margin-top: 12px; }
+
+/* Chat is a flat list: align titles with the quiet section heading. */
+.chatHistory {
+ --dsh-session-title-start: 0px;
+ --dsh-session-status-order: 1;
+}
+
+
+.chatHistory .list > * + * { margin-top: 2px; }
+
+/* Expanded search uses the same outer edges as the mode and new-session controls. */
+.root:not(.rail) .sectionHeaderExpanded {
+ padding-left: 0;
+ margin-right: 0;
+ gap: 0;
+ overflow: visible;
+}
+.sectionHeaderExpanded .sectionLabel,
+.sectionHeaderExpanded .headerActions { display: none; }
+
+.headerActions[hidden] { display: none; }
diff --git a/apps/deepviewer-adapter/presentation/WorkspacePicker.module.css b/apps/deepviewer-adapter/presentation/WorkspacePicker.module.css
new file mode 100644
index 0000000..226f0e0
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/WorkspacePicker.module.css
@@ -0,0 +1,26 @@
+/* The adoption error dialog's footer and message styles; the dialog itself is
+ * the shared Modal (same figma dialog family as the browser's own dialogs). */
+.modalAction {
+ min-width: 72px;
+}
+
+.modalError,
+.menuStatus {
+ margin-top: 8px;
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.modalError {
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.menuStatus {
+ color: var(--dsw-alias-label-secondary);
+}
+
+.createDialog { width: min(480px, calc(100vw - 32px)); }
+.projectNameField { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 12px; color: var(--dsw-alias-label-secondary); }
+.projectNameField:focus-within { border-color: var(--dsw-alias-label-secondary); }
+.projectNameField input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; line-height: 24px; }
+.projectHint { margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 20px; }
diff --git a/apps/deepviewer-adapter/presentation/contracts.json b/apps/deepviewer-adapter/presentation/contracts.json
new file mode 100644
index 0000000..0f945be
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/contracts.json
@@ -0,0 +1,397 @@
+[
+ {
+ "id": "deepviewer-settings-panel",
+ "file": "packages/client/ui-settings-general/src/client/SettingsRoot.tsx",
+ "beforeFile": "SettingsPanel.native.tsx.txt",
+ "afterFile": "SettingsPanel.tsx.txt"
+ },
+ {
+ "id": "deepviewer-settings-back-icon",
+ "file": "packages/client/ui-settings-general/src/client/SettingsRoot.tsx",
+ "before": "IconArchiveOutlineMedium, IconCloseOutlineRegular,",
+ "after": "IconArchiveOutlineMedium, IconChevronLeftOutlineRegular,"
+ },
+ {
+ "id": "deepviewer-settings-back-zh",
+ "file": "packages/client/ui-settings-general/src/client/locales.ts",
+ "before": "'close': '关闭'",
+ "after": "'close': '返回 DeepViewer'"
+ },
+ {
+ "id": "deepviewer-settings-back-en",
+ "file": "packages/client/ui-settings-general/src/client/locales.ts",
+ "before": "'close': 'Close'",
+ "after": "'close': 'Back to DeepViewer'"
+ },
+ {
+ "id": "deepviewer-running-pixels",
+ "file": "packages/client/ui-workspace/src/client/rows/Rows.tsx",
+ "before": " ",
+ "after": " {statuses[0].state === 'ongoing' ? (\n \n {[90, 180, 270, 0, 90, 180, 90, 180, 270].map((delay, index) => (\n \n ))}\n \n ) : }"
+ },
+ {
+ "id": "deepviewer-running-title",
+ "file": "packages/client/ui-workspace/src/client/rows/Rows.tsx",
+ "before": " className={css.title}",
+ "after": " className={clsx(css.title, primaryStatus.state === 'ongoing' && css.driveTitle)}"
+ },
+ {
+ "id": "deepviewer-sidebar-anchor",
+ "file": "packages/client/ui-sidebar/src/client/SidebarRoot.tsx",
+ "before": " ref={column}",
+ "after": " ref={column}\n data-deepviewer-sidebar=\"\""
+ },
+ {
+ "id": "deepviewer-chat-list-spacing",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": "className={clsx(css.root, !wide && css.rail)}",
+ "after": "className={clsx(css.root, isChat && css.chatHistory, !wide && css.rail)}"
+ },
+ {
+ "id": "deepviewer-sidebar-search-layout",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": "",
+ "after": "
"
+ },
+ {
+ "id": "deepviewer-chat-history-label",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": "{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}",
+ "after": "{isChat ? t('chat.history') : groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}"
+ },
+ {
+ "id": "deepviewer-chat-actions",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx",
+ "before": "
",
+ "after": "
"
+ },
+ {
+ "id": "deepviewer-chat-label-zh",
+ "file": "packages/client/ui-workspace/src/client/locales.ts",
+ "before": "'section.sessions': '会话',",
+ "after": "'section.sessions': '会话',\n 'chat.history': '聊天记录',"
+ },
+ {
+ "id": "deepviewer-chat-label-en",
+ "file": "packages/client/ui-workspace/src/client/locales.ts",
+ "before": "'section.sessions': 'Sessions',",
+ "after": "'section.sessions': 'Sessions',\n 'chat.history': 'Chat history',"
+ },
+ {
+ "id": "deepviewer-direct-settings-entry",
+ "file": "packages/client/ui-settings-general/src/client/SettingsRoot.tsx",
+ "before": " {renderSlot('settings.launcher', {\n wide, settingsOpen: open, openSettings: actions.open,\n ...(shortcut?.keys.length ? { settingsShortcut: { keys: shortcut.keys, aria: shortcut.aria } } : {}),\n openOnboarding: (id) => { close(); setRequestedOnboarding(id) },\n }, { fallback: ",
+ "after": " "
+ },
+ {
+ "id": "deepviewer-direct-settings-entry-close",
+ "file": "packages/client/ui-settings-general/src/client/SettingsRoot.tsx",
+ "before": " })}",
+ "after": " "
+ },
+ {
+ "id": "deepviewer-chat-copy-type",
+ "file": "packages/preset/agent-preset-registry/src/display.ts",
+ "before": " | 'presetCordisName' | 'presetCordisDescription'",
+ "after": " | 'presetCordisName' | 'presetCordisDescription'\n | 'presetDeepViewerChatName' | 'presetDeepViewerChatDescription'"
+ },
+ {
+ "id": "deepviewer-chat-copy-resolver",
+ "file": "packages/preset/agent-preset-registry/src/display.ts",
+ "before": " const keys = isBuiltInPreset(preset) ?",
+ "after": " // Translate shipped DeepViewer copy while preserving user-authored overrides and stable IDs.\n if (preset.id === 'deepviewer-chat') return {\n name: preset.name === undefined || preset.name === 'Chat' ? t('presetDeepViewerChatName') : preset.name,\n description: preset.description === undefined || preset.description === 'Workspace-free conversation with web and uploaded attachments.'\n ? t('presetDeepViewerChatDescription') : preset.description,\n }\n const keys = isBuiltInPreset(preset) ?"
+ },
+ {
+ "id": "deepviewer-chat-locale-type",
+ "file": "packages/client/ui-agent-preset/src/client/locales.ts",
+ "before": " | 'presetCordisDescription'",
+ "after": " | 'presetCordisDescription'\n | 'presetDeepViewerChatName'\n | 'presetDeepViewerChatDescription'"
+ },
+ {
+ "id": "deepviewer-chat-locale-en",
+ "file": "packages/client/ui-agent-preset/src/client/locales.ts",
+ "before": " presetStandardName: 'Standard mode',",
+ "after": " presetDeepViewerChatName: 'Chat',\n presetDeepViewerChatDescription: 'Chat without a workspace, with web access and uploaded attachments.',\n presetStandardName: 'Standard mode',"
+ },
+ {
+ "id": "deepviewer-chat-locale-zh",
+ "file": "packages/client/ui-agent-preset/src/client/locales.ts",
+ "before": " presetStandardName: '标准模式',",
+ "after": " presetDeepViewerChatName: '聊天',\n presetDeepViewerChatDescription: '无需工作区即可聊天,支持网页访问和上传附件。',\n presetStandardName: '标准模式',"
+ },
+ {
+ "id": "deepviewer-running-copy-zh",
+ "file": "packages/client/ui-chat/src/client/locale.ts",
+ "before": "'chat.deepDiving': '深度求索中'",
+ "after": "'chat.deepDiving': '深度求索中'"
+ },
+ {
+ "id": "deepviewer-running-clock-copy-zh",
+ "file": "packages/client/ui-chat/src/client/locale.ts",
+ "before": "'chat.deepDivingFor': '深度求索中,用时 {duration} ···'",
+ "after": "'chat.deepDivingFor': '深度求索中,用时 {duration} ···'"
+ },
+ {
+ "id": "deepviewer-running-copy-en",
+ "file": "packages/client/ui-chat/src/client/locale.ts",
+ "before": "'chat.deepDiving': 'Deep diving'",
+ "after": "'chat.deepDiving': 'Thinking'"
+ },
+ {
+ "id": "deepviewer-running-clock-copy-en",
+ "file": "packages/client/ui-chat/src/client/locale.ts",
+ "before": "'chat.deepDivingFor': 'Deep diving for {duration} ···'",
+ "after": "'chat.deepDivingFor': 'Thinking for {duration} ···'"
+ },
+ {
+ "id": "deepviewer-running-remove-whale-import",
+ "file": "packages/client/ui-chat/src/client/chat/RunningStatus.tsx",
+ "before": "import { RunningWhaleTail } from './RunningWhaleTail.tsx'\n",
+ "after": "// DeepViewer running status uses its localized thinking text.\n"
+ },
+ {
+ "id": "deepviewer-running-remove-whale",
+ "file": "packages/client/ui-chat/src/client/chat/RunningStatus.tsx",
+ "before": "
\n",
+ "after": " {/* DeepViewer: no decorative whale before the running label. */}\n"
+ },
+ {
+ "id": "deepviewer-stats-center-type",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "before": " /** Right-aligned Session utilities in ascending order. */",
+ "after": " /** DeepViewer centered native session statistics. */\n 'conversation.session.header.center': {\n kind: 'list'\n scope: 'session'\n owner: ConversationHeaderActionOwnerProps\n }\n /** Right-aligned Session utilities in ascending order. */"
+ },
+ {
+ "id": "deepviewer-stats-center-props",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "before": " | 'conversation.session.header.utilities'",
+ "after": " | 'conversation.session.header.center'\n | 'conversation.session.header.utilities'"
+ },
+ {
+ "id": "deepviewer-stats-center-register",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "before": " 'conversation.session.header.utilities': { kind: 'list', scope: 'session' },",
+ "after": " 'conversation.session.header.center': { kind: 'list', scope: 'session' },\n 'conversation.session.header.utilities': { kind: 'list', scope: 'session' },"
+ },
+ {
+ "id": "deepviewer-stats-center-render",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx",
+ "before": "
",
+ "after": " {/* DeepViewer statistics live beside the view tabs. */}\n
",
+ "previousAfter": "
\n {renderSlot('conversation.session.header.center', {})}\n
\n
"
+ },
+ {
+ "id": "deepviewer-stats-center-layout",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css",
+ "before": ".titleCluster {",
+ "after": "/* DeepViewer statistics use the separate view-navigation row. */\n.titleCluster {",
+ "previousAfter": ".titleRow:has(.headerCenter) {\n display: grid;\n grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr) auto;\n}\n\n.headerCenter {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n max-width: 100%;\n -webkit-app-region: no-drag;\n}\n\n.titleRow:has(.headerCenter) .headerUtilities {\n grid-column: 3;\n grid-row: 1;\n justify-self: end;\n margin-left: 8px;\n}\n\n.titleRow:has(.headerCenter) .headerCorner {\n grid-column: 4;\n grid-row: 1;\n}\n\n.titleCluster {"
+ },
+ {
+ "id": "deepviewer-context-next-to-model",
+ "file": "packages/client/ui-conversation/src/client/skeleton/InputBar.tsx",
+ "before": " {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}",
+ "after": " {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}\n {activity ? null : }"
+ },
+ {
+ "id": "deepviewer-context-remove-footer",
+ "file": "packages/client/ui-conversation/src/client/skeleton/InputBar.tsx",
+ "before": " {activity ? null : }\n
",
+ "after": "
"
+ },
+ {
+ "id": "deepviewer-statistics-navigation-row",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx",
+ "before": " {showTabs && (\n // data-conversation-tabs: marks the tab strip, which the window-chrome\n // geometry and the browser coverage lane anchor on.\n
\n {tabs.map(viewTab => (\n { selectView(viewTab.id) }}\n >\n {viewTab.label}\n \n ))}\n
\n )}",
+ "after": " {(!hideChrome || visibleTabs.some(tab => tab.id === 'deepviewer-map')) && (\n
\n {showTabs && (\n
\n {visibleTabs.map(viewTab => (\n { selectView(viewTab.id) }}\n >\n {viewTab.label}\n \n ))}\n
\n )}\n {!hideChrome &&
\n {renderSlot('conversation.session.header.center', {})}\n
}\n
\n )}",
+ "previousAfter": " {!hideChrome && (\n
\n {showTabs && (\n
\n {tabs.map(viewTab => (\n { selectView(viewTab.id) }}\n >\n {viewTab.label}\n \n ))}\n
\n )}\n
\n {renderSlot('conversation.session.header.center', {})}\n
\n
\n )}"
+ },
+ {
+ "id": "deepviewer-statistics-navigation-style",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css",
+ "before": ".tabs {",
+ "after": ".navigationRow {\n grid-column: 1 / -1;\n display: flex;\n align-items: stretch;\n gap: 16px;\n min-width: 0;\n margin-top: 10px;\n}\n\n.navigationRow .tabs {\n flex: none;\n margin-top: 0;\n}\n\n.headerCenter {\n display: flex;\n flex: 1;\n justify-content: center;\n align-items: flex-start;\n min-width: 0;\n padding-bottom: 9px;\n -webkit-app-region: no-drag;\n}\n\n.headerCenter > * {\n min-width: 0;\n max-width: 100%;\n}\n\n.tabs {"
+ },
+ {
+ "id": "deepviewer-window-safe-minimum",
+ "file": "apps/desktop/src/main.ts",
+ "before": " minWidth: 520,\n minHeight: 600,",
+ "after": " minWidth: 900,\n minHeight: 640,"
+ },
+ {
+ "id": "deepviewer-shared-sidebar-material-light",
+ "file": "packages/client/ui-layout/src/client/AppFrame.module.css",
+ "before": " background:\n linear-gradient(\n to bottom,\n rgb(122 155 240 / 0.1),\n rgb(122 155 240 / 0) 35%,\n rgb(143 137 184 / 0) 68%,\n rgb(143 137 184 / 0.09)\n ),\n color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent);",
+ "after": " background: var(--deepviewer-sidebar-material);"
+ },
+ {
+ "id": "deepviewer-shared-sidebar-material-dark",
+ "file": "packages/client/ui-layout/src/client/AppFrame.module.css",
+ "before": " background:\n linear-gradient(\n to bottom,\n rgb(122 155 240 / 0.08),\n rgb(122 155 240 / 0) 35%,\n rgb(143 137 184 / 0) 68%,\n rgb(143 137 184 / 0.07)\n ),\n color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent);",
+ "after": " background: var(--deepviewer-sidebar-material);"
+ },
+ {
+ "id": "deepviewer-shared-sidebar-material-reduced",
+ "file": "packages/client/ui-layout/src/client/AppFrame.module.css",
+ "before": "background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 90%, transparent);",
+ "after": "background: var(--deepviewer-sidebar-material);"
+ },
+ {
+ "id": "deepviewer-boot-sidebar-material",
+ "file": "packages/client/web/src/boot-page.module.css",
+ "before": ".card {",
+ "after": "/* Shared DeepViewer material: available before React/theme plugins load.\n The native window already supplies sidebar vibrancy; these are translucent tints. */\n:global(html[data-platform='darwin'] body) {\n --deepviewer-sidebar-material:\n linear-gradient(\n to bottom,\n rgb(122 155 240 / 0.1),\n rgb(122 155 240 / 0) 35%,\n rgb(143 137 184 / 0) 68%,\n rgb(143 137 184 / 0.09)\n ),\n color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill, #f3f4f6) 97%, rgb(122 155 240)) 40%, transparent);\n}\n:global(html[data-platform='darwin'] body[data-ds-dark-theme]) {\n --deepviewer-sidebar-material:\n linear-gradient(\n to bottom,\n rgb(122 155 240 / 0.08),\n rgb(122 155 240 / 0) 35%,\n rgb(143 137 184 / 0) 68%,\n rgb(143 137 184 / 0.07)\n ),\n color-mix(in srgb, var(--dsw-specific-sidebar-fill, #202124) 50%, transparent);\n}\n@media (prefers-reduced-transparency: reduce) {\n :global(html[data-platform='darwin'] body) {\n --deepviewer-sidebar-material: color-mix(in srgb, var(--dsw-specific-sidebar-fill, #f3f4f6) 90%, transparent);\n }\n :global(html[data-platform='darwin'] body[data-ds-dark-theme]) {\n --deepviewer-sidebar-material: color-mix(in srgb, var(--dsw-specific-sidebar-fill, #202124) 90%, transparent);\n }\n}\n:global(html[data-platform='darwin']) .boot {\n background: var(--deepviewer-sidebar-material);\n}\n\n.card {"
+ },
+ {
+ "id": "deepviewer-general-remove-version-import",
+ "file": "packages/client/ui-settings-general/src/client/index.ts",
+ "before": "import { CurrentVersionRow } from './CurrentVersionRow.tsx'",
+ "after": "// DeepViewer version information is owned by the About page."
+ },
+ {
+ "id": "deepviewer-general-remove-version-row",
+ "file": "packages/client/ui-settings-general/src/client/index.ts",
+ "before": " // Version information follows the core preferences.\n ctx.slots.inject('settings.general.item', () => ctx.slots.register({\n name: 'settings.general.item', id: 'current-version', order: 100, locale: NS,\n }, CurrentVersionRow))",
+ "after": " // The duplicate upstream version row is not registered in DeepViewer."
+ },
+ {
+ "id": "deepviewer-synapse-view-owner-navigation",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "beforeFile": "Synapse-view-owner-navigation.before.txt",
+ "afterFile": "Synapse-view-owner-navigation.after.txt",
+ "previousAfter": " /** Open a registered View on the addressed Session through native navigation. */\n openSessionView: (sessionId: SessionId, view: string, focus: string) => Promise
\n /** Acknowledge the current one-shot focus request. */"
+ },
+ {
+ "id": "deepviewer-synapse-session-navigation-injected",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "beforeFile": "Synapse-session-navigation-injected.before.txt",
+ "afterFile": "Synapse-session-navigation-injected.after.txt",
+ "previousAfter": " /** Address the native target Session's view store, never the caller's store. */\n openSessionView: (sessionId: SessionId, view: string, focus: string) => Promise\n /** Bind input draft persistence to the Session-owned store instance. */"
+ },
+ {
+ "id": "deepviewer-synapse-selected-view-standard",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "beforeFile": "Synapse-selected-view-standard.before.txt",
+ "afterFile": "Synapse-selected-view-standard.after.txt",
+ "previousAfter": " interface SessionStandardProps {\n /** Native selected view; changes with persisted view actions and restoration. */\n useConversationView: SnapshotSelectorHook\n /** Selector hook over target-neutral Conversation assembly. */"
+ },
+ {
+ "id": "deepviewer-synapse-selected-view-maybe-standard",
+ "file": "packages/client/ui-conversation/src/client/contract/slots.ts",
+ "beforeFile": "Synapse-selected-view-maybe-standard.before.txt",
+ "afterFile": "Synapse-selected-view-maybe-standard.after.txt",
+ "previousAfter": " interface SessionMaybeStandardProps {\n /** Native selected view; absent before a Session is selected. */\n useConversationView: MaybeSnapshotSelectorHook\n /** Selector hook whose values are absent without a current Session. */"
+ },
+ {
+ "id": "deepviewer-synapse-target-native-navigation",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-target-native-navigation.before.txt",
+ "afterFile": "Synapse-target-native-navigation.after.txt",
+ "previousAfter": " // DeepViewer target navigation uses the renderer-owned BoundActions of the exact\n // Session generation. It never manufactures a second persistent store.\n const selectedViews = new Map>>()\n const sessionViewActions = new Map }>()\n let pendingView: { sessionId: SessionId; view: string; focus: string; resolve: () => void; reject: (error: Error) => void; timer: ReturnType } | undefined\n const selectedView = (sessionId: SessionId) => {\n let source = selectedViews.get(sessionId)\n if (source === undefined) {\n source = createSnapshotStore(readConversationViewPreference(sessionId))\n selectedViews.set(sessionId, source)\n const binding = sessions.binding(sessionId)\n binding?.ctx.effect(() => () => {\n if (selectedViews.get(sessionId) === source) selectedViews.delete(sessionId)\n if (sessionViewActions.get(sessionId)?.binding === binding) sessionViewActions.delete(sessionId)\n if (pendingView?.sessionId === sessionId) {\n const pending = pendingView; pendingView = undefined; clearTimeout(pending.timer)\n pending.reject(new Error('Conversation target was released'))\n }\n }, 'DeepViewer selected conversation view')\n }\n return source\n }\n const restoreView = (sessionId: SessionId): void => {\n const preferred = pendingView?.sessionId === sessionId ? pendingView.view : readConversationViewPreference(sessionId)\n activateView(sessionId, preferred)\n selectedView(sessionId).set(preferred)\n }\n const applyPendingView = (): void => {\n const pending = pendingView\n if (pending === undefined) return\n const target = sessionViewActions.get(pending.sessionId)\n if (target === undefined || sessions.binding(pending.sessionId) !== target.binding) return\n pendingView = undefined; clearTimeout(pending.timer)\n activateView(pending.sessionId, pending.view)\n target.actions.openView(pending.view, pending.focus)\n selectedView(pending.sessionId).set(pending.view)\n pending.resolve()\n }\n const bindSessionViewActions = (sessionId: SessionId, actions: BoundActions): void => {\n const binding = sessions.binding(sessionId)\n if (binding === undefined) throw new Error('Conversation target has no native binding')\n sessionViewActions.set(sessionId, { binding, actions })\n queueMicrotask(applyPendingView)\n }\n const openSessionView = (targetId: SessionId, view: string, focus: string): Promise => {\n if (!viewTabs().some(tab => tab.id === view)) return Promise.reject(new Error('Unknown conversation view'))\n if (pendingView !== undefined) {\n const previous = pendingView; pendingView = undefined; clearTimeout(previous.timer)\n previous.reject(new Error('Conversation navigation was superseded'))\n }\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n if (pendingView?.timer !== timer) return\n pendingView = undefined\n reject(new Error('Conversation target did not become available'))\n }, 8_000)\n pendingView = { sessionId: targetId, view, focus, resolve, reject, timer }\n try {\n // Native navigation synchronously retains the target as mainView and\n // selects it before the target store acknowledges its focus request.\n workspaceNavigation.openSession(targetId)\n if (sessions.binding(targetId) === undefined) throw new Error('Conversation target has no native binding')\n selectedView(targetId).set(view)\n applyPendingView()\n } catch (error: unknown) {\n if (pendingView?.timer === timer) { pendingView = undefined; clearTimeout(timer) }\n reject(error instanceof Error ? error : new Error(String(error)))\n }\n })\n }\n ctx.effect(() => () => {\n if (pendingView === undefined) return\n const pending = pendingView; pendingView = undefined; clearTimeout(pending.timer)\n pending.reject(new Error('Conversation navigation was disposed'))\n }, 'DeepViewer native conversation navigation')"
+ },
+ {
+ "id": "deepviewer-synapse-selected-view-roster",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-selected-view-roster.before.txt",
+ "afterFile": "Synapse-selected-view-roster.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-selected-view-source",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-selected-view-source.before.txt",
+ "afterFile": "Synapse-selected-view-source.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-bind-body-target-actions",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-bind-body-target-actions.before.txt",
+ "afterFile": "Synapse-bind-body-target-actions.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-share-target-navigation",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-share-target-navigation.before.txt",
+ "afterFile": "Synapse-share-target-navigation.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-bind-header-target-actions",
+ "file": "packages/client/ui-conversation/src/client/apply.ts",
+ "beforeFile": "Synapse-bind-header-target-actions.before.txt",
+ "afterFile": "Synapse-bind-header-target-actions.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-view-navigation-owner",
+ "file": "packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx",
+ "beforeFile": "Synapse-view-navigation-owner.before.txt",
+ "afterFile": "Synapse-view-navigation-owner.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-blank-view-area",
+ "file": "packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx",
+ "beforeFile": "Synapse-blank-view-area.before.txt",
+ "afterFile": "Synapse-blank-view-area.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-render-target-navigation",
+ "file": "packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx",
+ "beforeFile": "Synapse-render-target-navigation.before.txt",
+ "afterFile": "Synapse-render-target-navigation.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-main-selected-view",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx",
+ "beforeFile": "Synapse-main-selected-view.before.txt",
+ "afterFile": "Synapse-main-selected-view.after.txt",
+ "previousAfter": " const { sessionId, useSession, useSessions, useConversation, useConversationView, renderSlot, renderFactorySlot } = props\n const mapActive = useConversationView(view => view === 'deepviewer-map') === true"
+ },
+ {
+ "id": "deepviewer-synapse-main-map-no-hero",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx",
+ "beforeFile": "Synapse-main-map-no-hero.before.txt",
+ "afterFile": "Synapse-main-map-no-hero.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-blank-map-tabs",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx",
+ "beforeFile": "Synapse-blank-map-tabs.before.txt",
+ "afterFile": "Synapse-blank-map-tabs.after.txt",
+ "previousAfter": " const showTabs = tabs.length > 1\n const visibleTabs = hideChrome ? tabs.filter(tab => tab.id === 'chat' || tab.id === 'deepviewer-map') : tabs"
+ },
+ {
+ "id": "deepviewer-synapse-composer-flow-marker",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx",
+ "beforeFile": "Synapse-composer-flow-marker.before.txt",
+ "afterFile": "Synapse-composer-flow-marker.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-chat-focus-react-import",
+ "file": "packages/client/ui-chat/src/client/chat/ChatView.tsx",
+ "beforeFile": "Synapse-chat-focus-react-import.before.txt",
+ "afterFile": "Synapse-chat-focus-react-import.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-chat-focus-seq-import",
+ "file": "packages/client/ui-chat/src/client/chat/ChatView.tsx",
+ "beforeFile": "Synapse-chat-focus-seq-import.before.txt",
+ "afterFile": "Synapse-chat-focus-seq-import.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-chat-focus-owner",
+ "file": "packages/client/ui-chat/src/client/chat/ChatView.tsx",
+ "beforeFile": "Synapse-chat-focus-owner.before.txt",
+ "afterFile": "Synapse-chat-focus-owner.after.txt"
+ },
+ {
+ "id": "deepviewer-synapse-chat-native-seq-focus",
+ "file": "packages/client/ui-chat/src/client/chat/ChatView.tsx",
+ "beforeFile": "Synapse-chat-native-seq-focus.before.txt",
+ "afterFile": "Synapse-chat-native-seq-focus.after.txt",
+ "previousAfter": " // DeepViewer map focus is a native history + turn navigation request. The\n // existing rail and scroll owner retain paging, reading, and cancellation.\n const [sequenceFocus, setSequenceFocus] = useState<{ request: NonNullable; seq: number } | null>(null)\n const attemptedFocus = useRef(null)\n useEffect(() => {\n if (viewRequest?.view !== 'chat' || openState !== 'open' || !scroll.initialized || attemptedFocus.current === viewRequest) return\n const match = /^seq:(0|[1-9]\\d*)$/.exec(viewRequest.focus)\n if (match === null) return\n const seq = Number(match[1])\n if (!Number.isSafeInteger(seq)) { completeViewRequest(); return }\n attemptedFocus.current = viewRequest\n let live = true\n void loadThrough(SessionSeq(seq)).then(() => {\n if (live) setSequenceFocus({ request: viewRequest, seq })\n }, () => { if (live) completeViewRequest() })\n return () => { live = false }\n }, [viewRequest, openState, scroll.initialized, loadThrough, completeViewRequest])\n useLayoutEffect(() => {\n if (sequenceFocus === null || sequenceFocus.request !== viewRequest || !scroll.initialized) return\n const { seq } = sequenceFocus\n const located = nodeStore.values().filter(node => node.anchorSeq <= seq\n && (node.location.kind === 'turn' || node.location.kind === 'step'))\n .sort((left, right) => right.anchorSeq - left.anchorSeq)[0]\n const location = located?.location\n let turn = location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined\n if (turn === undefined && Array.isArray(turnOutline)) {\n const outline = (turnOutline as readonly { seq?: unknown; turn?: unknown }[])\n .filter(item => typeof item.seq === 'number' && item.seq <= seq && typeof item.turn === 'number')\n .sort((left, right) => Number(right.seq) - Number(left.seq))[0]\n if (typeof outline?.turn === 'number') turn = outline.turn\n }\n const item = railItems.find(candidate => candidate.turn === turn)\n if (item === undefined) return\n scroll.navigateToTurn(item)\n completeViewRequest()\n setSequenceFocus(null)\n }, [sequenceFocus, viewRequest, scroll.initialized, scroll.navigateToTurn, nodeStore, order, turnOutline, railItems, completeViewRequest])\n useEffect(() => {\n if (sequenceFocus === null || sequenceFocus.request !== viewRequest) return\n const timer = setTimeout(() => {\n completeViewRequest(); setSequenceFocus(null)\n }, 5_000)\n return () => { clearTimeout(timer) }\n }, [sequenceFocus, viewRequest, completeViewRequest])\n\n return (\n "
+ },
+ {
+ "id": "deepviewer-synapse-map-ignore-blank-settling",
+ "file": "packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx",
+ "beforeFile": "Synapse-map-ignore-blank-settling.before.txt",
+ "afterFile": "Synapse-map-ignore-blank-settling.after.txt"
+ }
+]
diff --git a/apps/deepviewer-adapter/presentation/contracts.mjs b/apps/deepviewer-adapter/presentation/contracts.mjs
new file mode 100644
index 0000000..fa89fc5
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/contracts.mjs
@@ -0,0 +1,9 @@
+import { readFileSync } from 'node:fs'
+const read = name => readFileSync(new URL(name, import.meta.url), 'utf8')
+// Presentation only. Native controllers, owner stores and modal lifetimes stay upstream.
+export const presentationContracts = JSON.parse(read('contracts.json')).map(({ beforeFile, afterFile, ...contract }) => ({
+ ...contract,
+ before: beforeFile ? read(beforeFile) : contract.before,
+ after: afterFile ? read(afterFile) : contract.after,
+}))
+export const presentationStyles = JSON.parse(read('styles.json')).map(style => ({ ...style, content: read(style.source) }))
diff --git a/apps/deepviewer-adapter/presentation/smoke-renderer.js.txt b/apps/deepviewer-adapter/presentation/smoke-renderer.js.txt
new file mode 100644
index 0000000..12e1879
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/smoke-renderer.js.txt
@@ -0,0 +1,135 @@
+(async () => {
+ const wait = async test => { for (let i = 0; i < 500; i++) { const value = test(); if (value) return value; await new Promise(r => setTimeout(r, 30)); } throw new Error('Settings smoke timed out: '+test.toString()); };
+ // The shell mounts before the first session and composer finish initializing.
+ await wait(() => document.querySelector('[contenteditable="true"]'));
+ await new Promise(resolve => {
+ let quiet; const done = () => { clearTimeout(quiet); clearTimeout(deadline); observer.disconnect(); resolve(); };
+ const observer = new MutationObserver(() => { clearTimeout(quiet); quiet = setTimeout(done, 750); });
+ const deadline = setTimeout(done, 5000);
+ observer.observe(document.body, {childList:true, subtree:true});
+ quiet = setTimeout(done, 750);
+ });
+ const fontResponse = await fetch('/assets/deepviewer/fonts/SmileySans-Oblique.woff2');
+ if (!fontResponse.ok) throw new Error('Bundled Smiley Sans is unavailable');
+ const fontBytes = new Uint8Array(await fontResponse.arrayBuffer());
+ if (String.fromCharCode(...fontBytes.slice(0, 4)) !== 'wOF2') throw new Error('Bundled font is not WOFF2');
+ const faces = await document.fonts.load('24px "Smiley Sans"', '让我们做点什么');
+ if (faces.length !== 1 || faces[0].status !== 'loaded') throw new Error('Bundled Smiley Sans did not load');
+ if (!(await (await fetch('/assets/deepviewer/fonts/OFL-SmileySans.txt')).text()).includes('SIL OPEN FONT LICENSE')) throw new Error('Bundled font license missing');
+ const heroMark = await wait(() => document.querySelector('[data-deepviewer-brand-mark]'));
+ const hero = heroMark.closest('[data-deepviewer-hero]');
+ if (!hero || !/让我们做点什么|What shall we build\?/.test(hero.textContent) || hero.querySelector('canvas')) throw new Error('DeepViewer welcome branding is missing');
+ const markStyle = getComputedStyle(heroMark);
+ if (!markStyle.maskImage.includes('/assets/deepviewer/deepviewer-loading-logo.svg') || heroMark.getBoundingClientRect().height < 100) throw new Error('Original DeepViewer mark is not rendered');
+ const markResponse = await fetch('/assets/deepviewer/deepviewer-loading-logo.svg');
+ if (!markResponse.ok || !(await markResponse.text()).includes('viewBox="0 0 150.374 160"')) throw new Error('Original DeepViewer mark asset is missing');
+ // Synthetic status checks live content-size variables without running a model.
+ const probe = document.createElement('div'); probe.setAttribute('data-chat-running', '');
+ const normal = document.createElement('span'); normal.style.color = 'var(--dsw-alias-label-secondary)';
+ document.body.append(probe, normal);
+ try {
+ for (const size of [14, 18, 22]) {
+ probe.style.setProperty('--dsh-content-font-size', size + 'px');
+ if (getComputedStyle(probe).fontSize !== size + 'px') throw new Error('Running status ignores content font size');
+ }
+ if (getComputedStyle(probe).color !== getComputedStyle(normal).color) throw new Error('Running status does not use the secondary foreground');
+ } finally { probe.remove(); normal.remove(); }
+ // Exercise the framework-free boot surface after mount, using its actual CSS module.
+ const bootRule = [...document.styleSheets].flatMap(sheet => { try { return [...sheet.cssRules]; } catch { return []; } })
+ .find(rule => rule.style?.getPropertyValue('--dsh-boot-label-primary'));
+ if (!bootRule) throw new Error('Boot material stylesheet missing');
+ const bootProbe = document.createElement('div');
+ bootProbe.className = bootRule.selectorText.match(/\.([\w-]+)/)?.[1] || '';
+ document.body.append(bootProbe);
+ try {
+ const style = getComputedStyle(bootProbe);
+ if (!style.getPropertyValue('--deepviewer-sidebar-material').trim() || (!matchMedia('(prefers-reduced-transparency: reduce)').matches && style.backgroundImage === 'none')) throw new Error('Boot does not share sidebar material');
+ const alpha = style.backgroundColor.match(/\/\s*([\d.]+)\)/)?.[1] ?? style.backgroundColor.match(/rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/)?.[1];
+ if (alpha === undefined || Number(alpha) >= 1) throw new Error('Boot background blocks native vibrancy: '+style.backgroundColor);
+ } finally { bootProbe.remove(); }
+ const sidebar = document.querySelector('[data-deepviewer-sidebar]');
+ if (!sidebar) throw new Error('Sidebar missing');
+ const mode = sidebar.querySelector('[role="group"]');
+ const newSession = [...sidebar.querySelectorAll('button')].find(b => /新建会话|New Session|New session/.test(b.getAttribute('aria-label') || ''));
+ if (!mode || !newSession || !(mode.compareDocumentPosition(newSession) & Node.DOCUMENT_POSITION_FOLLOWING)) throw new Error('Mode switch must precede new session');
+ const trigger = [...sidebar.querySelectorAll('button')].find(b => /^(设置|Settings)$/.test(b.getAttribute('aria-label') || b.textContent));
+ if (!trigger) throw new Error('Settings trigger missing');
+ trigger.focus(); trigger.click();
+ const panel = await wait(() => document.querySelector('[data-deepviewer-settings-page]'));
+ const rect = panel.getBoundingClientRect();
+ if (Math.abs(rect.width-innerWidth)>1 || Math.abs(rect.height-innerHeight)>1) throw new Error('Settings must cover viewport');
+ const nav = panel.querySelector('nav');
+ const buttons = [...nav.querySelectorAll('button')];
+ if (panel.querySelector('[data-slot-id="current-version"]') || /当前版本:|Current version:/.test(panel.textContent)) throw new Error('Duplicate General version row remains');
+ const about = buttons.find(b => /关于 DeepViewer|About DeepViewer/.test(b.textContent));
+ if (!about || buttons.at(-1) !== about) throw new Error('About footer missing');
+ const models = buttons.find(b => /模型|Models/.test(b.textContent));
+ if (!models) throw new Error('Models entry missing');
+ models.click();
+ await wait(() => panel.querySelector('[data-slot="settings.section"][data-slot-id="subscriptions"]') || [...panel.querySelectorAll('h2')].find(h => /订阅|Subscriptions/.test(h.textContent)));
+ about.click();
+ await wait(() => panel.querySelector('[data-deepviewer-about]'));
+ document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape',bubbles:true}));
+ await wait(() => !document.querySelector('[data-deepviewer-settings-page]'));
+ await wait(() => document.activeElement === trigger);
+ // Use an isolated blank native session: map must share the existing composer.
+ newSession.click();
+ const mapTab = await wait(() => [...document.querySelectorAll('[role="tab"]')].find(tab => /^(会话地图|Session map)$/.test(tab.textContent.trim())));
+ mapTab.click();
+ const nativeMap = await wait(() => document.querySelector('iframe[data-deepviewer-map]'));
+ await wait(() => nativeMap.contentDocument?.querySelector('.map-toolbar'));
+ if (document.querySelector('[data-deepviewer-hero]')) throw new Error('Blank map still renders the welcome hero');
+ const composers = document.querySelectorAll('[data-composer-seat]');
+ if (composers.length !== 1 || nativeMap.contentDocument.querySelector('textarea, [contenteditable="true"], .sidebar')) throw new Error('Map duplicates the native sidebar or composer');
+ const mapRect = nativeMap.getBoundingClientRect(), composerRect = composers[0].getBoundingClientRect();
+ if (mapRect.height < 80 || mapRect.bottom > composerRect.top + 1 || composerRect.bottom > innerHeight + 1) throw new Error('Map and native composer overlap: '+JSON.stringify({map:mapRect.toJSON(),composer:composerRect.toJSON(),height:innerHeight}));
+ const nativeTabs = mapTab.closest('[role="tablist"]');
+ if (!nativeTabs || nativeTabs.scrollHeight > nativeTabs.clientHeight) throw new Error('Native map tabs have vertical overflow');
+ // Exercise the real adapter layout bridge and native CSS using an isolated inspector.
+ const mapContent = nativeMap.closest('[data-conversation-content]');
+ await wait(() => nativeMap.contentDocument.querySelector('.main-stage > .empty-canvas, .main-stage > .canvas-view'));
+ await new Promise(resolve => setTimeout(resolve, 150));
+ const stage = nativeMap.contentDocument.querySelector('.main-stage');
+ const empty = stage.querySelector(':scope > .empty-canvas, :scope > .canvas-view');
+ const split = nativeMap.contentDocument.createElement('section'); split.className = 'canvas-view';
+ split.innerHTML = '
';
+ empty.replaceWith(split);
+ const reportLayout = width => window.dispatchEvent(new MessageEvent('message', {source:nativeMap.contentWindow,origin:location.origin,data:{source:'dsh-synapse',type:'synapse:layout',inspectorWidth:width}}));
+ const inspector = split.querySelector('.card-inspector');
+ if (nativeMap.clientWidth <= 640) throw new Error('Inspector fixture needs a wide native window');
+ reportLayout(inspector.getBoundingClientRect().width);
+ await wait(() => mapContent.hasAttribute('data-deepviewer-map-inspector'));
+ await wait(() => parseFloat(nativeMap.contentDocument.documentElement.style.getPropertyValue('--map-composer-height')) > 0);
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const fullMapRect = nativeMap.getBoundingClientRect(), seatRect = composers[0].getBoundingClientRect(), contentRect = mapContent.getBoundingClientRect();
+ const inspectorRect = inspector.getBoundingClientRect(), viewportRect = split.querySelector('.canvas-viewport').getBoundingClientRect();
+ if (Math.abs(fullMapRect.bottom-contentRect.bottom)>1 || Math.abs(inspectorRect.bottom-nativeMap.clientHeight)>1 || nativeMap.contentWindow.getComputedStyle(inspector).borderLeftWidth==='0px') throw new Error('Inspector divider does not reach native content floor: '+JSON.stringify({map:fullMapRect.toJSON(),content:contentRect.toJSON(),inspector:inspectorRect.toJSON(),frameHeight:nativeMap.clientHeight,border:nativeMap.contentWindow.getComputedStyle(inspector).borderLeftWidth,scroll:nativeMap.closest('[data-conversation-scroll]').getBoundingClientRect().toJSON()}));
+ if (seatRect.right > fullMapRect.left + inspectorRect.left + 1 || fullMapRect.top + viewportRect.bottom > seatRect.top + 1) throw new Error('Inspector/native composer/graph overlap');
+ reportLayout(0);
+ await wait(() => !mapContent.hasAttribute('data-deepviewer-map-inspector'));
+ split.replaceWith(empty);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ if (nativeMap.getBoundingClientRect().bottom > composers[0].getBoundingClientRect().top + 1 || mapContent.style.getPropertyValue('--deepviewer-map-inspector-width')) throw new Error('Closing inspector did not restore native composer flow');
+ const chatTab = [...document.querySelectorAll('[role="tab"]')].find(tab => /^(对话|Chat)$/.test(tab.textContent.trim()));
+ if (!chatTab) throw new Error('Native chat tab missing');
+ chatTab.click();
+ await wait(() => !document.querySelector('iframe[data-deepviewer-map]'));
+ const mapProbe = document.createElement('iframe');
+ mapProbe.src = '/assets/deepviewer/synapse/index.html';
+ mapProbe.style.cssText = 'position:fixed;width:900px;height:600px;left:-10000px;top:0';
+ let mapRequests = 0;
+ const mapMessage = event => {
+ if (event.source !== mapProbe.contentWindow || event.origin !== location.origin || event.data?.source !== 'dsh-synapse') return;
+ const send = (type, payload) => mapProbe.contentWindow.postMessage({source:'dsh-synapse',type,...payload},location.origin);
+ if (event.data.type === 'synapse:api') { mapRequests++; send('synapse:api-result',{requestId:event.data.requestId,value:{workspaces:[]}}); }
+ if (event.data.type === 'synapse:request-current') { send('synapse:theme',{dark:true}); send('synapse:workspaces',{workspaces:[]}); send('synapse:current-session',{session:null}); }
+ };
+ window.addEventListener('message',mapMessage); document.body.append(mapProbe);
+ try {
+ await wait(() => mapRequests > 0 && mapProbe.contentDocument?.querySelector('.map-toolbar') && mapProbe.contentDocument?.querySelector('.empty-canvas'));
+ await wait(() => mapProbe.contentDocument.documentElement.dataset.theme === 'dark');
+ const content = mapProbe.contentDocument.querySelector('#app').textContent;
+ if (/Refused|请求失败|未在规定时间/.test(content)) throw new Error('Synapse iframe failed to boot');
+ } finally { window.removeEventListener('message',mapMessage); mapProbe.remove(); }
+ return { synapseCanvas:true, blankMap:true, oneNativeComposer:true, mapComposerGeometry:true, mapTabsNoVerticalOverflow:true, fullHeightInspector:true, inspectorComposerSeparation:true, inspectorLayoutCleanup:true, bootSidebarMaterial:true, bundledSmileyFont:true, runningTypography:true, fullWindowSettings:true, aboutFooter:true, combinedModels:true, escapeRestoresFocus:true, sidebarModeOrder:true };
+})()
diff --git a/apps/deepviewer-adapter/presentation/styles.json b/apps/deepviewer-adapter/presentation/styles.json
new file mode 100644
index 0000000..569ba59
--- /dev/null
+++ b/apps/deepviewer-adapter/presentation/styles.json
@@ -0,0 +1,32 @@
+[
+ {
+ "id": "deepviewer-presentation-SidebarRoot",
+ "file": "packages/client/ui-sidebar/src/client/SidebarRoot.module.css",
+ "sha256": "798a151927c143ae9b7c2e52090cb7d2a4ed967e5701e7fdd2b4bb4a330d3f82",
+ "source": "SidebarRoot.module.css"
+ },
+ {
+ "id": "deepviewer-presentation-Rows",
+ "file": "packages/client/ui-workspace/src/client/rows/Rows.module.css",
+ "sha256": "a9cd15eb625c5e01680de5ad770c573338bc9281ddea5dca9e53f5957356b2b8",
+ "source": "Rows.module.css"
+ },
+ {
+ "id": "deepviewer-presentation-WorkspaceBrowser",
+ "file": "packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css",
+ "sha256": "208fe8d00ef6e4285bd41bc979bc2362895ab2d2026e82ebd255c0bce680fbde",
+ "source": "WorkspaceBrowser.module.css"
+ },
+ {
+ "id": "deepviewer-presentation-WorkspacePicker",
+ "file": "packages/client/ui-workspace/src/client/WorkspacePicker.module.css",
+ "sha256": "8b9252a27e4163be3c9b32b2ec30a750477e83d09b5d2a05af3346b628503db4",
+ "source": "WorkspacePicker.module.css"
+ },
+ {
+ "id": "deepviewer-presentation-SettingsRoot",
+ "file": "packages/client/ui-settings-general/src/client/SettingsRoot.module.css",
+ "sha256": "886528a854b668db25c60cbc46ad982687142fcb02fda1f533dcbf491dc73513",
+ "source": "SettingsRoot.module.css"
+ }
+]
diff --git a/apps/deepviewer-adapter/scripts/prepare-upstream.mjs b/apps/deepviewer-adapter/scripts/prepare-upstream.mjs
new file mode 100644
index 0000000..cc1e355
--- /dev/null
+++ b/apps/deepviewer-adapter/scripts/prepare-upstream.mjs
@@ -0,0 +1,55 @@
+import { createHash } from 'node:crypto'
+import { presentationStyles } from '../presentation/contracts.mjs'
+import { execFileSync } from 'node:child_process'
+import { cpSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { contracts } from '../compatibility/contracts.mjs'
+
+export const harnessVersion = '0.2.0-rc.2'
+export const harnessCommit = '639ed015397290b3745d163aafe02ffee4aa3f84'
+export const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+export const upstreamRoot = resolve(projectRoot, 'upstream/deepseek-harness')
+
+export function prepareUpstream(root = upstreamRoot) {
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
+ if (head !== harnessCommit || JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version !== harnessVersion) {
+ throw new Error(`DeepViewer requires ${harnessVersion} at ${harnessCommit}; no source was replaced`)
+ }
+ // Validate every anchor before writing any source. Unknown edits never trigger a reset.
+ const files = new Map()
+ for (const contract of contracts) {
+ const path = resolve(root, contract.file)
+ let source = files.get(path) ?? readFileSync(path, 'utf8')
+ // Retire the Build 25 mode position before applying the new presentation seam.
+ if (contract.id === 'deepviewer-mode-29') source = source.replace(
+ " {renderSlot('sidebar.mode', { wide, expandSidebar: () => { if (collapsed) toggleSidebar() } })}\n
",
+ '
')
+ const expected = contract.count ?? 1
+ if (contract.previousAfter && source.split(contract.previousAfter).length - 1 === expected) source = source.replaceAll(contract.previousAfter, contract.before)
+ if (source.includes(contract.after) && !source.includes(contract.before)) { files.set(path, source); continue }
+ // Some insertions contain the original anchor; their exact full replacement is the idempotent contract.
+ if (source.includes(contract.after) && contract.after.includes(contract.before)) { files.set(path, source); continue }
+ if (source.split(contract.before).length - 1 !== expected) throw new Error(`Upstream contract changed: ${contract.id}`)
+ files.set(path, source.replaceAll(contract.before, contract.after))
+ }
+ for (const style of presentationStyles) {
+ const path = resolve(root, style.file)
+ const source = files.get(path) ?? readFileSync(path, 'utf8')
+ if (source !== style.content && createHash('sha256').update(source).digest('hex') !== style.sha256) {
+ throw new Error(`Upstream presentation changed: ${style.id}; review against the pinned source`)
+ }
+ files.set(path, style.content)
+ }
+ for (const [path, source] of files) writeFileSync(path, source)
+ writeFileSync(resolve(root, 'packages/bundle/web-app/deepviewer.patch.yml'),
+ readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8') + '\n' +
+ readFileSync(new URL('../native-modules.patch.yml', import.meta.url), 'utf8'))
+ writeFileSync(resolve(root, 'packages/web/web-fetch-http/src/desktop-network.ts'), readFileSync(new URL('../compatibility/files/desktop-network.ts', import.meta.url), 'utf8'))
+ writeFileSync(resolve(root, 'packages/api/session-controller/src/chat-attachments.ts'), readFileSync(new URL('../compatibility/files/chat-attachments.ts', import.meta.url), 'utf8'))
+ cpSync(new URL('../compatibility/tests/file-protection-tone.client.spec.tsx', import.meta.url), resolve(root, 'packages/client/ui-tool/tests/deepviewer-file-protection-tone.client.spec.tsx'))
+ cpSync(new URL('../compatibility/tests/rpc-owner.host.spec.ts', import.meta.url), resolve(root, 'packages/client/connection/tests/deepviewer-rpc-owner.host.spec.ts'))
+ cpSync(new URL('../compatibility/tests/client-artifact-metadata.host.spec.ts', import.meta.url), resolve(root, 'packages/client/hmr/tests/deepviewer-client-artifact-metadata.client.spec.ts'))
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) prepareUpstream()
diff --git a/apps/deepviewer-adapter/scripts/stage-native-modules.mjs b/apps/deepviewer-adapter/scripts/stage-native-modules.mjs
new file mode 100644
index 0000000..a525044
--- /dev/null
+++ b/apps/deepviewer-adapter/scripts/stage-native-modules.mjs
@@ -0,0 +1,30 @@
+import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+
+export const nativeModuleNames = ['@deepviewer/adapter', '@deepviewer/dsh-plugin-model-capabilities', 'dsh-plugin-subscriptions']
+/** Retain standalone source packaging, but ship integrated modules without installable bundle metadata. */
+export function stageNativeModules(upstream, cliTarget) {
+ const versions = {}
+ for (const name of nativeModuleNames) {
+ const path = join(upstream, 'node_modules', name, 'package.json')
+ const manifest = JSON.parse(readFileSync(path, 'utf8'))
+ if (manifest.name !== name || !manifest.dsh?.client) throw new Error(`Native module manifest mismatch: ${name}`)
+ delete manifest.dsh.bundle
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n')
+ versions[name] = manifest.version
+ }
+ // Generate the application-owned CLI package. Keep upstream's manifest/lockfile unchanged,
+ // so repeated bootstrap still uses its original frozen dependency installation.
+ const source = join(upstream, 'apps/cli')
+ const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8'))
+ manifest.dependencies = { ...manifest.dependencies, ...versions }
+ rmSync(cliTarget, { recursive: true, force: true }); mkdirSync(cliTarget, { recursive: true })
+ cpSync(join(source, 'lib'), join(cliTarget, 'lib'), { recursive: true })
+ writeFileSync(join(cliTarget, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
+ for (const name of Object.keys(manifest.dependencies)) {
+ const directory = nativeModuleNames.includes(name) ? join(upstream,'node_modules',name) : join(source,'node_modules',name)
+ if (!existsSync(join(directory,'package.json'))) throw new Error(`Missing native CLI dependency: ${name}`)
+ const link = join(cliTarget,'node_modules',name); mkdirSync(dirname(link),{recursive:true}); symlinkSync(directory,link,'dir')
+ }
+ return cliTarget
+}
diff --git a/apps/deepviewer-adapter/scripts/stage.mjs b/apps/deepviewer-adapter/scripts/stage.mjs
new file mode 100644
index 0000000..6c82480
--- /dev/null
+++ b/apps/deepviewer-adapter/scripts/stage.mjs
@@ -0,0 +1,58 @@
+import { cpSync, existsSync, globSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { productionGraph } from '../../deepviewer-desktop/scripts/native-package-closure.mjs'
+const source = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+export function stageAdapter(upstream) {
+ const target = join(upstream, 'node_modules', '@deepviewer', 'adapter')
+ mkdirSync(target, { recursive: true })
+ for (const dir of ['src', 'lib', 'assets']) rmSync(join(target, dir), { recursive: true, force: true })
+ mkdirSync(join(target, 'src'), { recursive: true })
+ cpSync(join(source, 'client'), join(target, 'src', 'client'), { recursive: true })
+ const product = JSON.parse(readFileSync(join(source, '../deepviewer-desktop/package.json'), 'utf8'))
+ writeFileSync(join(target, 'src/client/build-info.ts'), `export const buildInfo = ${JSON.stringify({ version: product.version, build: String(product.buildNumber), kernel: '0.2.0-rc.2' })}\n`)
+ cpSync(join(source, 'workflows'), join(target, 'src', 'workflows'), { recursive: true })
+ cpSync(join(source, 'index.ts'), join(target, 'src', 'index.ts'))
+ cpSync(join(source, 'assets'), join(target, 'assets'), { recursive: true })
+ for (const file of ['package.json', 'cordis.patch.yml', 'UPSTREAM-LICENSE']) cpSync(join(source, file), join(target, file))
+ const packages = new Map()
+ for (const file of globSync(['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'], { cwd: upstream })) {
+ const path = join(upstream, file), manifest = JSON.parse(readFileSync(path, 'utf8'))
+ packages.set(manifest.name, dirname(path))
+ }
+ for (const [name, directory] of packages) {
+ const link = join(target, 'node_modules', name)
+ mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(directory, link)
+ }
+ for (const [name, path] of [['react', 'packages/client/ui-renderer/node_modules/react'], ['@types/react', 'packages/client/ui-renderer/node_modules/@types/react'], ['@types/node', 'node_modules/@types/node']]) {
+ const link = join(target, 'node_modules', name)
+ mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(join(upstream, path), link)
+ }
+ for (const name of ['state', 'view', 'commands', 'search', 'language', 'lang-javascript', 'lang-json', 'lang-markdown', 'lang-python', 'lang-html', 'lang-css']) {
+ const link = join(target, 'node_modules/@codemirror', name)
+ mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(join(source, '../../node_modules/@codemirror', name), link)
+ }
+ const bundled = productionGraph(['state', 'view', 'commands', 'search', 'language', 'lang-javascript', 'lang-json', 'lang-markdown', 'lang-python', 'lang-html', 'lang-css'].map(name => join(source, '../../node_modules/@codemirror', name)))
+ const licenses = []
+ for (const record of bundled.records.values()) {
+ const paths = globSync(['LICENSE*', 'LICENCE*', 'COPYING*'], { cwd: record.id })
+ if (paths.length === 0) throw new Error(`Bundled dependency license missing: ${record.manifest.name}`)
+ licenses.push(`${record.manifest.name}@${record.manifest.version}\n${paths.map(path => readFileSync(join(record.id, path), 'utf8')).join('\n')}`)
+ }
+ writeFileSync(join(target, 'THIRD-PARTY-LICENSES.txt'), licenses.join('\n\n---\n\n'))
+ writeFileSync(join(target, 'src', 'client', 'index.ts'), "export { apply, inject } from './index.tsx'\n")
+ writeFileSync(join(target, 'src', 'client', 'assets.d.ts'), "declare module '*.module.css' { const css: Record
; export default css }\ndeclare module '*.css?inline' { const css: string; export default css }\n")
+ writeFileSync(join(target, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2023', module: 'ESNext', moduleResolution: 'bundler', jsx: 'react-jsx', strict: true, noEmit: true, allowImportingTsExtensions: true, skipLibCheck: true, types: ['node'], lib: ['ES2023', 'DOM', 'DOM.Iterable'] }, include: ['src/client/**/*'] }))
+ const typecheck = spawnSync(process.execPath, [join(upstream, 'node_modules/typescript/bin/tsc'), '-p', join(target, 'tsconfig.json')], { cwd: target, stdio: 'inherit' })
+ if (typecheck.status !== 0) throw new Error('DeepViewer adapter typecheck failed')
+ const hostConfig = JSON.parse(readFileSync(join(target, 'tsconfig.json'), 'utf8')); hostConfig.include = ['src/index.ts', 'src/workflows/**/*']; hostConfig.compilerOptions.lib = ['ES2023']; writeFileSync(join(target, 'tsconfig.host.json'), JSON.stringify(hostConfig))
+ const hostCheck = spawnSync(process.execPath, [join(upstream, 'node_modules/typescript/bin/tsc'), '-p', join(target, 'tsconfig.host.json')], { cwd: target, stdio: 'inherit' })
+ if (hostCheck.status !== 0) throw new Error('DeepViewer adapter host typecheck failed')
+ cpSync(join(source, 'workflows/vendor/BETTER-SIDEBAR-LICENSE'), join(target, 'BETTER-SIDEBAR-LICENSE'))
+ const config = join(target, 'tsdown.config.mjs')
+ writeFileSync(config, `import { clientBundle } from ${JSON.stringify(join(upstream, 'packages/client/tsdown.client.ts'))}\nexport default clientBundle('@deepviewer/adapter', ['src/index.ts', 'src/workflows/chat-guard.ts'])\n`)
+ const built = spawnSync(join(upstream, 'node_modules/.bin/tsdown'), ['--config', config], { cwd: target, stdio: 'inherit', env: { ...process.env, DSH_EXTERNAL_WORKSPACE_MANIFEST: join(target, 'package.json') } })
+ if (built.status !== 0 || !existsSync(join(target, 'lib/client.js'))) throw new Error('DeepViewer adapter bundle failed')
+ return target
+}
diff --git a/apps/deepviewer-adapter/tests/synapse-chat-focus.spec.tsx.txt b/apps/deepviewer-adapter/tests/synapse-chat-focus.spec.tsx.txt
new file mode 100644
index 0000000..b219764
--- /dev/null
+++ b/apps/deepviewer-adapter/tests/synapse-chat-focus.spec.tsx.txt
@@ -0,0 +1,27 @@
+// Appended to the native Chat regression harness by the bounded smoke runner.
+it('DeepViewer map native seq focus pages history and navigates the matching native turn', async () => {
+ const h = makeHarness({ nodes: [userInTurn(1, 'First question', 1), assistant(2, 'First answer', 1), userInTurn(4, 'Second question', 2), assistant(5, 'Second answer', 2)] })
+ const complete = vi.fn()
+ const view = render( )
+ const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
+ const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
+ const metrics = installScrollMetrics(scroller, 1_000, 300)
+ metrics.setLayout(1_000, 700)
+ vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect)
+ const firstRow = view.container.querySelector('[data-chat-flow-key="fixture:user:1"]') as HTMLElement
+ vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({ top: -500, bottom: -440 } as DOMRect)
+ view.rerender( )
+ await waitFor(() => { expect(h.loadThrough).toHaveBeenCalledWith(2); expect(complete).toHaveBeenCalled() })
+ expect(first.getAttribute('aria-current')).toBe('true')
+})
+
+it('DeepViewer map native seq focus ignores foreign view requests and rejects unsafe sequence ids', async () => {
+ const h = makeHarness({ nodes: [userInTurn(1, 'Question', 1), assistant(2, 'Answer', 1), userInTurn(4, 'Next question', 2), assistant(5, 'Next answer', 2)] })
+ const complete = vi.fn()
+ const view = render( )
+ await view.findByRole('button', { name: '跳转到第 1 轮' })
+ expect(h.loadThrough).not.toHaveBeenCalled()
+ view.rerender( )
+ await waitFor(() => { expect(complete).toHaveBeenCalled() })
+ expect(h.loadThrough).not.toHaveBeenCalled()
+})
diff --git a/apps/deepviewer-adapter/tests/synapse-client.spec.tsx.txt b/apps/deepviewer-adapter/tests/synapse-client.spec.tsx.txt
new file mode 100644
index 0000000..036a3f3
--- /dev/null
+++ b/apps/deepviewer-adapter/tests/synapse-client.spec.tsx.txt
@@ -0,0 +1,239 @@
+// @vitest-environment jsdom
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { registerSynapseView } from '../../../../node_modules/@deepviewer/adapter/src/client/SynapseView.tsx'
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); document.body.removeAttribute('style') })
+function source(initial: T) {
+ let value = initial
+ const listeners = new Set<() => void>()
+ return { getSnapshot: () => value, subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
+ set: (next: T) => { value = next; for (const listener of [...listeners]) listener() }, listeners }
+}
+function fixture() {
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1 })
+ const resizeObservers: { callback: () => void; elements: Set; disconnect: ReturnType }[] = []
+ vi.stubGlobal('ResizeObserver', class {
+ observer: typeof resizeObservers[number]
+ constructor(callback: () => void) { this.observer = { callback, elements: new Set(), disconnect: vi.fn(() => this.observer.elements.clear()) }; resizeObservers.push(this.observer) }
+ observe(element: Element) { this.observer.elements.add(element) }
+ disconnect() { this.observer.disconnect() }
+ })
+ let Component: any, registration: any
+ const rows: any = { parent: { displayTitle: 'Parent', cwd: '/fixture', running: false }, child: { displayTitle: 'Child', cwd: '/fixture', running: false },
+ running: { displayTitle: 'Running', running: true }, cold: { displayTitle: 'Cold', running: false } }
+ const list = source({ ids: Object.keys(rows), byId: rows }), counts = new Map()
+ const lifecycle = new Map>>(), chats = new Map>>()
+ const drafts = new Map; focus: ReturnType; notify: ReturnType; state: ReturnType> }>()
+ const loadThrough = vi.fn(async () => {}), prompt = vi.fn(async () => ({ ok: true })), fork = vi.fn(async () => 'child')
+ const retain = vi.fn((id: string, options: any) => {
+ expect(options.source).toBe('deepviewerMap'); counts.set(id, (counts.get(id) ?? 0) + 1)
+ if (!lifecycle.has(id)) lifecycle.set(id, source({ running: rows[id]?.running ?? false }))
+ if (!chats.has(id)) chats.set(id, source({ legacy: { partial: null, nodes: [] } }))
+ if (!drafts.has(id)) { const state = source({ draft: '' }); drafts.set(id, { state, setDraft: vi.fn((draft: string) => state.set({ draft })), focus: vi.fn(), notify: vi.fn() }) }
+ const session = { ...lifecycle.get(id)!, loadThrough, prompt }
+ const binding = { sessionId: id, session, ctx: { fixtureId: id } }
+ let released = false
+ return { sessionId: id, binding, ready: Promise.resolve(binding), release: () => { if (!released) { released = true; counts.set(id, counts.get(id)! - 1) } } }
+ })
+ const openSession = vi.fn(), openView = vi.fn()
+ const openSessionView = vi.fn(async (id: string, view: string, focus: string) => { openSession(id); openView(view, focus) })
+ let activeLocale = 'zh', fontSize = 16
+ const handlers = new Map void>>()
+ const ctx: any = {
+ locale: { bind: () => (key: string) => key, getLocale: () => ({ active: activeLocale }) },
+ slots: { inject: (_: unknown, callback: any) => callback(), register: (options: any, component: any) => { registration = options; Component = component } },
+ sessions: { list, fork, retain, binding: (id: string) => drafts.has(id) ? { ctx: { fixtureId: id } } : undefined, using: async (id: string, options: any, operation: any) => { const reference = retain(id, options); try { return await operation(reference) } finally { reference.release() } }, create: vi.fn(async () => 'child') },
+ workspaces: { list: source({ items: [{ workspaceId: 'workspace', sessionIds: ['parent', 'child'], title: 'Fixture', path: '/fixture' }] }) },
+ uiWorkspace: { openSession }, uiConversation: { binding: (binding: any) => ({ target: () => chats.get(binding.sessionId)! }) },
+ conversation: { input: { for: (scope: any) => drafts.get(scope.fixtureId)! } },
+ theme: { getTheme: () => ({ fontSize, active: { colorScheme: 'dark' } }) },
+ on: (name: string, listener: () => void) => { const listeners = handlers.get(name) ?? new Set(); handlers.set(name, listeners); listeners.add(listener); return () => { listeners.delete(listener) } },
+ get: () => ({ rpc: { call: vi.fn(async () => ({ ok: true, value: { workspaces: [] } })) } }),
+ }
+ registerSynapseView(ctx)
+ const view = render()
+ const frame = view.container.querySelector('iframe')!, frameWindow = frame.contentWindow!
+ const send = vi.spyOn(frameWindow, 'postMessage')
+ const flush = async () => { await act(async () => { for (let index = 0; index < 8; index++) await Promise.resolve() }) }
+ async function event(type: string, payload: any = {}, valid = true, origin = location.origin) {
+ await act(async () => { window.dispatchEvent(new MessageEvent('message', { origin, source: valid ? frameWindow : window, data: { source: 'dsh-synapse', type, ...payload } })); for (let index = 0; index < 12; index++) await Promise.resolve() })
+ }
+ return { ctx, view, frame, send, event, flush, registration, counts, chats, lifecycle, drafts, retain, prompt, fork, loadThrough, openSession, openView, openSessionView, list,
+ resizeObservers, changeAppearance: (locale: string, size: number) => { activeLocale = locale; fontSize = size; for (const name of ['locale/change', 'theme/change']) for (const handler of handlers.get(name) ?? []) handler() }, handlers }
+}
+
+it('canvas activation navigates the native session and keeps the map selected', async () => {
+ const f = fixture(); await f.flush()
+ expect(f.registration.id).toBe('deepviewer-map')
+ expect(f.frame.style.minHeight).toBe('0px')
+ await f.event('synapse:activate-session', { sessionId: 'child' })
+ expect(f.openSessionView).toHaveBeenCalledWith('child', 'deepviewer-map', '')
+ expect(f.openSession).toHaveBeenCalledWith('child')
+ await f.event('synapse:open-session', { sessionId: 'parent', seq: 42 })
+ expect(f.loadThrough).toHaveBeenCalledWith(42)
+ expect(f.openSessionView).toHaveBeenCalledWith('parent', 'chat', 'seq:42')
+ await f.event('synapse:open-session', { sessionId: 'parent', seq: -1 })
+ expect(f.loadThrough).toHaveBeenCalledTimes(1)
+})
+
+it('compose prepares the native draft and branch without sending a prompt', async () => {
+ const f = fixture(); await f.flush()
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, text: 'Follow this idea', requestId: 'compose' })
+ expect(f.fork).toHaveBeenCalledWith({ sessionId: 'parent', atSeq: 2, increaseTitle: true })
+ expect(f.drafts.get('child')!.setDraft).toHaveBeenCalledWith('Follow this idea')
+ expect(f.drafts.get('child')!.focus).toHaveBeenCalled()
+ expect(f.openSessionView).toHaveBeenCalledWith('child', 'deepviewer-map', '')
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:composed', requestId: 'compose', session: expect.objectContaining({ id: 'child' }) }), location.origin)
+ await f.event('synapse:send-message', { sessionId: 'parent', text: 'Legacy send forbidden' })
+ expect(f.prompt).not.toHaveBeenCalled()
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: -1 })
+ expect(f.fork).toHaveBeenCalledTimes(1)
+ await f.event('synapse:compose', { text: 'New draft', requestId: 'new' })
+ expect(f.ctx.sessions.create).toHaveBeenCalledWith({ workspaceId: 'workspace' })
+})
+
+it('streaming observes only selected and running sessions and releases every reference', async () => {
+ const f = fixture(); await f.flush()
+ expect(f.counts.get('parent')).toBe(1); expect(f.counts.get('running')).toBe(1); expect(f.counts.has('cold')).toBe(false)
+ await act(async () => { f.chats.get('running')!.set({ legacy: { nodes: [{ kind: 'user', seq: 7 }], partial: { blocks: [{ kind: 'reasoning', text: 'hidden' }, { kind: 'text', text: 'Live answer' }] } } }) })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:live-reply', sessionId: 'running', running: true, text: 'Live answer', userSeq: 7 }), location.origin)
+ await act(async () => { const current = f.list.getSnapshot(); f.list.set({ ...current, byId: { ...current.byId, running: { ...current.byId.running, running: false } } }); await Promise.resolve() })
+ expect(f.counts.get('running')).toBe(0)
+ f.view.unmount(); await f.flush()
+ for (const count of f.counts.values()) expect(count).toBe(0)
+ for (const chat of f.chats.values()) expect(chat.listeners.size).toBe(0)
+})
+
+it('presentation follows locale and font settings while commands enforce source and origin', async () => {
+ const f = fixture(); await f.flush()
+ document.body.style.setProperty('--dsw-alias-bg-base', '#121212')
+ await act(async () => { f.changeAppearance('en', 22); await Promise.resolve() })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:presentation', locale: 'en-US', fontSize: 22, fontFamily: expect.any(String), tokens: expect.objectContaining({ background: '#121212' }) }), location.origin)
+ await f.event('synapse:compose', { sessionId: 'parent', text: 'wrong frame' }, false)
+ await f.event('synapse:compose', { sessionId: 'parent', text: 'wrong origin' }, true, 'https://foreign.invalid')
+ expect(f.drafts.get('parent')!.setDraft).not.toHaveBeenCalled()
+ const calls = f.openSessionView.mock.calls.length
+ f.view.unmount()
+ await f.event('synapse:activate-session', { sessionId: 'child' })
+ expect(f.openSessionView).toHaveBeenCalledTimes(calls)
+ for (const listeners of f.handlers.values()) expect(listeners.size).toBe(0)
+})
+
+
+it('a slow historical jump cannot overwrite a later native session selection', async () => {
+ const f = fixture(); await f.flush()
+ let finish!: () => void
+ f.loadThrough.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
+ await f.event('synapse:open-session', { sessionId: 'parent', seq: 80 })
+ await f.event('synapse:activate-session', { sessionId: 'child' })
+ await act(async () => { finish(); for (let index = 0; index < 8; index++) await Promise.resolve() })
+ expect(f.openSessionView).toHaveBeenCalledTimes(1)
+ expect(f.openSessionView).toHaveBeenLastCalledWith('child', 'deepviewer-map', '')
+ f.openSessionView.mockRejectedValueOnce(new Error('navigation refused'))
+ await f.event('synapse:activate-session', { sessionId: 'parent' })
+ await f.event('synapse:request-current')
+ expect(f.send).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'synapse:map-opened' }), location.origin)
+ const current = f.send.mock.calls.filter(([message]: any) => message.type === 'synapse:current-session').at(-1)![0]
+ expect(current.session.id).toBe('child')
+})
+
+
+it('compose selection preserves an existing native draft and plain followup leaves it untouched', async () => {
+ const f = fixture(); await f.flush()
+ f.drafts.get('parent')!.state.set({ draft: 'Existing thought' })
+ await f.event('synapse:compose', { sessionId: 'parent', text: 'Quoted selection' })
+ expect(f.drafts.get('parent')!.setDraft).toHaveBeenLastCalledWith('Existing thought\n\nQuoted selection')
+ const calls = f.drafts.get('parent')!.setDraft.mock.calls.length
+ await f.event('synapse:compose', { sessionId: 'parent' })
+ expect(f.drafts.get('parent')!.setDraft).toHaveBeenCalledTimes(calls)
+ expect(f.prompt).not.toHaveBeenCalled()
+})
+
+
+it('streaming anchors to a human steering message and excludes newer injected context', async () => {
+ const f = fixture(); await f.flush()
+ await act(async () => { f.chats.get('running')!.set({ legacy: { nodes: [
+ { kind: 'user', seq: 7, source: { kind: 'user' } },
+ { kind: 'steering', seq: 11, source: { kind: 'user' } },
+ { kind: 'context', seq: 12, source: { kind: 'model-change' } },
+ ], partial: { blocks: [{ kind: 'text', text: 'Steered answer' }] } } }) })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:live-reply', sessionId: 'running', running: true, text: 'Steered answer', userSeq: 11 }), location.origin)
+})
+
+
+it('map layout coordinates the native composer and validates inspector geometry', async () => {
+ const f = fixture(); await f.flush()
+ const content = f.frame.closest('[data-conversation-content]')!
+ const composer = content.querySelector('[data-deepviewer-composer-seat]')!
+ Object.defineProperty(f.frame, 'clientWidth', { configurable: true, value: 900 })
+ Object.defineProperty(composer, 'offsetHeight', { configurable: true, value: 164 })
+ const geometry = f.resizeObservers[0]!
+ expect(geometry.elements.has(f.frame)).toBe(true); expect(geometry.elements.has(composer)).toBe(true)
+ await act(async () => { geometry.callback() })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:layout', composerHeight: 164 }), location.origin)
+ const count = f.send.mock.calls.filter(([message]: any) => message.type === 'synapse:layout').length
+ await act(async () => { geometry.callback() })
+ expect(f.send.mock.calls.filter(([message]: any) => message.type === 'synapse:layout')).toHaveLength(count)
+ await f.event('synapse:layout', { inspectorWidth: 360 })
+ expect(content.hasAttribute('data-deepviewer-map-inspector')).toBe(true)
+ expect(content.style.getPropertyValue('--deepviewer-map-inspector-width')).toBe('360px')
+ for (const width of [-1, NaN, Infinity, 901, '360']) await f.event('synapse:layout', { inspectorWidth: width })
+ await f.event('synapse:layout', { inspectorWidth: 250 }, false)
+ await f.event('synapse:layout', { inspectorWidth: 250 }, true, 'https://foreign.invalid')
+ expect(content.style.getPropertyValue('--deepviewer-map-inspector-width')).toBe('360px')
+ Object.defineProperty(f.frame, 'clientWidth', { configurable: true, value: 320 })
+ await act(async () => { geometry.callback() })
+ expect(content.hasAttribute('data-deepviewer-map-inspector')).toBe(false)
+ expect(content.style.getPropertyValue('--deepviewer-map-inspector-width')).toBe('')
+ Object.defineProperty(f.frame, 'clientWidth', { configurable: true, value: 900 })
+ await f.event('synapse:layout', { inspectorWidth: 320 })
+ await f.event('synapse:layout', { inspectorWidth: 0 })
+ expect(content.hasAttribute('data-deepviewer-map-inspector')).toBe(false)
+ await f.event('synapse:layout', { inspectorWidth: 320 })
+ f.view.unmount()
+ expect(geometry.disconnect).toHaveBeenCalledOnce()
+ expect(content.hasAttribute('data-deepviewer-map-inspector')).toBe(false)
+ const calls = f.send.mock.calls.length
+ await act(async () => { geometry.callback() })
+ expect(f.send).toHaveBeenCalledTimes(calls)
+})
+
+it('a pending fork rejects a second fork without creating another native child', async () => {
+ const f = fixture(); await f.flush()
+ let completeFork!: (id: string) => void
+ f.fork.mockImplementationOnce(() => new Promise(resolve => { completeFork = resolve }))
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, requestId: 'first-fork' })
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, requestId: 'second-fork' })
+ expect(f.fork).toHaveBeenCalledTimes(1)
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:bridge-error', requestId: 'second-fork', message: expect.stringContaining('already') }), location.origin)
+ await act(async () => { completeFork('child'); for (let index = 0; index < 12; index++) await Promise.resolve() })
+ expect(f.openSessionView).toHaveBeenCalledWith('child', 'deepviewer-map', '')
+ expect(f.drafts.get('child')!.focus).toHaveBeenCalled()
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, requestId: 'later-fork' })
+ expect(f.fork).toHaveBeenCalledTimes(2)
+})
+
+it('navigation failure after child preparation is visible after the RPC was acknowledged', async () => {
+ const f = fixture(); await f.flush()
+ f.openSessionView.mockRejectedValueOnce(new Error('Destination did not render'))
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, requestId: 'failed-navigation' })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:composed', requestId: 'failed-navigation', session: expect.objectContaining({ id: 'child' }) }), location.origin)
+ expect(f.drafts.get('child')!.notify).toHaveBeenCalledWith('error', 'Destination did not render')
+ expect(f.drafts.get('parent')!.notify).toHaveBeenCalledWith('error', 'Destination did not render')
+ const error = f.send.mock.calls.map(([message]: any) => message).find(message => message.type === 'synapse:bridge-error' && message.message === 'Destination did not render')
+ expect(error?.requestId).toBeUndefined()
+ expect(f.drafts.get('child')!.focus).not.toHaveBeenCalled()
+})
+
+it('a superseded mounted compose rejects its RPC instead of waiting for the iframe timeout', async () => {
+ const f = fixture(); await f.flush()
+ let completeFork!: (id: string) => void
+ f.fork.mockImplementationOnce(() => new Promise(resolve => { completeFork = resolve }))
+ await f.event('synapse:compose', { sessionId: 'parent', fork: true, atSeq: 2, requestId: 'superseded-fork' })
+ await f.event('synapse:activate-session', { sessionId: 'running' })
+ await act(async () => { completeFork('child'); for (let index = 0; index < 12; index++) await Promise.resolve() })
+ expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'synapse:bridge-error', requestId: 'superseded-fork', message: expect.stringContaining('superseded') }), location.origin)
+ expect(f.drafts.get('child')!.focus).not.toHaveBeenCalled()
+ expect(f.prompt).not.toHaveBeenCalled()
+})
diff --git a/apps/deepviewer-adapter/tests/synapse-host.test.mjs b/apps/deepviewer-adapter/tests/synapse-host.test.mjs
new file mode 100644
index 0000000..3ed261d
--- /dev/null
+++ b/apps/deepviewer-adapter/tests/synapse-host.test.mjs
@@ -0,0 +1,278 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { createSynapseHost, registerSynapse } from '../workflows/synapse-host.mjs'
+import { WorkspaceStore } from '../workflows/vendor/synapse/index.js'
+import { isHumanUserEvent } from '../workflows/synapse-projection.mjs'
+
+test('cold logs, incremental refresh, native branch, hide and restart stay in isolated metadata', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'deepviewer-map-test-'))
+ const events = [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'Synthetic question' }] } }]
+ const logs = new Map([['parent', {session:{id:'parent',cwd:'/synthetic'}, inheritedEventCount:0, events}]])
+ let reads = 0
+ const query = { listSessions: async () => [...logs.values()].map(log => ({header:log.session})), readSession: async id => { reads++; if (!logs.has(id)) throw Error('missing'); return logs.get(id) } }
+ const dataFile = join(dir,'map.json'), host = createSynapseHost({query,dataFile})
+ const request = (path,method='GET',body) => host.call({path,method,body:body===undefined?undefined:JSON.stringify(body)})
+ try {
+ const list = await request('/synapse/api/workspaces')
+ const path = '/synapse/api/workspaces/'+list.workspaces[0].id
+ let graph = (await request(path)).workspace
+ const parent = graph.threads[0]
+ assert.equal(parent.messages[0].text,'Synthetic question')
+ assert.equal(reads,1)
+ await assert.rejects(request('/synapse/api/reset','POST'),/Unsupported/)
+ await assert.rejects(host.call({path,body:'x'}))
+ await assert.rejects(host.call({path,body:'x'.repeat(70000)}),/Invalid/)
+ logs.set('child',{session:{id:'child',cwd:'/synthetic',parentSession:'parent'},inheritedEventCount:1,events:[...events,{type:'user/message',seq:1,time:2,data:{content:[{type:'text',text:'Synthetic branch'}]}}]})
+ host.changed('child')
+ graph = (await request(path)).workspace
+ assert.equal(graph.threads.find(t=>t.dshSessionId==='child').parentId,parent.id)
+ assert.equal(graph.threads.find(t=>t.dshSessionId==='child').messages.length,1)
+ await assert.rejects(request('/synapse/api/threads/'+parent.id+'/branch','POST',{dshSessionId:'parent',title:'forged'}),/native session parent/)
+ await request('/synapse/api/threads/'+parent.id,'DELETE')
+ await host.dispose()
+ assert.equal(logs.get('parent').events,events)
+ const restored = createSynapseHost({query,dataFile})
+ try {
+ const list = await restored.call({path:'/synapse/api/workspaces'})
+ for (const summary of list.workspaces) {
+ const graph = (await restored.call({path:'/synapse/api/workspaces/'+summary.id})).workspace
+ assert.equal(graph.threads.some(t=>t.dshSessionId==='parent'),false)
+ }
+ } finally { await restored.dispose() }
+ assert.ok((await readFile(dataFile,'utf8')).includes('hiddenSessionIds'))
+ } finally { await host.dispose(); await rm(dir,{recursive:true,force:true}) }
+})
+test('only the owned authenticated RPC route is registered, with no HTTP handler', async () => {
+ let handler, disposed
+ const owner = {connection:{rpc:{handle:(channel,fn,scope)=>{assert.equal(channel,'/deepviewer-synapse'); assert.equal(scope,owner); handler=fn}}}}
+ const ctx = {inject:(_,fn)=>fn(owner),on:()=>{},effect:fn=>{disposed=fn()},sessionQuery:{}}
+ registerSynapse(ctx)
+ const response = await handler('reset',{})
+ assert.equal(response.ok,false)
+ await disposed()
+})
+
+function user(seq, text, source) {
+ return { type: 'user/message', seq, time: seq + 1, surfaceOp: 'append', data: { content: [{ type: 'text', text }], ...(source === undefined ? {} : { source }) } }
+}
+function reply(seq, text, turn = 1) {
+ return { type: 'assistant/message', seq, time: seq + 1, data: { turn, step: 1, message: { content: [{ type: 'text', text }] } } }
+}
+
+test('native source classification excludes injected context while preserving human quotations and legacy prompts', () => {
+ const reminder = 'instructions '
+ assert.equal(isHumanUserEvent(user(0, reminder, { kind: 'agent-instructions' })), false)
+ assert.equal(isHumanUserEvent(user(0, reminder, { kind: 'user' })), true)
+ assert.equal(isHumanUserEvent(user(0, reminder)), false)
+ assert.equal(isHumanUserEvent(user(0, 'Explain ' + reminder)), true)
+ assert.equal(isHumanUserEvent(user(0, '[model changed: assistant turns above this point were generated by a; the session continues with b]')), false)
+ assert.equal(isHumanUserEvent(user(0, '[model changed: assistant turns above this point were generated by a; the session continues with b]', { kind: 'user' })), true)
+ assert.equal(isHumanUserEvent(user(0, 'The approval policy changed from "ask" to "never" (changed by the user).')), false)
+ assert.equal(isHumanUserEvent(user(0, 'Ordinary old prompt')), true)
+ assert.equal(isHumanUserEvent(user(0, 'unknown injected producer', { kind: 'new-plugin' })), false)
+ assert.equal(isHumanUserEvent({ ...user(0, 'replacement snapshot', { kind: 'user' }), surfaceOp: { op: 'replace', start: 0, end: 2 } }), false)
+})
+
+test('rebuild Build 38 cards from native logs without losing graph identity, layout, notes, hidden sessions or branch cuts', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'deepviewer-map-rebuild-'))
+ const dataFile = join(dir, 'map.json')
+ const reminder = 'Workspace instructions '
+ const events = [
+ user(0, '真实问题一', { kind: 'user' }),
+ user(1, reminder, { kind: 'agent-instructions' }),
+ user(2, '[model changed: assistant turns above this point were generated by old; the session continues with new]', { kind: 'model-selection' }),
+ user(3, 'The approval policy changed from "ask" to "never" (changed by the user).', { kind: 'user-approval' }),
+ reply(4, '真实回答一'),
+ user(5, reminder, { kind: 'user' }),
+ { type: 'tool/call', seq: 6, time: 7, data: { turn: 2, step: 1, callId: 'read', name: 'file/read', arguments: '{}' } },
+ reply(7, '真实回答二', 2),
+ { type: 'tool/result', seq: 8, time: 9, data: { turn: 2, step: 1, message: { source: { callId: 'read' }, content: [{ type: 'text', text: 'read content' }] } } },
+ { type: 'turn/end', seq: 9, time: 10, data: { turn: 2, reason: { kind: 'error', error: { message: 'Expected failure' } } } },
+ ]
+ const childEvents = [...events.slice(0, 5), user(5, '分支真实问题', { kind: 'user' }), user(6, reminder, { kind: 'agent-instructions' }), reply(7, '分支真实回答')]
+ const logs = new Map([
+ ['child', { session: { id: 'child', cwd: '/synthetic', parentSession: 'parent' }, inheritedEventCount: 5, events: childEvents }],
+ ['parent', { session: { id: 'parent', cwd: '/synthetic' }, inheritedEventCount: 0, events }],
+ ['hidden', { session: { id: 'hidden', cwd: '/synthetic' }, inheritedEventCount: 0, events: [user(0, 'hidden prompt', { kind: 'user' })] }],
+ ])
+ const query = { listSessions: async () => [...logs.values()].map(log => ({ header: log.session })), readSession: async id => logs.get(id) }
+ const serializedLogs = JSON.stringify([...logs])
+ const legacy = new WorkspaceStore(dataFile)
+ let parentId, childId, questionId, workspaceId
+ try {
+ // Seed the exact append-only projection from Build 38, including its bad cards.
+ for (const log of logs.values()) await legacy.projectSession({ id: log.session.id, header: { ...log.session, seedLength: log.inheritedEventCount }, events: log.events }, log.inheritedEventCount)
+ const graph = await legacy.get((await legacy.list())[0].id)
+ const parent = graph.threads.find(thread => thread.dshSessionId === 'parent')
+ const child = graph.threads.find(thread => thread.dshSessionId === 'child')
+ const hidden = graph.threads.find(thread => thread.dshSessionId === 'hidden')
+ parentId = parent.id; childId = child.id; workspaceId = graph.id; questionId = parent.messages.find(message => message.sourceSeq === 0).id
+ assert.equal(parent.messages.filter(message => message.kind === 'user').length, 5)
+ await legacy.updateThread(parent.id, { position: { x: 543, y: -120 } })
+ await legacy.addMessage(parent.id, 'Existing unbound canvas note')
+ await legacy.removeThread(hidden.id)
+ } finally { clearTimeout(legacy.flushTimer); await legacy.flush(); await legacy.serial }
+
+ const host = createSynapseHost({ query, dataFile })
+ let restored
+ try {
+ const graph = (await host.call({ path: '/synapse/api/workspaces/' + workspaceId })).workspace
+ const parent = graph.threads.find(thread => thread.dshSessionId === 'parent')
+ const child = graph.threads.find(thread => thread.dshSessionId === 'child')
+ assert.equal(parent.id, parentId)
+ assert.deepEqual(parent.position, { x: 543, y: -120 })
+ assert.equal(parent.messages.find(message => message.sourceSeq === 0).id, questionId)
+ assert.deepEqual(parent.messages.filter(message => message.kind === 'user' && Number.isSafeInteger(message.sourceSeq)).map(message => message.sourceSeq), [0, 5])
+ assert.deepEqual(parent.messages.filter(message => message.kind === 'assistant').map(message => message.text), ['真实回答一', '真实回答二'])
+ assert.equal(parent.messages.find(message => message.kind === 'error').text, 'Expected failure')
+ assert.equal(parent.messages.find(message => message.sourceSeq === 7).process[0].result, 'read content')
+ assert.equal(parent.messages.at(-1).text, 'Existing unbound canvas note')
+ assert.equal(child.id, childId)
+ assert.equal(child.parentId, parentId)
+ assert.equal(child.sourceSeedLength, 5)
+ assert.deepEqual(child.messages.map(message => message.text), ['分支真实问题', '分支真实回答'])
+ assert.equal(graph.threads.some(thread => thread.dshSessionId === 'hidden'), false)
+ assert.equal(JSON.stringify([...logs]), serializedLogs)
+ await host.dispose()
+ restored = createSynapseHost({ query, dataFile })
+ const replay = (await restored.call({ path: '/synapse/api/workspaces/' + workspaceId })).workspace
+ assert.deepEqual(replay.threads.find(thread => thread.id === parentId).messages, parent.messages)
+ assert.deepEqual(replay.threads.find(thread => thread.id === parentId).position, parent.position)
+ assert.equal((await readFile(dataFile, 'utf8')).includes('"hidden"'), true)
+ } finally { await host.dispose(); await restored?.dispose(); await rm(dir, { recursive: true, force: true }) }
+})
+
+
+test('attachment-only human prompts rebuild as independent turns, while empty content and injected attachments do not', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'deepviewer-map-attachments-'))
+ const dataFile = join(dir, 'map.json')
+ const attachmentPrompt = (seq, content, source = { kind: 'user' }) => ({ ...user(seq, '', source), data: { source, content } })
+ const events = [
+ user(0, 'Before attachments', { kind: 'user' }), reply(1, 'Before answer', 1),
+ attachmentPrompt(2, [{ type: 'image', attachment: { name: 'PRIVATE_IMAGE_NAME', path: '/PRIVATE_IMAGE_PATH', attachmentId: 'PRIVATE_IMAGE_ID' } }]),
+ { type: 'tool/call', seq: 3, time: 4, data: { turn: 2, step: 1, callId: 'inspect-image', name: 'image/read', arguments: '{}' } },
+ reply(4, 'Image answer', 2),
+ { type: 'tool/result', seq: 5, time: 6, data: { turn: 2, step: 1, message: { source: { callId: 'inspect-image' }, content: [{ type: 'text', text: 'Image inspected' }] } } },
+ { type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'error', error: { message: 'Image failure' } } } },
+ attachmentPrompt(7, [{ type: 'text', text: ' ' }, { type: 'file', attachment: { name: 'PRIVATE_FILE_NAME', path: '/PRIVATE_FILE_PATH', attachmentId: 'PRIVATE_FILE_ID' } }]),
+ reply(8, 'File answer', 3),
+ { type: 'turn/end', seq: 9, time: 10, data: { turn: 3, reason: { kind: 'cancelled' } } },
+ attachmentPrompt(10, []), attachmentPrompt(11, [{ type: 'text', text: ' ' }]),
+ attachmentPrompt(12, [{ type: 'image', attachment: { name: 'injected-image' } }], { kind: 'agent-instructions' }),
+ ]
+ const log = { session: { id: 'attachment-session', cwd: '/synthetic' }, inheritedEventCount: 0, events }
+ const query = { listSessions: async () => [{ header: log.session }], readSession: async () => log }
+ const untouched = JSON.stringify(log)
+ const legacy = new WorkspaceStore(dataFile)
+ let workspaceId, threadId
+ try {
+ const original = await legacy.projectSession({ id: log.session.id, header: log.session, events })
+ workspaceId = (await legacy.list())[0].id; threadId = original.id
+ assert.deepEqual(original.messages.filter(message => message.kind === 'user').map(message => message.sourceSeq), [0])
+ await legacy.updateThread(threadId, { position: { x: 200, y: 300 } })
+ } finally { clearTimeout(legacy.flushTimer); await legacy.flush(); await legacy.serial }
+ const host = createSynapseHost({ query, dataFile })
+ let restarted
+ try {
+ const graph = (await host.call({ path: '/synapse/api/workspaces/' + workspaceId })).workspace
+ const thread = graph.threads[0]
+ assert.equal(thread.id, threadId)
+ assert.deepEqual(thread.position, { x: 200, y: 300 })
+ const users = thread.messages.filter(message => message.kind === 'user')
+ assert.deepEqual(users.map(message => message.sourceSeq), [0, 2, 7])
+ assert.deepEqual(users.slice(1).map(message => [message.text, message.deepviewerAttachmentOnly]), [['附件消息', true], ['附件消息', true]])
+ const rounds = users.map((question, index) => ({
+ question: question.sourceSeq,
+ replies: thread.messages.filter(message => message.sourceSeq > question.sourceSeq && message.sourceSeq < (users[index + 1]?.sourceSeq ?? Infinity)),
+ }))
+ assert.deepEqual(rounds.map(round => round.replies.filter(message => message.kind === 'assistant').map(message => message.text)), [['Before answer'], ['Image answer'], ['File answer']])
+ assert.equal(rounds[1].replies.find(message => message.kind === 'assistant').process[0].result, 'Image inspected')
+ assert.equal(rounds[1].replies.find(message => message.kind === 'error').text, 'Image failure')
+ assert.equal(rounds[2].replies.find(message => message.kind === 'error').text, '本轮已取消')
+ assert.equal(JSON.stringify(thread.messages).includes('PRIVATE_'), false)
+ assert.equal(JSON.stringify(log), untouched)
+ await host.dispose()
+ restarted = createSynapseHost({ query, dataFile })
+ const replay = (await restarted.call({ path: '/synapse/api/workspaces/' + workspaceId })).workspace.threads[0]
+ assert.deepEqual(replay.messages, thread.messages)
+ } finally { await host.dispose(); await restarted?.dispose(); await rm(dir, { recursive: true, force: true }) }
+})
+
+
+test('an event appended during a pending projection is read on the next request before cache expiry', { timeout: 5000 }, async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'deepviewer-map-dirty-race-'))
+ const started = Promise.withResolvers(), resume = Promise.withResolvers()
+ const log = { session: { id: 'parent', cwd: '/synthetic' }, inheritedEventCount: 0, events: [user(0, 'First synthetic turn', { kind: 'user' })] }
+ let reads = 0
+ const query = {
+ listSessions: async () => [{ header: log.session }],
+ readSession: async () => {
+ reads += 1
+ const snapshot = structuredClone(log)
+ if (reads === 1) { started.resolve(); await resume.promise }
+ return snapshot
+ },
+ }
+ const host = createSynapseHost({ query, dataFile: join(dir, 'map.json') })
+ try {
+ host.changed('parent')
+ const first = host.call({ path: '/synapse/api/workspaces' })
+ await started.promise
+ const concurrent = host.call({ path: '/synapse/api/workspaces' })
+ log.events.push(user(1, 'Appended during the old snapshot', { kind: 'user' }))
+ host.changed('parent')
+ const untouched = JSON.stringify(log)
+ resume.resolve()
+ const [initial, shared] = await Promise.all([first, concurrent])
+ assert.equal(reads, 1, 'Concurrent requests should share the first projection')
+ assert.deepEqual(shared, initial)
+ const path = '/synapse/api/workspaces/' + initial.workspaces[0].id
+ const refreshed = (await host.call({ path })).workspace.threads[0]
+ assert.equal(reads, 2, 'Unconsumed events must bypass the fresh 1500ms cache')
+ assert.deepEqual(refreshed.messages.filter(message => message.kind === 'user').map(message => message.sourceSeq), [0, 1])
+ await host.call({ path })
+ assert.equal(reads, 2, 'A fully consumed projection should resume caching')
+ assert.equal(JSON.stringify(log), untouched)
+ } finally { resume.resolve(); await host.dispose(); await rm(dir, { recursive: true, force: true }) }
+})
+
+test('a session created after the pending catalogue snapshot is projected on the next request', { timeout: 5000 }, async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'deepviewer-map-created-race-'))
+ const started = Promise.withResolvers(), resume = Promise.withResolvers()
+ const parentEvent = user(0, 'Synthetic parent turn', { kind: 'user' })
+ const logs = new Map([['parent', { session: { id: 'parent', cwd: '/synthetic' }, inheritedEventCount: 0, events: [parentEvent] }]])
+ const reads = new Map()
+ const query = {
+ listSessions: async () => [...logs.values()].map(log => ({ header: structuredClone(log.session) })),
+ readSession: async id => {
+ reads.set(id, (reads.get(id) ?? 0) + 1)
+ const snapshot = structuredClone(logs.get(id))
+ if (id === 'parent' && reads.get(id) === 1) { started.resolve(); await resume.promise }
+ return snapshot
+ },
+ }
+ const host = createSynapseHost({ query, dataFile: join(dir, 'map.json') })
+ try {
+ const pending = host.call({ path: '/synapse/api/workspaces' })
+ await started.promise
+ logs.set('child', { session: { id: 'child', cwd: '/synthetic', parentSession: 'parent' }, inheritedEventCount: 1,
+ events: [parentEvent, user(1, 'Synthetic child first turn', { kind: 'user' })] })
+ host.changed('child')
+ const untouched = JSON.stringify([...logs])
+ resume.resolve()
+ const initial = await pending
+ const graph = (await host.call({ path: '/synapse/api/workspaces/' + initial.workspaces[0].id })).workspace
+ const parent = graph.threads.find(thread => thread.dshSessionId === 'parent')
+ const child = graph.threads.find(thread => thread.dshSessionId === 'child')
+ assert.ok(child, 'A new native session must not remain hidden by the catalogue cache')
+ assert.equal(child.parentId, parent.id)
+ assert.equal(child.sourceSeedLength, 1)
+ assert.deepEqual(child.messages.filter(message => message.kind === 'user').map(message => message.sourceSeq), [1])
+ assert.equal(reads.get('parent'), 1, 'Unchanged projected sessions should not be reread')
+ assert.equal(reads.get('child'), 1)
+ assert.equal(JSON.stringify([...logs]), untouched)
+ } finally { resume.resolve(); await host.dispose(); await rm(dir, { recursive: true, force: true }) }
+})
diff --git a/apps/deepviewer-adapter/tests/synapse-native.spec.tsx.txt b/apps/deepviewer-adapter/tests/synapse-native.spec.tsx.txt
new file mode 100644
index 0000000..e178a08
--- /dev/null
+++ b/apps/deepviewer-adapter/tests/synapse-native.spec.tsx.txt
@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { apply, inject } from '../src/client/apply.ts'
+import { EMPTY_CONVERSATION_SNAPSHOT } from '../src/client/contract/snapshot.ts'
+import { DefaultConversationViews } from '../src/client/skeleton/DefaultConversationViews.tsx'
+import { ConversationMainPanel } from '../src/client/skeleton/ConversationMainPanel.tsx'
+import { ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
+usePinnedBrowserLanguages('zh-CN')
+afterEach(() => { cleanup(); vi.useRealTimers() })
+
+async function nativeNavigationBench() {
+ const runtime = await SlotTestRuntime.create()
+ const openSession = vi.fn()
+ runtime.ctx.provide('uiWorkspace', { openWorkspace: vi.fn(), openSession } as never)
+ runtime.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
+ const locale = new LocaleRuntime(runtime.ctx)
+ runtime.ctx.provide('locale', locale); runtime.slots.installLocale(locale)
+ await runtime.root.declare({ main: { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' } }, () => null)
+ const feature = await runtime.mount({ inject: [...inject], apply })
+ runtime.renderRoot()
+ for (const id of ['chat', 'deepviewer-map']) runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never)
+ await runtime.sessions.add({ id: 'map-parent' as never }); await runtime.sessions.add({ id: 'map-child' as never })
+ const parent = runtime.sessions.retain('map-parent' as never), child = runtime.sessions.retain('map-child' as never)
+ await Promise.all([parent.ready, child.ready])
+ const entry: any = runtime.slots.entries('conversation.session')[0]!
+ const parentStore = entry.store.create(), childStore = entry.store.create()
+ const owner = entry.inject('map-parent', parentStore.actions)
+ const bindChild = () => entry.inject('map-child', childStore.actions)
+ return { runtime, feature, parent, child, parentStore, childStore, owner, bindChild, openSession,
+ dispose: async () => { parent.release(); child.release(); await runtime.dispose() } }
+}
+
+it('DeepViewer map native navigation writes the destination actions while preserving the source view', async () => {
+ const b = await nativeNavigationBench()
+ try {
+ b.parentStore.actions.setView('chat')
+ const pending = b.owner.openSessionView('map-child', 'deepviewer-map', '')
+ expect(b.openSession).toHaveBeenCalledWith('map-child')
+ b.bindChild(); await pending
+ expect(b.childStore.getSnapshot().view).toBe('deepviewer-map')
+ expect(b.parentStore.getSnapshot().view).toBe('chat')
+ expect(b.childStore.getSnapshot().viewRequest).toEqual({ view: 'deepviewer-map', focus: '' })
+ await b.owner.openSessionView('map-child', 'chat', 'seq:42')
+ expect(b.childStore.getSnapshot().viewRequest).toEqual({ view: 'chat', focus: 'seq:42' })
+ } finally { await b.dispose() }
+})
+
+it('DeepViewer map native navigation supersedes pending destinations and rejects unknown view ids', async () => {
+ const b = await nativeNavigationBench()
+ try {
+ const first = b.owner.openSessionView('map-child', 'chat', 'seq:1')
+ const firstFailure = expect(first).rejects.toThrow(/superseded/)
+ const second = b.owner.openSessionView('map-child', 'deepviewer-map', '')
+ b.bindChild(); await firstFailure; await second
+ expect(b.childStore.getSnapshot().view).toBe('deepviewer-map')
+ await expect(b.owner.openSessionView('map-child', 'unregistered', '')).rejects.toThrow(/Unknown conversation view/)
+ } finally { await b.dispose() }
+})
+
+it('DeepViewer map native blank session shows its view and docks the composer while Chat retains the hero', () => {
+ const session: any = { blank: true, running: false, openState: 'open', subagent: null, promptAttempted: false, awaitingFirstTurn: false }
+ let selected = 'deepviewer-map'
+ const select = (selector: any) => selector(selected)
+ const factory = vi.fn((_name: string, input: any) =>
)
+ const props: any = { sessionId: 'blank', useSession: (selector: any) => selector(session), useSessions: (selector: any) => selector({ byId: { blank: { blank: true } } }),
+ useConversation: (selector: any) => selector(EMPTY_CONVERSATION_SNAPSHOT), useConversationView: select, renderSlot: () => null, renderFactorySlot: factory }
+ const shell = render( )
+ expect(shell.getByTestId('native-body').dataset.hero).toBe('false')
+ expect(shell.getByTestId('native-body').dataset.phase).toBe('active')
+ selected = 'chat'; shell.rerender( )
+ expect(shell.getByTestId('native-body').dataset.hero).toBe('true')
+ const viewProps: any = { useSession: props.useSession, useConversation: props.useConversation, useConversationViews: (selector: any) => selector([{ id: 'chat' }, { id: 'deepviewer-map' }]),
+ useInspectCall: (selector: any) => selector(undefined), useStore: (selector: any) => selector({ view: 'deepviewer-map', draft: '', viewRequest: null }),
+ useInput: (selector: any) => selector({ draft: '' }), inputActions: { setDraft: vi.fn() }, actions: { setDraft: vi.fn(), completeViewRequest: vi.fn() },
+ bindDraftMirror: () => () => {}, openView: vi.fn(), openSessionView: vi.fn(), renderSlot: (_key: string, _owner: any, options: any) =>
}
+ const area = render( )
+ expect(area.queryByTestId('deepviewer-map')).not.toBeNull()
+ area.rerender( selector({ view: 'chat', draft: '', viewRequest: null })} />)
+ expect(area.queryByTestId('deepviewer-map')).toBeNull()
+})
+
+it('DeepViewer map native blank header offers Chat and Map tabs without statistics or trajectory', () => {
+ const header = render( selector({ byId: {} }), useConversationViews: (selector: any) => selector([{ id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, { id: 'deepviewer-map', label: 'Map' }]),
+ useStore: (selector: any) => selector({ view: 'deepviewer-map' }), selectView: vi.fn(), open: vi.fn(), t: (key: string) => key, renderSlot: () => null } as any)} />)
+ expect(header.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Chat', 'Map'])
+ expect(header.getByRole('tab', { name: 'Map' }).getAttribute('aria-selected')).toBe('true')
+ expect(header.container.querySelector('[data-deepviewer-session-statistics]')).toBeNull()
+})
diff --git a/apps/deepviewer-adapter/tests/workflows.test.mjs b/apps/deepviewer-adapter/tests/workflows.test.mjs
new file mode 100644
index 0000000..549c444
--- /dev/null
+++ b/apps/deepviewer-adapter/tests/workflows.test.mjs
@@ -0,0 +1,30 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,mkdir,readFile,writeFile,lstat,stat,symlink,rm,realpath} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {prepareWorkspaceDeliveries} from '../workflows/workspace-delivery.ts'
+import {generatedPaths} from '../workflows/auto-delivery.ts'
+import {apply as chatGuard} from '../workflows/chat-guard.ts'
+test('automatic delivery retains source bytes, uses outputs, records aliases and reuses the delivery',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'deepviewer-delivery-'))
+ try{
+ const cwd=join(root,'workspace'),source=join(root,'opaque-object');await mkdir(cwd);const bytes=await readFile(new URL('../assets/icon-light.png',import.meta.url));await writeFile(source,bytes)
+ const fs={resolve:async(input,{cwd}={})=>({displayPath:resolve(cwd??root,input)}),processPath:value=>value.displayPath,processPathFromHostPath:value=>value,lstat:async(input,{cwd})=>({type:(await lstat(resolve(cwd,input))).isSymbolicLink()?'link':'file'}),stat:async(target)=>({type:(await stat(target.displayPath)).isFile()?'file':'directory'})}
+ const ctx={fs,sandboxPolicy:{resolve:()=>({mode:'workspace-write'})}},events=[],session={header:{cwd},snapshotEvents:()=>events}
+ const [delivery]=await prepareWorkspaceDeliveries(ctx,session,[{path:source}],new AbortController().signal)
+ assert.ok(delivery.path.startsWith(join(await realpath(cwd),'outputs')+'/'));assert.ok(delivery.path.endsWith('.png'));assert.deepEqual(await readFile(delivery.path),bytes);assert.deepEqual(await readFile(source),bytes);assert.ok(delivery.sourcePaths.includes(source))
+ events.push({type:'deliverables/presented',data:{files:[delivery]}})
+ assert.equal((await prepareWorkspaceDeliveries(ctx,session,[{path:source}],new AbortController().signal))[0].path,delivery.path)
+ await assert.rejects(prepareWorkspaceDeliveries(ctx,{header:{},snapshotEvents:()=>[]},[{path:source}],new AbortController().signal),/workspace/)
+ const readonly={...ctx,sandboxPolicy:{resolve:()=>({mode:'read-only'})}};await assert.rejects(prepareWorkspaceDeliveries(readonly,{header:{cwd},snapshotEvents:()=>[]},[{path:source}],new AbortController().signal),/read-only/)
+ await rm(join(cwd,'outputs'),{recursive:true});await symlink(root,join(cwd,'outputs'));await assert.rejects(prepareWorkspaceDeliveries(ctx,{header:{cwd},snapshotEvents:()=>[]},[{path:source}],new AbortController().signal),/real directory/)
+ }finally{await rm(root,{recursive:true,force:true})}
+})
+test('only producer contracts trigger automatic delivery and Chat restricts project tools',()=>{
+ assert.deepEqual(generatedPaths('read',{file_path:'/private/file'},{paths:['/private/file']}),[])
+ assert.deepEqual(generatedPaths('bash',{command:'echo generated'},{paths:['/private/file']}),[])
+ assert.deepEqual(generatedPaths('image_generate',{}, {paths:['/generated/image','/generated/image']}),['/generated/image'])
+ let restriction,guard;chatGuard({tools:{restrict:value=>restriction=value,guard:value=>guard=value}})
+ assert.deepEqual(restriction,{allow:[]});assert.equal(guard({name:'web_fetch'}),undefined);assert.match(guard({name:'bash'}),/unavailable/);assert.match(guard({name:'edit'}),/unavailable/)
+})
diff --git a/apps/deepviewer-adapter/workflows/auto-delivery.ts b/apps/deepviewer-adapter/workflows/auto-delivery.ts
new file mode 100644
index 0000000..f465738
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/auto-delivery.ts
@@ -0,0 +1,83 @@
+/** Register deterministic generated outputs without relying on another model call. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import type {} from '@deepseek-ai/dsh-session-projection'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import { basename } from 'node:path'
+import { prepareWorkspaceDeliveries } from './workspace-delivery.ts'
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record : undefined
+}
+
+/** Only producer-owned contracts count; prose, file reads and shell strings do not. */
+export function generatedPaths(name: string, args: unknown, value: unknown): string[] {
+ const input = record(args)
+ const output = record(value)
+ const paths: unknown[] = name === 'image_generate' && Array.isArray(output?.paths)
+ ? output.paths
+ : name === 'write' && typeof input?.content === 'string'
+ ? [input.file_path]
+ : name === 'str_replace_editor' && input?.command === 'create' && typeof input.file_text === 'string'
+ ? [input.path] : []
+ return [...new Set(paths.filter((path): path is string =>
+ typeof path === 'string' && path.trim().length > 0 && !path.includes('\0')))]
+}
+
+/** Normalize both producer results and explicit present calls at the same workspace boundary. */
+export function registerAutoDelivery(ctx: Context): void {
+ const pending = new WeakMap()
+ ctx.on('deliverables/prepare', async (exec, _files, next) => {
+ const files = await next()
+ if (!exec.agent) throw new Error('File delivery requires a session')
+ return prepareWorkspaceDeliveries(ctx, exec.agent.session, files, exec.signal)
+ })
+ ctx.on('tools/post-execute', async (exec, result, next) => {
+ const decision = await next()
+ if (decision.kind !== 'accept' || result.isError || exec.signal.aborted || !exec.agent) return decision
+ const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
+ if (!boundary || boundary.openTurnStartSeq === null) return decision
+ const value = decision.value === undefined ? result.value : decision.value
+ const paths = generatedPaths(exec.name, exec.arguments, value)
+ if (paths.length === 0) return decision
+ const images = record(value)?.images
+ const sources = paths.map((path, index): PresentedFile => {
+ const image = Array.isArray(images) ? images[index] : undefined
+ let objectPath: string | undefined
+ try {
+ const host = image && ctx.get('attachments')?.imageHostPath(image as ImageAttachmentRef)
+ objectPath = host ? ctx.fs.processPathFromHostPath(host) : undefined
+ } catch { /* Invalid optional image metadata cannot grant access to a cache object. */ }
+ return { path, ...(objectPath ? { sourcePaths: [objectPath] } : {}) }
+ })
+ try {
+ const files = await prepareWorkspaceDeliveries(ctx, exec.agent.session, sources, exec.signal)
+ if (exec.signal.aborted) return decision
+ const finalPaths = exec.name === 'image_generate' ? files.map(file => file.path) : paths
+ pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files, paths: finalPaths })
+ if (exec.name === 'image_generate' && record(value)) {
+ // The model, the final attachment and the side preview now name one original-quality file.
+ return { kind: 'accept', value: { ...record(value), paths: finalPaths,
+ ...(Array.isArray(images) ? { images: images.map((image, index) => ({ ...record(image), name: basename(finalPaths[index] ?? paths[index] ?? 'image.png') })) } : {}),
+ } as Extract['value'], ...(decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {}) }
+ }
+ return decision
+ } catch (error) {
+ if (exec.signal.aborted) return decision
+ return { kind: 'block', feedback: [{ type: 'text', text: `File generation completed, but workspace delivery failed: ${error instanceof Error ? error.message : String(error)}. Existing source files: ${paths.join(', ')}. Fix delivery with present; do not regenerate or claim successful delivery.` }] }
+ }
+ })
+ ctx.on('tools/result', (exec, result: Readonly) => {
+ const delivery = pending.get(exec)
+ pending.delete(exec)
+ if (!delivery || result.isError || exec.signal.aborted) return
+ const boundary = ctx.sessionProjections.stateOf(delivery.session, 'turnBoundary')
+ if (!boundary || boundary.openTurnStartSeq === null || boundary.lastTurn !== delivery.turn) return
+ const paths = generatedPaths(exec.name, exec.arguments, result.value)
+ if (paths.length !== delivery.paths.length || paths.some((path, i) => path !== delivery.paths[i])) return
+ delivery.session.append('deliverables/presented', { turn: delivery.turn, callId: exec.callId, files: delivery.files })
+ })
+}
diff --git a/apps/deepviewer-adapter/workflows/chat-guard.ts b/apps/deepviewer-adapter/workflows/chat-guard.ts
new file mode 100644
index 0000000..1590795
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/chat-guard.ts
@@ -0,0 +1,9 @@
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-tools'
+export const inject = ['tools']
+/** Mounted only inside the Chat preset, before its first request. */
+export function apply(ctx: Context): void {
+ // Native 0.2 restrictions mask inherited tools; the preset's own web tools remain visible.
+ ctx.tools.restrict({ allow: [] })
+ ctx.tools.guard(exec => ['web_search', 'web_fetch'].includes(exec.name) ? undefined : 'This tool is unavailable in Chat. Start a Work session to use project tools.')
+}
diff --git a/apps/deepviewer-adapter/workflows/file-delivery-prompt.ts b/apps/deepviewer-adapter/workflows/file-delivery-prompt.ts
new file mode 100644
index 0000000..56cdf71
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/file-delivery-prompt.ts
@@ -0,0 +1,6 @@
+/** Product-wide output contract, injected into every DeepViewer conversation. */
+export const FILE_DELIVERY_PROMPT = `File delivery is required in DeepViewer. Whenever the user asks you to generate or export an image, video, audio, document, archive, or any other file, deliver every final output as an attachment card before ending your response.
+Use the present tool with the existing file paths and concise descriptions. This applies to files created through image_generate, Bash, code execution, MCP, and other plugins. Tool output, inline images, Markdown links, path text, and a statement that a file was generated do not replace attachment delivery.
+The final delivered file must be inside the current workspace. DeepViewer normalizes generated outputs and present calls into the workspace outputs directory. Use the workspace paths returned by those tools. Never present the internal attachments/v1/objects path: that is a read-only model cache, not the original user delivery.
+Preserve existing filenames and formats. For other tools that return an external or temporary source file, call present on that source: DeepViewer will publish a correctly named workspace file and return its canonical path. Do not manually copy, rename, or convert an already delivered output unless the user asks. If delivery fails, use the existing original file to retry; do not spend another generation call to fix a delivery problem.
+Only present real, successfully generated final outputs; do not present input/reference files or internal scratch files. Never claim a missing or failed file was delivered. If saving or presenting fails, fix the failure when possible or explain the unresolved issue in the final response.`
diff --git a/apps/deepviewer-adapter/workflows/synapse-host.mjs b/apps/deepviewer-adapter/workflows/synapse-host.mjs
new file mode 100644
index 0000000..62d42ca
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/synapse-host.mjs
@@ -0,0 +1,84 @@
+import { join } from 'node:path'
+import { DeepViewerSynapseStore } from './synapse-projection.mjs'
+
+// No HTTP routes: only the authenticated native connection can reach this service.
+export function createSynapseHost({ query, dataFile }) {
+ let store, refreshPending, lastRefresh = 0, changeRevision = 0
+ const projected = new Set(), dirty = new Map()
+ const getStore = () => store ??= new DeepViewerSynapseStore(dataFile)
+ async function refresh() {
+ if (refreshPending) return refreshPending
+ if (dirty.size === 0 && Date.now() - lastRefresh < 1500) return
+ refreshPending = (async () => {
+ const target = getStore(), records = await query.listSessions()
+ await target.ready
+ const summaries = []
+ for (const { header } of records) {
+ if (projected.has(header.id) && !dirty.has(header.id)) {
+ const thread = target.state.workspaces.flatMap(w => w.threads).find(t => t.dshSessionId === header.id)
+ summaries.push({ id: header.id, title: thread?.dshSessionTitle ?? header.title ?? 'DeepViewer 会话', cwd: header.cwd ?? null, parentId: header.parentSession ?? null }); continue
+ }
+ const revision = dirty.get(header.id)
+ const log = await query.readSession(header.id)
+ const titleEvent = [...log.events].reverse().find(event => event.type === 'session/title')
+ const title = titleEvent?.data?.title ?? header.title ?? 'DeepViewer 会话'
+ const session = { id: header.id, title, header: { ...log.session, seedLength: log.inheritedEventCount }, events: log.events }
+ await target.projectSession(session, header.parentSession ? log.inheritedEventCount : 0, 'DeepViewer 会话')
+ // Reading/projection can yield while native events append. Consume only
+ // the revision this snapshot started with; newer events must be retried.
+ if (dirty.get(header.id) === revision) dirty.delete(header.id)
+ projected.add(header.id)
+ summaries.push({ id: header.id, title, cwd: header.cwd ?? null, parentId: header.parentSession ?? null })
+ }
+ const ids = new Set(summaries.map(s => s.id))
+ const removed = target.state.workspaces.flatMap(w => w.threads).map(t => t.dshSessionId).filter(id => id && !ids.has(id))
+ await target.syncSessions(summaries, removed)
+ lastRefresh = Date.now()
+ })().finally(() => { refreshPending = undefined })
+ return refreshPending
+ }
+ return {
+ changed(id) { dirty.set(id, ++changeRevision); lastRefresh = 0 },
+ async call(input) {
+ if (!input || typeof input !== 'object' || JSON.stringify(input).length > 64 * 1024) throw new Error('Invalid map request')
+ const { path, method = 'GET' } = input
+ if (typeof path !== 'string') throw new Error('Invalid map path')
+ const body = input.body === undefined ? {} : JSON.parse(input.body)
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Invalid map body')
+ if (path === '/synapse/api/workspaces' && method === 'GET') {
+ await refresh(); return { workspaces: await getStore().list() }
+ }
+ const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]{36})$/i.exec(path)
+ if (workspace && method === 'GET') { await refresh(); return { workspace: await getStore().get(workspace[1]) } }
+ const branch = /^\/synapse\/api\/threads\/([0-9a-f-]{36})\/branch$/i.exec(path)
+ if (branch && method === 'POST') {
+ // Native fork must exist before a canvas edge can refer to it.
+ const log = await query.readSession(body.dshSessionId)
+ const parent = getStore().state.workspaces.flatMap(w => w.threads).find(t => t.id === branch[1])
+ if (!parent || log.session.parentSession !== parent.dshSessionId) throw new Error('Canvas branch must match the native session parent')
+ return { thread: await getStore().branch(branch[1], body) }
+ }
+ const thread = /^\/synapse\/api\/threads\/([0-9a-f-]{36})$/i.exec(path)
+ if (thread && method === 'PATCH') return { thread: await getStore().updateThread(thread[1], body) }
+ if (thread && method === 'DELETE') return getStore().removeThread(thread[1])
+ throw new Error('Unsupported map operation')
+ },
+ async dispose() { await refreshPending; if (store) { await store.ready; clearTimeout(store.flushTimer); store.flushTimer = null; await store.flush(); await store.serial } },
+ }
+}
+export function registerSynapse(ctx) {
+ let host
+ const instance = () => {
+ if (!process.env.DSH_HOME) throw new Error('DeepViewer data directory unavailable')
+ return host ??= createSynapseHost({ query: ctx.sessionQuery, dataFile: join(process.env.DSH_HOME, 'synapse', 'workspaces.json') })
+ }
+ ctx.inject(['connection', 'webServer'], scope => scope.connection.rpc.handle('/deepviewer-synapse', async (method, input) => {
+ try {
+ if (method !== 'request') throw new Error('Unsupported map method')
+ return { ok: true, value: await instance().call(input) }
+ } catch (error) { return { ok: false, error: { code: 'deepviewer/synapse', message: error.message, details: {} } } }
+ }, scope))
+ ctx.on('session/event', session => host?.changed(session.id))
+ ctx.on('session/created', session => host?.changed(session.id))
+ ctx.effect(() => () => host?.dispose())
+}
diff --git a/apps/deepviewer-adapter/workflows/synapse-projection.mjs b/apps/deepviewer-adapter/workflows/synapse-projection.mjs
new file mode 100644
index 0000000..aa27598
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/synapse-projection.mjs
@@ -0,0 +1,81 @@
+import { randomUUID } from 'node:crypto'
+import { WorkspaceStore } from './vendor/synapse/index.js'
+
+const MAX_PROJECTION_LENGTH = 8_000
+const TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
+
+/** Match the native Chat classifier: injected user-role context is not a prompt. */
+export function isHumanUserEvent(event) {
+ if (event?.type !== 'user/message') return false
+ if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return false
+ const source = event.data?.source
+ if (source !== undefined) return source?.kind === 'user'
+ // Native logs always identify the source. Restrict compatibility filtering to
+ // known complete legacy envelopes so ordinary questions quoting them survive.
+ const text = textContent(event.data?.content).trim()
+ return !text.startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
+ && !/^[\s\S]*<\/system-reminder>$/i.test(text)
+ && !/^\[model changed: assistant turns above this point were generated by [\s\S]+; the session continues with [\s\S]+\]$/i.test(text)
+ && !/^The approval policy changed from "(?:ask|never)" to "(?:ask|never)" \(changed by the user\)\.$/.test(text)
+}
+
+function textContent(content) {
+ if (!Array.isArray(content)) return ''
+ return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n')
+}
+
+/** DeepViewer owns projection semantics; upstream continues to own graph storage. */
+export class DeepViewerSynapseStore extends WorkspaceStore {
+ async projectSession(session, replayFrom = 0, workspaceTitle = 'DeepViewer 会话') {
+ return this.mutate(() => {
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
+ const cwd = session.header?.meta?.cwd ?? session.header?.cwd
+ const workspace = this.dshWorkspace(typeof cwd === 'string' && cwd.trim() ? cwd : '未指定工作目录', workspaceTitle)
+ const thread = this.dshThread(workspace, session)
+ const previous = new Map(thread.messages.filter(message => Number.isSafeInteger(message.sourceSeq)).map(message => [message.sourceSeq, message]))
+ const notes = thread.messages.filter(message => !Number.isSafeInteger(message.sourceSeq))
+ // Build 38 projected injections into question cards. Rebuild only the
+ // derived messages, retaining thread ids, positions, colors, branch edges,
+ // hidden session ids, and any unbound legacy notes. Native logs stay intact.
+ thread.messages = []
+ thread.pendingProcess = []
+ for (const event of session.events) {
+ if (event.seq < replayFrom) continue
+ this.projectEventInto(workspace, thread, event)
+ }
+ for (const message of thread.messages) {
+ const old = previous.get(message.sourceSeq)
+ if (old?.kind === message.kind && typeof old.id === 'string') message.id = old.id
+ }
+ thread.messages.push(...notes)
+ thread.deepviewerProjectionVersion = 1
+ return structuredClone(thread)
+ }, { deferred: true })
+ }
+
+ projectEventInto(workspace, thread, event) {
+ if (event.type !== 'user/message') return super.projectEventInto(workspace, thread, event)
+ if (!isHumanUserEvent(event) || thread.messages.some(message => message.sourceSeq === event.seq)) return
+ const body = textContent(event.data?.content).trim()
+ const attachmentOnly = !body && Array.isArray(event.data?.content)
+ && event.data.content.some(block => block?.type === 'image' || block?.type === 'file')
+ if (!body && !attachmentOnly) return
+ // Attachment-only prompts are real turn boundaries. Keep the question card
+ // without copying filenames, local paths, or opaque attachment identifiers.
+ const text = attachmentOnly ? '附件消息' : body
+ const at = new Date(event.time).toISOString()
+ const message = {
+ id: randomUUID(), kind: 'user', sourceKind: 'user', sourceSeq: event.seq, at,
+ ...(attachmentOnly ? { deepviewerAttachmentOnly: true } : {}),
+ text: text.length <= MAX_PROJECTION_LENGTH ? text : text.slice(0, MAX_PROJECTION_LENGTH) + TRUNCATED_SUFFIX,
+ }
+ thread.messages.push(message)
+ thread.updatedAt = at
+ workspace.updatedAt = at
+ if (thread.dshSessionTitle === null) {
+ const title = text.replaceAll(/\s+/g, ' ').trim()
+ thread.title = title.length > 42 ? title.slice(0, 42) + '...' : title
+ thread.dshSessionTitle = thread.title
+ }
+ }
+}
diff --git a/apps/deepviewer-adapter/workflows/vendor/BETTER-SIDEBAR-LICENSE b/apps/deepviewer-adapter/workflows/vendor/BETTER-SIDEBAR-LICENSE
new file mode 100644
index 0000000..a2078e7
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/BETTER-SIDEBAR-LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 dsh-external
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/deepviewer-adapter/workflows/vendor/UPSTREAM.md b/apps/deepviewer-adapter/workflows/vendor/UPSTREAM.md
new file mode 100644
index 0000000..7393d64
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/UPSTREAM.md
@@ -0,0 +1 @@
+Only git.ts is extracted from dsh-better-sidebar 0.19.1 (MIT). Its original source and license are preserved. This is a stateless system-Git wrapper; the Better Sidebar application, renderer, bundled dependencies and registration are not included. DeepViewer owns the authenticated Session boundary and the native Sidebar tab. Port upstream Git fixes deliberately and verify against isolated repositories.
diff --git a/apps/deepviewer-adapter/workflows/vendor/git.ts b/apps/deepviewer-adapter/workflows/vendor/git.ts
new file mode 100644
index 0000000..29e5aad
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/git.ts
@@ -0,0 +1,462 @@
+/**
+ * Git operations for the sidebar source-control panel. Everything goes
+ * through the system `git` binary spawned per request (no library, no state),
+ * with porcelain-parseable output formats (`-z` NUL framing, unit separators)
+ * so parsing never depends on locale or color config. All commands run with
+ * `-C ` on the session's working directory and `--no-pager` /
+ * `-c color.ui=false` so output stays machine-readable.
+ *
+ * Commits use the user's git global identity untouched (never sets
+ * user.name/user.email).
+ */
+import { readdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { spawn } from 'node:child_process'
+import { resolve } from 'node:path'
+
+/** A parsed `git status --porcelain=v1 -z` entry. */
+export interface GitStatusEntry {
+ path: string
+ /** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
+ xy: string
+}
+
+/** The source-control panel snapshot. */
+export interface GitStatusResult {
+ isRepo: boolean
+ branch?: string
+ entries: GitStatusEntry[]
+ /** True when the working tree had more rows than `GIT_STATUS_LIMIT`; the
+ * panel shows a truncation notice instead of freezing on a huge untracked
+ * set (issue #369). */
+ truncated?: boolean
+ /** Selected repository root, or the discovered roots when the cwd is a container. */
+ root?: string
+ repositories?: string[]
+}
+
+/** One linked checkout returned by `git worktree list --porcelain`. */
+export interface GitWorktree {
+ /** Absolute checkout root. */
+ path: string
+ /** Branch name without `refs/heads/`, or `HEAD` when detached. */
+ branch: string
+ /** Whether this checkout contains the session cwd. */
+ current: boolean
+ /** Number of staged + unstaged status rows (a file changed on both sides counts once). */
+ changes: number
+}
+
+/** One `git log` row. */
+export interface GitLogEntry {
+ /** Short hash (7+ chars, display). */
+ hash: string
+ /** Full 40-char hash (advanced operations: revert / cherry-pick). */
+ hashFull: string
+ subject: string
+ author: string
+ /** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
+ date: string
+ /** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
+ refs: string
+}
+
+/** One git failure (stderr text as the message). */
+export class GitCommandError extends Error {
+ constructor(
+ message: string,
+ readonly code = 'git-error',
+ readonly command: string,
+ ) {
+ super(message)
+ }
+}
+
+/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
+export function parsePorcelainZ(output: string): GitStatusEntry[] {
+ const tokens = output.split('\0')
+ const entries: GitStatusEntry[] = []
+ let index = 0
+ while (index < tokens.length) {
+ const token = tokens[index]!
+ index += 1
+ if (token === '') continue
+ const xy = token.slice(0, 2)
+ const rest = token.slice(3)
+ entries.push({ path: rest, xy })
+ // Rename/copy entries carry the ORIGIN path as the next NUL field; the
+ // new path (the file as it exists now) is the display path.
+ if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
+ index += 1
+ }
+ }
+ return entries
+}
+
+/** One raw porcelain worktree record. Prunable checkouts are retained by
+ * Git's administrative metadata after their directory disappears and must not
+ * become selectable command targets. Locked checkouts remain usable. */
+export interface GitWorktreeRecord {
+ path: string
+ branch: string
+ locked: boolean
+ prunable: boolean
+}
+
+/** Parse `git worktree list --porcelain` records. Production requests use
+ * `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
+ * newline framing remains accepted for small fixtures and older Git output. */
+export function parseWorktreeList(output: string): GitWorktreeRecord[] {
+ const rows: GitWorktreeRecord[] = []
+ let path: string | undefined
+ let branch = 'HEAD'
+ let locked = false
+ let prunable = false
+ const flush = (): void => {
+ if (path !== undefined) rows.push({ path, branch, locked, prunable })
+ path = undefined
+ branch = 'HEAD'
+ locked = false
+ prunable = false
+ }
+ const sep = output.includes('\0') ? '\0' : '\n'
+ const framed = output.endsWith(sep) ? output : `${output}${sep}`
+ for (const line of framed.split(sep)) {
+ if (line === '') {
+ flush()
+ } else if (line.startsWith('worktree ')) {
+ path = line.slice('worktree '.length)
+ } else if (line.startsWith('branch refs/heads/')) {
+ branch = line.slice('branch refs/heads/'.length)
+ } else if (line === 'locked' || line.startsWith('locked ')) {
+ locked = true
+ } else if (line === 'prunable' || line.startsWith('prunable ')) {
+ prunable = true
+ }
+ }
+ return rows
+}
+
+/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
+export function parseLogLines(output: string): GitLogEntry[] {
+ const rows: GitLogEntry[] = []
+ for (const line of output.split('\n')) {
+ if (line === '') continue
+ const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
+ if (hash === undefined || subject === undefined) continue
+ rows.push({
+ hash,
+ subject,
+ author: author ?? '',
+ date: date ?? '',
+ hashFull: hashFull ?? hash,
+ refs: refs ?? '',
+ })
+ }
+ return rows
+}
+
+/** Run one git command; resolves with stdout, rejects with GitCommandError. */
+function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise {
+ const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
+ return new Promise((resolvePromise, reject) => {
+ const child = spawn('git', full, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
+ })
+ let stdout = ''
+ let stderr = ''
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL')
+ reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
+ }, timeoutMs)
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
+ child.on('error', (error) => {
+ clearTimeout(timer)
+ reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
+ })
+ child.on('close', (code) => {
+ clearTimeout(timer)
+ if (code === 0) {
+ resolvePromise(stdout)
+ } else {
+ reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
+ }
+ })
+ })
+}
+
+/** Cap on child directories probed by the workspace-container fallback scan.
+ * A home-directory cwd can hold hundreds of visible folders (Library, iCloud
+ * mounts…); probing them all serially is what froze the panel in #369. */
+const DISCOVERY_LIMIT = 200
+/** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
+ * a healthy checkout; a probe that needs longer is a stalled mount and is
+ * better abandoned than waited on. */
+const DISCOVERY_TIMEOUT_MS = 5_000
+/** Discovery results are cheap to recompute but expensive to storm: the panel
+ * polls every 2s and each poll fans out into several git.* calls that all
+ * resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
+const DISCOVERY_CACHE_TTL_MS = 60_000
+
+const repoRootsCache = new Map()
+const repoRootsInFlight = new Map>()
+
+/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
+ * Probe timeout is short: a cwd on a stalled mount must not hold the panel
+ * hostage for the full command budget (issue #369). */
+export async function isGitRepo(cwd: string): Promise {
+ try {
+ const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], DISCOVERY_TIMEOUT_MS)
+ return out.trim() === 'true'
+ } catch {
+ return false
+ }
+}
+
+/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
+async function directRepoRoot(cwd: string): Promise {
+ const out = await runGit(cwd, ['rev-parse', '--show-toplevel'], DISCOVERY_TIMEOUT_MS)
+ return out.trim()
+}
+
+/** Discover the current repository or direct child repositories. Results are
+ * cached per cwd and concurrent callers share one in-flight scan, so opening
+ * the panel (three parallel git.* requests) costs a single discovery pass. */
+export function repoRoots(cwd: string): Promise {
+ const cached = repoRootsCache.get(cwd)
+ if (cached !== undefined && cached.expires > Date.now()) return Promise.resolve(cached.roots)
+ const pending = repoRootsInFlight.get(cwd)
+ if (pending !== undefined) return pending
+ const promise = discoverRepoRoots(cwd).then(
+ (roots) => {
+ repoRootsCache.set(cwd, { roots, expires: Date.now() + DISCOVERY_CACHE_TTL_MS })
+ repoRootsInFlight.delete(cwd)
+ return roots
+ },
+ (error: unknown) => {
+ repoRootsInFlight.delete(cwd)
+ throw error
+ },
+ )
+ repoRootsInFlight.set(cwd, promise)
+ return promise
+}
+
+async function discoverRepoRoots(cwd: string): Promise {
+ try {
+ return [await directRepoRoot(cwd)]
+ } catch {
+ const entries = await readdir(cwd, { withFileTypes: true }).catch(() => [])
+ const roots: string[] = []
+ for (const entry of entries
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
+ .sort((left, right) => left.name.localeCompare(right.name))
+ .slice(0, DISCOVERY_LIMIT)) {
+ try {
+ const root = await directRepoRoot(join(cwd, entry.name))
+ if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
+ } catch {
+ // Ordinary child directory; keep discovering sibling repositories.
+ }
+ }
+ return roots
+ }
+}
+
+/** Resolve the selected repository, defaulting to the first discovered root. */
+export async function repoRoot(cwd: string, selected?: string): Promise {
+ const roots = await repoRoots(cwd)
+ if (roots.length === 0) throw new GitCommandError('not a git repository', 'not-repo', 'rev-parse')
+ // Git for Windows may return forward-slash roots while callers pass
+ // backslashes (or vice-versa); compare via the platform-aware identity.
+ if (selected !== undefined) {
+ const identity = pathIdentity(selected)
+ const match = roots.find(root => pathIdentity(root) === identity)
+ if (match !== undefined) return match
+ }
+ return roots[0]!
+}
+
+/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
+export async function currentBranch(cwd: string): Promise {
+ const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
+ return out.trim()
+}
+
+/** Upper bound on status rows shipped to the client. Beyond this the result
+ * is truncated (with `truncated: true`) so a pathological untracked set —
+ * e.g. the working tree discovered under a home-directory cwd — cannot
+ * freeze the browser main thread on JSON parse or list render (#369). */
+const GIT_STATUS_LIMIT = 2_000
+
+/**
+ * Working-tree status (untracked included). `--untracked-files=all` lists
+ * the contents of new directories as individual entries, while preserving
+ * repository discovery and explicit repository selection for workspace roots.
+ */
+export async function status(cwd: string, selected?: string): Promise {
+ const repositories = await repoRoots(cwd)
+ if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] }
+ const root = await repoRoot(cwd, selected)
+ const [branch, raw] = await Promise.all([
+ currentBranch(root).catch(() => 'HEAD'),
+ runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
+ ])
+ const parsed = parsePorcelainZ(raw)
+ const truncated = parsed.length > GIT_STATUS_LIMIT
+ return {
+ isRepo: true,
+ branch,
+ entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
+ truncated,
+ root,
+ repositories,
+ }
+}
+
+/** Platform-aware identity used only for comparing absolute checkout roots. */
+function pathIdentity(path: string): string {
+ const absolute = resolve(path).replace(/[\\/]+$/, '')
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
+}
+
+/** Whether the current Git binary supports NUL-framed `worktree list` output.
+ * Git < 2.36 rejects `-z`; cache the capability after the first attempt so
+ * the SCM panel's polling does not repeatedly spawn a command known to fail. */
+let worktreeListSupportsZ: boolean | undefined
+
+/** Raw usable checkout records, shared by inventory and target validation.
+ * Prunable records point at missing paths and are deliberately excluded from
+ * both the selector and the command-target allowlist. */
+async function listedWorktrees(cwd: string): Promise {
+ let raw: string
+ if (worktreeListSupportsZ === false) {
+ raw = await runGit(cwd, ['worktree', 'list', '--porcelain'])
+ } else {
+ try {
+ raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
+ worktreeListSupportsZ = true
+ } catch {
+ worktreeListSupportsZ = false
+ raw = await runGit(cwd, ['worktree', 'list', '--porcelain'])
+ }
+ }
+ return parseWorktreeList(raw).filter(entry => !entry.prunable)
+}
+
+/** All linked checkouts of the repository containing `cwd`, enriched with a
+ * live change count. The current checkout is first so a single-worktree repo
+ * preserves the old UI ordering. */
+export async function worktrees(cwd: string): Promise {
+ if (!await isGitRepo(cwd)) return []
+ const currentRoot = await repoRoot(cwd)
+ const listed = await listedWorktrees(cwd)
+ const rows = await Promise.all(listed.map(async (entry): Promise => ({
+ path: entry.path,
+ branch: entry.branch,
+ current: pathIdentity(entry.path) === pathIdentity(currentRoot),
+ // One stale/permission-raced linked checkout must not hide the valid
+ // current repository from the panel. Targeted operations still fail loud.
+ changes: await status(entry.path).then(result => result.entries.length, () => 0),
+ })))
+ return rows.sort((left, right) => Number(right.current) - Number(left.current))
+}
+
+/** Resolve an optional client-selected linked checkout. A caller may never use
+ * this seam to point Git operations at an unrelated repository: the target
+ * must occur in the authoritative session repository's worktree list. */
+export async function resolveWorktree(cwd: string, requested?: string): Promise {
+ if (requested === undefined || requested === '') return cwd
+ const identity = pathIdentity(requested)
+ const match = (await listedWorktrees(cwd)).find(entry => pathIdentity(entry.path) === identity)
+ if (match === undefined) {
+ throw new GitCommandError(`unknown linked worktree: ${requested}`, 'git-worktree', 'worktree list')
+ }
+ return match.path
+}
+
+/** Diff text of the worktree (unstaged) or the index (staged). */
+export async function diff(cwd: string, path: string | undefined, staged: boolean, selected?: string): Promise {
+ const root = await repoRoot(cwd, selected)
+ const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
+ if (staged) args.push('--cached')
+ if (path !== undefined) args.push('--', path)
+ return runGit(root, args)
+}
+
+/** Stage paths (all when path is undefined). */
+export async function stage(cwd: string, path: string | undefined, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
+}
+
+/** Unstage paths (all when path is undefined). */
+export async function unstage(cwd: string, path: string | undefined, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
+}
+
+/** Commit the staged changes with a message (global identity untouched). */
+export async function commit(cwd: string, message: string, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['commit', '-m', message])
+}
+
+/** Branch names (current first). */
+export async function branches(cwd: string, selected?: string): Promise<{ current: string; names: string[] }> {
+ const root = await repoRoot(cwd, selected)
+ const [current, raw] = await Promise.all([
+ currentBranch(root).catch(() => 'HEAD'),
+ runGit(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
+ ])
+ const names = raw.split('\n').filter(line => line !== '')
+ return { current, names: names.includes(current) ? names : [current, ...names] }
+}
+
+/** Switch to an existing branch. */
+export async function checkout(cwd: string, branch: string, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['checkout', branch])
+}
+
+/** Recent commit history (newest first), lazily pageable via skip/count. */
+export async function log(cwd: string, count = 30, skip = 0, selected?: string): Promise {
+ const raw = await runGit(await repoRoot(cwd, selected), [
+ 'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
+ '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
+ ])
+ return parseLogLines(raw)
+}
+
+/**
+ * Content of a file at a revision (`git show :`), or null when the
+ * revision has no such path (a new/untracked file has no HEAD side).
+ */
+export async function show(cwd: string, rev: string, path: string, selected?: string): Promise {
+ try {
+ return await runGit(await repoRoot(cwd, selected), ['show', `${rev}:${path}`])
+ } catch {
+ return null
+ }
+}
+
+/** Full patch text of one commit (`git show` with the commit header suppressed).
+ * Merge commits show their diff against the first parent (`-m --first-parent`
+ * is a no-op for regular commits), so a history click always has content. */
+export async function commitDiff(cwd: string, hash: string, selected?: string): Promise {
+ return runGit(await repoRoot(cwd, selected), ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
+}
+
+/** Discard the worktree changes of one path (`git checkout -- `; the index is untouched). */
+export async function discard(cwd: string, path: string, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['checkout', '--', path])
+}
+
+/** Revert one commit onto the current branch with an auto-generated message. */
+export async function revert(cwd: string, hash: string, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['revert', '--no-edit', hash])
+}
+
+/** Cherry-pick one commit onto the current branch. */
+export async function cherryPick(cwd: string, hash: string, selected?: string): Promise {
+ await runGit(await repoRoot(cwd, selected), ['cherry-pick', hash])
+}
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/LICENSE b/apps/deepviewer-adapter/workflows/vendor/synapse/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/UPSTREAM.json b/apps/deepviewer-adapter/workflows/vendor/synapse/UPSTREAM.json
new file mode 100644
index 0000000..5e772af
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/UPSTREAM.json
@@ -0,0 +1,6 @@
+{
+ "repository": "https://github.com/liangmianya/dsh-synapse",
+ "version": "0.4.1",
+ "commit": "56935dc1862e7791b212f6eb2dd26404def5a575",
+ "integration": "Store retained; authenticated RPC and native client adapters owned by DeepViewer"
+}
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/app.js b/apps/deepviewer-adapter/workflows/vendor/synapse/app.js
new file mode 100644
index 0000000..b57b4bc
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/app.js
@@ -0,0 +1,1806 @@
+const app = document.querySelector('#app')
+if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
+const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
+const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
+const COLLAPSED_CARDS_KEY = 'dsh-synapse:collapsed-cards:v1'
+const QUICK_PHRASES_KEY = 'dsh-synapse:quick-phrases:v1'
+const DEFAULT_QUICK_PHRASES = ['展开说明', '举例', '通俗易懂', '对比解释']
+const MAX_QUICK_PHRASES = 12
+const MAX_QUICK_PHRASE_LENGTH = 16
+function normalizeQuickPhrases(value) {
+ if (!Array.isArray(value)) return []
+ const phrases = []
+ for (const item of value) {
+ const phrase = typeof item === 'string' ? item.trim().slice(0, MAX_QUICK_PHRASE_LENGTH) : ''
+ if (phrase !== '' && !phrases.includes(phrase)) phrases.push(phrase)
+ if (phrases.length === MAX_QUICK_PHRASES) break
+ }
+ return phrases
+}
+const savedQuickPhrases = (() => {
+ try {
+ const stored = localStorage.getItem(QUICK_PHRASES_KEY)
+ return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
+ } catch { return DEFAULT_QUICK_PHRASES }
+})()
+const savedBranchAnchors = (() => {
+ try {
+ const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
+ } catch { return [] }
+})()
+const savedCardPositions = (() => {
+ try {
+ // Drop formats that were never persisted; the current key stores drags.
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
+ const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && item[1] !== null && Number.isFinite(item[1].x) && Number.isFinite(item[1].y)) : []
+ } catch { return [] }
+})()
+const savedCollapsedCards = (() => {
+ try {
+ const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
+ } catch { return [] }
+})()
+const CARD_WIDTH = 310
+const CARD_HEIGHT = 276
+const CARD_GAP_Y = 42
+const CAMERA_INSET_X = 56
+const CAMERA_INSET_Y = 56
+// Cards outside the viewport (plus this world-space margin) are not mounted
+// into the DOM; the margin pre-mounts cards just before they scroll into view
+// so panning never flashes empty space.
+const VIEWPORT_MARGIN = 1400
+const state = {
+ summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
+ dshWorkspaces: [], selectedDshWorkspaceId: null,
+ historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
+ draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
+ dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
+ expandedMessageIds: new Set(),
+ canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
+ detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
+ inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
+}
+
+const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]))
+const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
+const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
+
+function rememberBranchAnchor(sessionId, cardId) {
+ state.branchAnchors.set(sessionId, cardId)
+ try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
+}
+
+function persistCardPositions() {
+ try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
+}
+
+function persistCollapsedCards() {
+ try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
+}
+
+function persistQuickPhrases() {
+ try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
+}
+
+function rememberCardPosition(cardId, position, aliases = []) {
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
+ persistCardPositions()
+}
+
+function resetCardPositions() {
+ state.cardPositions.clear()
+ persistCardPositions()
+ try {
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
+ } catch { /* Private browsing may disable local storage. */ }
+}
+
+function resetCanvasCamera() {
+ state.canvasViewInitialized = false
+ state.canvasCamera = { x: 0, y: 0 }
+}
+
+async function api(path, options = {}) {
+ const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
+ const body = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(body.error ?? '请求失败')
+ return body
+}
+
+function post(type, payload = {}) {
+ if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
+}
+
+function dshRpc(type, payload = {}) {
+ if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
+ const requestId = crypto.randomUUID()
+ post(type, { requestId, ...payload })
+ return new Promise((resolve, reject) => {
+ const timer = window.setTimeout(() => {
+ state.pendingRpc.delete(requestId)
+ reject(new Error('DSH 未在规定时间内响应'))
+ }, 20_000)
+ state.pendingRpc.set(requestId, { resolve, reject, timer })
+ })
+}
+
+function settleRpc(requestId, value, error) {
+ const pending = state.pendingRpc.get(requestId)
+ if (pending === undefined) return
+ state.pendingRpc.delete(requestId)
+ window.clearTimeout(pending.timer)
+ if (error === undefined) pending.resolve(value)
+ else pending.reject(error instanceof Error ? error : new Error(String(error)))
+}
+
+function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
+
+function messagesFromEvents(events) {
+ if (!Array.isArray(events)) return []
+ return events.flatMap(event => {
+ const content = event?.data?.message?.content ?? event?.data?.content
+ const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
+ if (event?.type === 'user/message' && text && !text.startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')) return [{ kind: 'user', text, at: event.time, sourceSeq: event.seq }]
+ if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
+ return []
+ })
+}
+
+async function loadThreadHistory() {}
+
+function canReplaceView() {
+ return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
+}
+
+function deferCanvasRefresh(delay = 700) {
+ state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
+}
+
+function currentDshWorkspace() {
+ const id = state.currentDsh?.id
+ return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
+}
+
+function selectedDshWorkspace() {
+ return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
+}
+
+function currentDshThread(threads = state.workspace?.threads ?? []) {
+ const id = state.currentDsh?.id
+ return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
+}
+
+function workspaceChoices() {
+ if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
+ return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
+}
+
+async function threadsForDshWorkspace(workspace) {
+ if (workspace.sessionIds.length === 0) return []
+ const requested = new Set(workspace.sessionIds)
+ const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
+ return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
+}
+
+async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
+ const workspace = state.dshWorkspaces.find(item => item.id === id)
+ if (workspace === undefined) return false
+ const load = ++state.workspaceLoad
+ state.selectedDshWorkspaceId = id
+ const threads = await threadsForDshWorkspace(workspace)
+ if (load !== state.workspaceLoad) return true
+ const nextWorkspaceId = `dsh:${workspace.id}`
+ if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera) resetCanvasCamera()
+ state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
+ const currentThread = currentDshThread(state.workspace.threads)
+ state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
+ if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
+ if (renderAfter && canReplaceView()) render()
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
+ return true
+}
+
+async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
+ const workspace = currentDshWorkspace()
+ if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
+ return openDshWorkspace(workspace.id, { preserveCanvasCamera })
+}
+
+async function refreshSummaries({ renderAfter = true } = {}) {
+ const before = JSON.stringify(state.summaries)
+ const body = await api('/synapse/api/workspaces')
+ state.summaries = body.workspaces
+ const changed = before !== JSON.stringify(state.summaries)
+ const current = state.workspace?.id
+ if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
+ const selected = selectedDshWorkspace()
+ if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
+ else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
+ else if (renderAfter && changed && canReplaceView()) render()
+ return changed
+}
+
+async function openWorkspace(id, { renderAfter = true } = {}) {
+ const load = ++state.workspaceLoad
+ const body = await api(`/synapse/api/workspaces/${id}`)
+ if (load !== state.workspaceLoad) return
+ if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
+ state.workspace = body.workspace
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
+ if (renderAfter && canReplaceView()) render()
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
+}
+
+async function refreshProjection() {
+ const summariesChanged = await refreshSummaries({ renderAfter: false })
+ if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
+ if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
+ else await openWorkspace(state.workspace.id)
+ return true
+}
+
+function openNewSession() {
+ if (state.draft !== null) return
+ state.mode = 'canvas'
+ state.activeId = null
+ state.selectedCardId = null
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ state.quickPhraseEditorOpen = false
+ state.draft = { kind: 'new', text: '', sending: false }
+ state.error = ''
+ resetCanvasCamera()
+ render()
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
+}
+
+async function archiveThread(thread) {
+ if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
+ await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
+ state.historyBySession.delete(thread.dshSessionId)
+ state.detailScrollByThread.delete(thread.id)
+ state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
+ if (state.workspace !== null) {
+ const removed = new Set([thread.id])
+ for (let changed = true; changed;) {
+ changed = false
+ for (const item of state.workspace.threads) {
+ if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
+ removed.add(item.id)
+ changed = true
+ }
+ }
+ }
+ state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
+ for (const key of [...state.cardPositions.keys()]) {
+ if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
+ }
+ let collapsedChanged = false
+ for (const key of [...state.collapsedCardIds]) {
+ if ([...removed].some(id => key.startsWith(`${id}:`))) {
+ state.collapsedCardIds.delete(key)
+ collapsedChanged = true
+ }
+ }
+ if (collapsedChanged) persistCollapsedCards()
+ state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
+ ? state.activeId
+ : state.workspace.threads[0]?.id ?? null
+ render()
+ } else {
+ state.activeId = null
+ }
+ await refreshSummaries()
+}
+
+function focusDraftInput() {
+ const input = document.querySelector('[data-draft] textarea')
+ if (!(input instanceof HTMLTextAreaElement)) return
+ input.focus()
+ input.setSelectionRange(input.value.length, input.value.length)
+}
+
+function openContinue(parent, anchorId = undefined, text = '') {
+ if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
+ state.activeId = parent.id
+ state.quickPhraseEditorOpen = false
+ state.draft = { kind: 'continue', parentId: parent.id, anchorId, text, sending: false }
+ render()
+ window.setTimeout(focusDraftInput, 0)
+}
+
+function openBranch(parent, atSeq = undefined, anchorId = undefined) {
+ if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
+ state.activeId = parent.id
+ state.quickPhraseEditorOpen = false
+ state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
+ render()
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
+}
+
+async function sendMessage(thread, text) {
+ if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
+ if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
+ state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
+ state.error = ''
+ render()
+ try {
+ await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
+ void loadThreadHistory(thread)
+ } catch (error) {
+ state.pendingReplies.delete(thread.dshSessionId)
+ render()
+ throw error
+ }
+}
+
+async function submitDraft() {
+ const draft = state.draft
+ const text = draft?.text.trim()
+ if (draft === null || !text) return
+ const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
+ draft.sending = true
+ state.error = ''
+ render()
+ try {
+ if (draft.kind === 'new') {
+ const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
+ await dshRpc('synapse:send-message', { sessionId: session.id, text })
+ state.draft = null
+ render()
+ window.setTimeout(() => {
+ void refreshProjection().catch(() => {})
+ }, 150)
+ return
+ }
+ const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
+ if (parent === undefined) throw new Error('来源会话不存在')
+ if (draft.kind === 'continue') {
+ state.draft = null
+ await sendMessage(parent, text)
+ return
+ }
+ const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
+ if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
+ const result = await api(`/synapse/api/threads/${parent.id}/branch`, { method: 'POST', body: JSON.stringify({ title: text.slice(0, 42), dshSessionId: session.id, dshSessionTitle: session.title, position: branchPosition }) })
+ if (state.workspace !== null && !state.workspace.threads.some(thread => thread.id === result.thread.id || thread.dshSessionId === result.thread.dshSessionId)) state.workspace.threads.push(result.thread)
+ state.activeId = result.thread.id
+ state.draft = null
+ state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
+ render()
+ await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
+ void loadThreadHistory(result.thread)
+ await refreshProjection()
+ } catch (error) {
+ if (draft.kind === 'branch') {
+ state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
+ if (state.draft !== null) state.draft = { ...draft, sending: false }
+ } else {
+ state.draft = { ...draft, sending: false }
+ }
+ setError(error)
+ }
+}
+
+function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
+function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
+
+function pendingUserIndex(messages, pending) {
+ return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
+}
+
+function settlePendingReply(thread, messages) {
+ const pending = state.pendingReplies.get(thread.dshSessionId)
+ if (pending === undefined) return false
+ const userIndex = pendingUserIndex(messages, pending)
+ if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
+ state.pendingReplies.delete(thread.dshSessionId)
+ return true
+}
+
+function messagesFor(thread) {
+ // A runtime-context snapshot is internal DSH state, never a user turn.
+ // Filter here as well as during persistence so existing saved workspaces
+ // immediately render one question and its answer as one card.
+ const messages = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
+ const pending = state.pendingReplies.get(thread.dshSessionId)
+ if (pending === undefined) return messages
+ if (settlePendingReply(thread, messages)) {
+ state.liveReplies.delete(thread.dshSessionId)
+ return messages
+ }
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
+ const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
+ const userIndex = pendingUserIndex(messages, pending)
+ if (userIndex !== -1) return [...messages, liveAssistant]
+ return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
+}
+
+function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
+function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
+function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
+
+function inlineMarkdown(text) {
+ return escapeHtml(text)
+ .replace(/`([^`\n]+)`/g, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ .replace(/~~([^~]+)~~/g, '$1 ')
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ')
+}
+
+const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
+
+const isTableDelimiter = line => {
+ const cells = tableCells(line)
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
+}
+
+function markdownBlock(text) {
+ const lines = text.split('\n')
+ const output = []
+ for (let index = 0; index < lines.length;) {
+ const line = lines[index]
+ if (line.trim() === '') { index++; continue }
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
+ if (heading !== null) {
+ const level = heading[1].length
+ output.push(`${inlineMarkdown(heading[2])} `)
+ index++
+ continue
+ }
+ const unordered = /^[-*+]\s+(.+)$/.exec(line)
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
+ if (unordered !== null || ordered !== null) {
+ const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
+ const items = []
+ while (index < lines.length) {
+ const item = matcher.exec(lines[index])
+ if (item === null) break
+ items.push(`${inlineMarkdown(item[1])} `)
+ index++
+ }
+ output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}${unordered === null ? 'ol' : 'ul'}>`)
+ continue
+ }
+ // GFM table: a leading-pipe header row followed by a |-delimiter row,
+ // then any number of leading-pipe body rows.
+ if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
+ const header = line
+ const body = []
+ index += 2
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
+ body.push(lines[index])
+ index++
+ }
+ output.push(`${tableCells(header).map(cell => `${inlineMarkdown(cell)} `).join('')} ${body.map(row => `${tableCells(row).map(cell => `${inlineMarkdown(cell)} `).join('')} `).join('')}
`)
+ continue
+ }
+ const paragraph = []
+ while (index < lines.length && lines[index].trim() !== '' && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index]) && !/^\d+[.)]\s+/.test(lines[index])) paragraph.push(lines[index++])
+ // A marker-only line such as PowerShell's "+ " diagnostic is neither a
+ // list item nor paragraph content under the rules above. Consume it so
+ // the parser always makes progress.
+ if (paragraph.length === 0) paragraph.push(lines[index++])
+ output.push(`${paragraph.map(inlineMarkdown).join(' ')}
`)
+ }
+ return output.join('')
+}
+
+// Markdown parsing is pure CPU and repeats for every card on every canvas
+// rebuild; cache the rendered HTML by input text so stable answers are never
+// re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
+const markdownCache = new Map()
+const MARKDOWN_CACHE_LIMIT = 5000
+function renderMarkdown(text) {
+ const key = String(text)
+ const cached = markdownCache.get(key)
+ if (cached !== undefined) return cached
+ const parts = key.split(/```/)
+ const rendered = parts.map((part, index) => index % 2 === 1
+ ? `${escapeHtml(part.replace(/^\w*\n/, ''))} `
+ : markdownBlock(part)).join('')
+ if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
+ markdownCache.set(key, rendered)
+ return rendered
+}
+
+function overlapsCard(position, other) {
+ return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
+ && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
+}
+
+function firstAvailableCardPosition(position, occupied) {
+ const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
+ while (true) {
+ const collisions = occupied.filter(other => overlapsCard(candidate, other))
+ if (collisions.length === 0) return candidate
+ candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
+ }
+}
+
+function connectorPath(fromPosition, toPosition) {
+ const fromX = fromPosition.x + CARD_WIDTH
+ const fromY = fromPosition.y + CARD_HEIGHT / 2
+ const toX = toPosition.x
+ const toY = toPosition.y + CARD_HEIGHT / 2
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
+}
+
+function connectorPathFromElements(fromCard, toCard) {
+ const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
+ const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
+ const toX = Number.parseFloat(toCard.style.left)
+ const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
+ if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
+}
+
+function selectorValue(value) {
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+}
+
+// Connector paths are rebuilt together with the canvas DOM; cache the mapping
+// from card id to its incident paths so dragging never scans the whole SVG.
+let connectorPathsByCard = new Map()
+function cacheCardConnectors() {
+ connectorPathsByCard = new Map()
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ for (const path of viewport.querySelectorAll('.connectors path[data-from]')) {
+ const fromId = path.getAttribute('data-from')
+ const toId = path.getAttribute('data-to')
+ if (fromId === null || toId === null) continue
+ for (const id of [fromId, toId]) {
+ const paths = connectorPathsByCard.get(id)
+ if (paths === undefined) connectorPathsByCard.set(id, new Set([path]))
+ else paths.add(path)
+ }
+ }
+}
+
+function refreshCardConnectors(cardId) {
+ const paths = connectorPathsByCard.get(cardId)
+ if (paths === undefined || paths.size === 0) return
+ const byId = state.canvasCardsById
+ if (byId === undefined) return
+ for (const path of paths) {
+ const fromId = path.getAttribute('data-from')
+ const toId = path.getAttribute('data-to')
+ if (fromId === null || toId === null) continue
+ const fromCard = byId.get(fromId)
+ const toCard = byId.get(toId)
+ if (fromCard === undefined || toCard === undefined) continue
+ // Data-driven endpoints: the counterpart card may be unmounted (outside
+ // the viewport) but its position is still authoritative.
+ path.setAttribute('d', connectorPath(fromCard.position, toCard.position))
+ }
+}
+
+function initialCanvasCamera(cards) {
+ const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
+ // Focus the active conversation's latest turn, not its first: after many
+ // rounds the canvas should open where work is happening, at the newest card.
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
+ const active = activeCards.at(-1)
+ const focus = draft ?? active ?? cards[0]
+ const position = focus?.position
+ if (position === undefined) return { x: 0, y: 0 }
+ return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
+}
+
+function placeConversationCards(cards) {
+ const saved = new Map(cards.flatMap(card => {
+ if (card.positionLocked !== true) return []
+ const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
+ return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
+ }))
+ const occupied = []
+ for (const card of cards) {
+ const position = saved.get(card.id)
+ if (position !== undefined) {
+ card.position = position
+ continue
+ }
+ card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
+ occupied.push(card.position)
+ }
+ return cards
+}
+
+function layoutConversationGraph(cards, threads) {
+ const childrenByThread = new Map()
+ for (const thread of threads) {
+ if (thread.parentId === null) continue
+ const children = childrenByThread.get(thread.parentId) ?? []
+ children.push(thread.id)
+ childrenByThread.set(thread.parentId, children)
+ }
+ const laneByThread = new Map()
+ const visitThread = threadId => {
+ if (laneByThread.has(threadId)) return
+ laneByThread.set(threadId, laneByThread.size)
+ for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
+ }
+ for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
+ for (const thread of threads) visitThread(thread.id)
+
+ const byId = new Map(cards.map(card => [card.id, card]))
+ const positioned = new Map()
+ const positionFor = (card, visiting = new Set()) => {
+ if (positioned.has(card.id)) return positioned.get(card.id)
+ if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
+ visiting.add(card.id)
+ const parent = card.parentId === null ? undefined : byId.get(card.parentId)
+ const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
+ const position = {
+ x: parentPosition === undefined ? 86 : parentPosition.x + 365,
+ y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
+ }
+ visiting.delete(card.id)
+ positioned.set(card.id, position)
+ return position
+ }
+ for (const card of cards) {
+ card.naturalPosition = positionFor(card)
+ if (!card.positionLocked) card.position = card.naturalPosition
+ }
+ return placeConversationCards(cards)
+}
+
+function conversationCards(threads) {
+ const cards = []
+ const cardsByThread = new Map()
+ for (const thread of threads) {
+ const messages = messagesFor(thread)
+ const turns = []
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
+ const question = messages[messageIndex]
+ if (question.kind !== 'user') continue
+ const replies = []
+ const errors = []
+ let processCount = 0
+ for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
+ const reply = messages[replyIndex]
+ if (reply.kind === 'user') break
+ if (reply.kind === 'assistant') replies.push(reply)
+ if (reply.kind === 'error') errors.push(reply)
+ if (Array.isArray(reply.process)) processCount += reply.process.length
+ else if (reply.kind === 'tool') processCount += 1
+ }
+ const answer = replies.at(-1) ?? null
+ const error = errors.at(-1) ?? null
+ const turnIndex = turns.length
+ const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
+ const previous = turns.at(-1)
+ const positionKey = `${thread.id}:turn-index:${turnIndex}`
+ const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
+ const positionLocked = savedPosition !== undefined
+ const position = positionLocked ? savedPosition : naturalPosition
+ turns.push({
+ id,
+ positionKey,
+ dshThreadId: thread.id,
+ sourceParentId: thread.parentId,
+ parentId: null,
+ sourceSeq: question.sourceSeq,
+ turnIndex,
+ naturalPosition,
+ position,
+ positionLocked,
+ question: question.text,
+ answer,
+ error,
+ processCount,
+ })
+ }
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
+ const latestTurn = turns.at(-1)
+ if (liveReply?.running && latestTurn !== undefined && (latestTurn.answer === null || latestTurn.answer.pending === true)) latestTurn.answer = { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() }
+ if (turns.length === 0) {
+ const id = `${thread.id}:turn:empty`
+ const positionKey = `${thread.id}:turn-index:0`
+ const naturalPosition = { x: 86, y: 82 }
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
+ const positionLocked = savedPosition !== undefined
+ turns.push({
+ id,
+ positionKey,
+ dshThreadId: thread.id,
+ sourceParentId: thread.parentId,
+ parentId: null,
+ sourceSeq: undefined,
+ turnIndex: 0,
+ naturalPosition,
+ position: positionLocked ? savedPosition : naturalPosition,
+ positionLocked,
+ question: thread.dshSessionTitle ?? thread.title,
+ answer: null,
+ error: null,
+ processCount: 0,
+ })
+ }
+ turns.at(-1).canContinue = true
+ cardsByThread.set(thread.id, turns)
+ cards.push(...turns)
+ }
+ for (const card of cards) {
+ const siblings = cardsByThread.get(card.dshThreadId)
+ if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
+ else {
+ const parentCards = cardsByThread.get(card.sourceParentId)
+ const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
+ const firstChildQuestion = siblings?.[0]
+ const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
+ // A fork inherits every parent event before DSH's durable seed boundary.
+ // The latest parent question below that boundary is the exact Turn where
+ // this child was born. Canvas coordinates never participate in lineage.
+ const inheritedTurn = Number.isSafeInteger(seedLength)
+ ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
+ : undefined
+ card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
+ }
+ }
+ return layoutConversationGraph(cards, threads)
+}
+
+function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
+ const cardIds = new Set(cards.map(card => card.id))
+ const childrenByParent = new Map()
+ for (const card of cards) {
+ if (card.parentId === null || !cardIds.has(card.parentId)) continue
+ const children = childrenByParent.get(card.parentId) ?? []
+ children.push(card.id)
+ childrenByParent.set(card.parentId, children)
+ }
+
+ const hiddenIds = new Set()
+ for (const rootId of collapsedCardIds) {
+ if (!cardIds.has(rootId)) continue
+ const visited = new Set([rootId])
+ const visit = parentId => {
+ for (const childId of childrenByParent.get(parentId) ?? []) {
+ if (visited.has(childId)) continue
+ visited.add(childId)
+ hiddenIds.add(childId)
+ visit(childId)
+ }
+ }
+ visit(rootId)
+ }
+
+ // Persisted collapse roots must remain visible even if malformed metadata
+ // contains a cycle where two collapsed nodes otherwise hide each other.
+ for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
+
+ // Post-order accumulation: each card's descendant count is 1 + the sum of
+ // its children's subtree sizes, so the whole graph is O(n) instead of a BFS
+ // from every card (O(n²) on deep chains). Malformed parent cycles are
+ // detected through the DFS path: every member of a cycle reaches every other
+ // member plus the union of their off-cycle subtrees, so when the cycle entry
+ // pops last, all members are settled to (cycleSize - 1) + off-cycle total,
+ // which matches the per-card BFS' unique-descendant count.
+ const descendantCounts = new Map()
+ const inStack = new Set()
+ for (const card of cards) {
+ if (descendantCounts.has(card.id)) continue
+ const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }]
+ const path = [card.id]
+ let cycleEntry = null
+ let cycleMembers = null
+ let cycleOffCycleTotal = 0
+ inStack.add(card.id)
+ while (stack.length > 0) {
+ const top = stack[stack.length - 1]
+ if (top.index < top.children.length) {
+ const childId = top.children[top.index++]
+ if (descendantCounts.has(childId)) continue
+ if (inStack.has(childId)) {
+ // Back edge: the nodes from childId up to top.id form a cycle.
+ cycleEntry = childId
+ cycleMembers = new Set(path.slice(path.indexOf(childId)))
+ cycleOffCycleTotal = 0
+ continue
+ }
+ inStack.add(childId)
+ path.push(childId)
+ stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 })
+ } else {
+ stack.pop()
+ path.pop()
+ inStack.delete(top.id)
+ let count = 0
+ for (const childId of top.children) {
+ if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below
+ count += 1 + (descendantCounts.get(childId) ?? 0)
+ }
+ if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count
+ if (cycleMembers !== null && top.id === cycleEntry) {
+ // All cycle members have popped (the entry pops last in post-order);
+ // settle them so ancestors popping next read the final counts.
+ const base = cycleMembers.size - 1
+ for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal)
+ cycleEntry = null
+ cycleMembers = null
+ } else {
+ descendantCounts.set(top.id, count)
+ }
+ }
+ }
+ }
+
+ return {
+ cards: cards.filter(card => !hiddenIds.has(card.id)),
+ childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
+ descendantCounts,
+ }
+}
+
+function revealConversationThread(cards, threadId) {
+ const byId = new Map(cards.map(card => [card.id, card]))
+ let changed = false
+ for (const target of cards.filter(card => card.dshThreadId === threadId)) {
+ const visited = new Set([target.id])
+ let parentId = target.parentId
+ while (parentId !== null && !visited.has(parentId)) {
+ visited.add(parentId)
+ if (state.collapsedCardIds.delete(parentId)) changed = true
+ parentId = byId.get(parentId)?.parentId ?? null
+ }
+ }
+ if (changed) persistCollapsedCards()
+}
+
+function canvasConnectors(cards) {
+ const index = new Map(cards.map(card => [card.id, card]))
+ const links = cards.map(card => {
+ const parent = card.parentId === null ? null : index.get(card.parentId)
+ if (parent === undefined || parent === null) return ''
+ const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
+ return ` `
+ })
+ const placement = draftPlacement(cards)
+ if (placement !== null) {
+ links.push(` `)
+ }
+ return links.join('')
+}
+
+function conversationCard(card, graph) {
+ const selected = card.id === state.selectedCardId ? 'selected' : ''
+ const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
+ const continueButton = card.canContinue === true
+ ? ` `
+ : ''
+ const childCount = graph.childCounts.get(card.id) ?? 0
+ const collapsed = state.collapsedCardIds.has(card.id)
+ const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
+ const foldButton = childCount === 0 || card.canContinue === true ? '' : ` ${collapsed ? ' ' : ''} `
+ const branchButton = childCount === 0 || card.canContinue === true || !Number.isInteger(card.answer?.sourceSeq) ? '' : ` `
+ return `
+
+ ${continueButton}${foldButton}${branchButton}
+ ${escapeHtml(card.question)}
+ ${source} 第 ${card.turnIndex + 1} 轮 ${card.error === null ? '' : '失败 '}${card.processCount > 0 ? `工具 ${card.processCount} ` : ''}
+ ${card.answer === null ? (card.error === null ? '
等待助手回复
' : '') : card.answer.pending && card.answer.text === '' ? '
正在回复
' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '
正在回复
' : ''}`}${card.error === null ? '' : `
本轮失败:${escapeHtml(card.error.text)}
`}
+
+ `
+}
+
+function draftActions(draft) {
+ const disabled = draft.sending ? 'disabled' : ''
+ return ``
+}
+
+function quickPhraseEditor(draft) {
+ const disabled = draft.sending ? 'disabled' : ''
+ const phrases = state.quickPhrases.map((phrase, index) => ``).join('')
+ return ``
+}
+
+function draftQuickPhrases(draft) {
+ const disabled = draft.sending ? 'disabled' : ''
+ if (state.quickPhraseEditorOpen) return quickPhraseEditor(draft)
+ const phrases = state.quickPhrases.map(phrase => `${escapeHtml(phrase)} `).join('')
+ return ``
+}
+
+function insertQuickPhrase(phrase) {
+ const input = document.querySelector('[data-draft] textarea')
+ if (!(input instanceof HTMLTextAreaElement) || state.draft === null) return
+ const start = input.selectionStart
+ const end = input.selectionEnd
+ const prefix = input.value.slice(0, start)
+ const suffix = input.value.slice(end)
+ const separator = prefix !== '' && !prefix.endsWith('\n') ? '\n' : ''
+ const text = `${prefix}${separator}${phrase}${suffix}`
+ if (text.length > input.maxLength) return setError('追问内容不能超过 4000 个字符')
+ const caret = prefix.length + separator.length + phrase.length
+ input.value = text
+ state.draft.text = text
+ input.focus()
+ input.setSelectionRange(caret, caret)
+}
+
+function addQuickPhrase(value) {
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
+ if (phrase === '') return false
+ if (state.quickPhrases.includes(phrase)) return setError('这个快捷词已经存在')
+ if (state.quickPhrases.length >= MAX_QUICK_PHRASES) return setError(`最多保留 ${MAX_QUICK_PHRASES} 个快捷词`)
+ state.quickPhrases.push(phrase)
+ persistQuickPhrases()
+ return true
+}
+
+function updateQuickPhrase(index, value) {
+ if (!Number.isInteger(index) || index < 0 || index >= state.quickPhrases.length) return
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
+ if (phrase === '') {
+ state.quickPhrases.splice(index, 1)
+ } else if (state.quickPhrases.some((item, itemIndex) => itemIndex !== index && item === phrase)) {
+ return setError('这个快捷词已经存在')
+ } else {
+ state.quickPhrases[index] = phrase
+ }
+ persistQuickPhrases()
+ render()
+}
+
+function draftPlacement(cards) {
+ const draft = state.draft
+ if (draft === null || draft.kind === 'new') return null
+ const parent = draft.anchorId === undefined
+ ? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
+ : cards.find(card => card.id === draft.anchorId)
+ if (parent === undefined) return null
+ return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
+}
+
+function draftCard(cards) {
+ const draft = state.draft
+ if (draft?.kind === 'new') return `
+ 新会话
+
+ `
+ const placement = draftPlacement(cards)
+ if (draft === null || placement === null) return ''
+ const continuing = draft.kind === 'continue'
+ return `
+ ${continuing ? '新的追问' : '新的分支'}
+ ${draftQuickPhrases(draft)}${escapeHtml(draft.text)} ${draftActions(draft)}
+ `
+}
+
+function selectionFollowupButton() {
+ return `追问 `
+}
+
+// Cards are mounted into the DOM only when they intersect the viewport
+// (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
+// translate(camera) scale(zoom), so screen = world * zoom + camera.
+function visibleCardIds(cards) {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
+ const bounds = viewport.getBoundingClientRect()
+ const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
+ const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
+ const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
+ const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
+ const visible = new Set()
+ for (const card of cards) {
+ const { x, y } = card.position
+ if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
+ visible.add(card.id)
+ }
+ return visible
+}
+
+// Incrementally mount cards entering the viewport and unmount cards leaving
+// it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
+function syncCanvasViewport() {
+ if (state.mode !== 'canvas' || state.canvasCards === undefined) return
+ const layer = document.querySelector('.cards-layer')
+ if (!(layer instanceof HTMLElement)) return
+ const visible = visibleCardIds(state.canvasCards)
+ for (const cardId of [...state.mountedCardIds]) {
+ if (visible.has(cardId)) continue
+ const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
+ if (element instanceof HTMLElement) element.remove()
+ state.mountedCardIds.delete(cardId)
+ }
+ for (const card of state.canvasCards) {
+ if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
+ const wrapper = document.createElement('div')
+ wrapper.innerHTML = conversationCard(card, state.canvasGraph)
+ const element = wrapper.firstElementChild
+ if (element instanceof HTMLElement) {
+ layer.appendChild(element)
+ const handle = element.querySelector('[data-drag-card]')
+ if (handle instanceof HTMLElement) bindDragHandle(handle)
+ }
+ state.mountedCardIds.add(card.id)
+ }
+}
+
+function renderCanvas() {
+ const threads = state.workspace?.threads ?? []
+ if (threads.length === 0 && state.draft?.kind !== 'new') return `当前工作目录还没有 DSH 对话。 点击新会话,在画布中输入第一条消息。
新建会话
`
+ const allCards = conversationCards(threads)
+ const graph = conversationGraphView(allCards)
+ const cards = graph.cards
+ state.canvasCards = cards
+ state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
+ state.canvasGraph = graph
+ if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) {
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ }
+ if (!state.canvasViewInitialized) {
+ state.canvasCamera = initialCanvasCamera(cards)
+ state.canvasViewInitialized = true
+ // The viewport is not laid out yet while renderCanvas builds its HTML;
+ // center the focused card once the DOM is mounted (render tail).
+ state.canvasNeedsCenter = true
+ }
+ const visible = visibleCardIds(cards)
+ state.mountedCardIds = new Set(visible)
+ const mounted = cards.filter(card => visible.has(card.id))
+ const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
+ return `${canvasConnectors(cards)} ${mounted.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}
${inspector} `
+}
+
+function isProcessMessage(message) {
+ if (message.kind === 'tool' || message.kind === 'tool-result') return true
+ return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
+}
+
+function processSummary(text) {
+ return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
+}
+
+function threadMessage(thread, message) {
+ const isUser = message.kind === 'user'
+ const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
+ const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
+ ? ` 分支 `
+ : ''
+ const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
+ const collapsible = isProcessMessage(message)
+ const expanded = state.expandedMessageIds.has(messageId)
+ const fold = collapsible ? ` ` : ''
+ const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
+ const body = message.pending && message.text === '' ? ' 正在回复
'
+ : `${collapsible && !expanded ? `${escapeHtml(processSummary(message.text))}
` : renderMarkdown(message.text)}${message.pending ? ' 正在回复
' : ''}${process === null ? '' : processRecords(process, messageId)}`
+ const avatar = isUser ? '' : ' '
+ return `${avatar}${label} ${formatTime(message.at)} ${branch}${fold} ${body}
`
+}
+
+function processRecords(process, messageId) {
+ const key = `${messageId}:process`
+ const expanded = state.expandedMessageIds.has(key)
+ const entries = process.map((entry, index) => {
+ const entryKey = `${key}:${index}`
+ const entryExpanded = state.expandedMessageIds.has(entryKey)
+ const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
+ const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `${escapeHtml(entry.arguments)} `
+ const outcomeHtml = entry.error !== null ? `${escapeHtml(entry.error)} ` : entry.result === null ? '' : `${escapeHtml(entry.result)} `
+ return `${escapeHtml(entry.name)} ${status} ${entryExpanded ? `
${argumentsHtml}${outcomeHtml}
` : ''}
`
+ }).join('')
+ return `${expanded ? '收起过程记录' : '过程记录'} ${process.length} ${expanded ? entries : ''} `
+}
+
+function messagesForCard(card) {
+ const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId)
+ if (thread === undefined) return { thread: null, messages: [] }
+ const messages = messagesFor(thread)
+ let turnIndex = -1
+ let start = -1
+ for (let index = 0; index < messages.length; index++) {
+ if (messages[index].kind !== 'user') continue
+ turnIndex += 1
+ if (turnIndex === card.turnIndex) {
+ start = index
+ break
+ }
+ }
+ if (start === -1) return { thread, messages: [] }
+ const end = messages.findIndex((message, index) => index > start && message.kind === 'user')
+ return { thread, messages: messages.slice(start, end === -1 ? undefined : end) }
+}
+
+function inspectorProcessEntries(messages) {
+ const entries = []
+ for (const message of messages) {
+ if (Array.isArray(message.process)) {
+ entries.push(...message.process.map(entry => ({ ...entry })))
+ continue
+ }
+ if (message.kind === 'tool') {
+ entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null })
+ continue
+ }
+ if (message.kind === 'tool-result') {
+ const previous = entries.at(-1)
+ if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text
+ else entries.push({ name: '工具结果', arguments: null, result: message.text, error: null })
+ }
+ }
+ return entries
+}
+
+function renderCardInspector(card) {
+ if (card === undefined) return ''
+ const { thread, messages } = messagesForCard(card)
+ if (thread === null) return ''
+ const process = inspectorProcessEntries(messages)
+ const answer = card.answer === null
+ ? card.error === null ? '等待助手回复
' : ''
+ : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '正在回复
' : ''} `
+ const error = card.error === null ? '' : `本轮未完成 ${escapeHtml(card.error.text)}
`
+ const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`)
+ const continueAction = card.canContinue === true ? ` 继续追问 ` : ''
+ const branch = Number.isInteger(card.answer?.sourceSeq)
+ ? ` 创建分支 `
+ : ''
+ const openDshAction = ` 在 DSH 中打开 `
+ return ``
+}
+
+function renderThread() {
+ const thread = currentThread()
+ if (thread === null) return renderCanvas()
+ const messages = messagesFor(thread)
+ const waiting = state.pendingReplies.has(thread.dshSessionId)
+ const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq
+ return `${waiting ? '等待回复' : '发送'} `
+}
+
+function render() {
+ // Remember the departing thread's scroll position per thread id, so
+ // switching sessions restores each conversation's own place instead of
+ // smearing one session's position onto another.
+ if (state.mode === 'thread' && state.detailThreadId !== null) {
+ const detail = document.querySelector('.detail-scroll')
+ if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
+ }
+ if (state.mode === 'canvas' && state.inspectorCardId !== null) {
+ const inspector = document.querySelector('.card-inspector-scroll')
+ if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
+ }
+ state.detailThreadId = state.mode === 'thread' ? state.activeId : null
+ const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
+ const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
+ const cardScrollTops = new Map()
+ if (state.mode === 'canvas') {
+ // Key by the unique card id: every card of a session shares data-thread,
+ // so keying on it would clobber sibling cards' scroll positions. Only
+ // scrollable answers have a position worth preserving; reading the two
+ // height properties shares the same forced layout as the scrollTop read.
+ for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
+ if (answer.scrollHeight <= answer.clientHeight) continue
+ const card = answer.closest('.thread-card')
+ if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
+ }
+ }
+ const workspace = state.workspace
+ const threads = workspace?.threads ?? []
+ const view = state.mode === 'thread' ? renderThread() : renderCanvas()
+ const choices = workspaceChoices()
+ const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
+ const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? ` 整理 定位${Math.round(state.zoom * 100)}% ` : ''
+ const detailAvailable = currentThread() !== null
+ const canvasTabs = `地图 详情 `
+ app.innerHTML = ``
+ installDragging()
+ cacheCardConnectors()
+ // The initial camera from renderCanvas is inset (viewport not laid out yet);
+ // center it on the focused card once the canvas DOM is mounted.
+ if (state.canvasNeedsCenter) {
+ state.canvasNeedsCenter = false
+ window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() })
+ }
+ for (const [cardId, scrollTop] of cardScrollTops) {
+ const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
+ if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
+ }
+ if (detailScrollTop !== null) window.requestAnimationFrame(() => {
+ const nextDetail = document.querySelector('.detail-scroll')
+ if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
+ })
+ if (inspectorScrollTop !== null) window.requestAnimationFrame(() => {
+ const inspector = document.querySelector('.card-inspector-scroll')
+ if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop
+ })
+ if (state.inspectorOpening) window.requestAnimationFrame(() => {
+ document.querySelector('.card-inspector')?.classList.remove('is-opening')
+ state.inspectorOpening = false
+ })
+ // Jump the detail view to the card the user clicked: card ids carry the
+ // source sequence (`:turn:`), which matches data-message-seq
+ // anchors on the rendered messages.
+ const targetCardId = state.detailTargetCardId
+ state.detailTargetCardId = null
+ if (targetCardId !== null) {
+ const match = /:turn:(\d+)$/.exec(targetCardId)
+ const seq = match === null ? null : match[1]
+ if (seq !== null) window.requestAnimationFrame(() => {
+ const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
+ if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
+ })
+ }
+}
+
+function renderPreservingDetailScroll() {
+ render()
+}
+
+let inspectorCloseTimer = 0
+function openCardInspector(cardId) {
+ if (inspectorCloseTimer !== 0) {
+ window.clearTimeout(inspectorCloseTimer)
+ inspectorCloseTimer = 0
+ }
+ state.inspectorOpening = state.inspectorCardId === null
+ state.inspectorCardId = cardId
+}
+
+function closeCardInspector({ animate = true } = {}) {
+ if (state.inspectorCardId === null) return
+ if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer)
+ const cardId = state.inspectorCardId
+ const inspector = document.querySelector('.card-inspector')
+ if (!animate || !(inspector instanceof HTMLElement)) {
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ render()
+ return
+ }
+ inspector.classList.add('is-closing')
+ inspectorCloseTimer = window.setTimeout(() => {
+ inspectorCloseTimer = 0
+ if (state.inspectorCardId !== cardId) return
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ render()
+ }, 180)
+}
+
+function applyCanvasTransform() {
+ const content = document.querySelector('.canvas-content')
+ if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
+}
+
+function bindDragHandle(handle) {
+ handle.addEventListener('pointerdown', event => {
+ const cardId = event.currentTarget.dataset.dragCard
+ const card = event.currentTarget.closest('.thread-card')
+ if (cardId === undefined || !(card instanceof HTMLElement)) return
+ event.preventDefault()
+ const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
+ const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
+ let position = origin.position
+ let stopped = false
+ let frame = 0
+ state.dragging = true
+ // Coalesce pointermove updates to one DOM pass per animation frame so a
+ // high report-rate pointer cannot queue a reflow per event.
+ const apply = () => {
+ frame = 0
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
+ // Keep the virtualized data object in sync so viewport visibility and
+ // connector paths track the live drag position.
+ const dataCard = state.canvasCardsById?.get(cardId)
+ if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y }
+ card.style.left = `${position.x}px`
+ card.style.top = `${position.y}px`
+ refreshCardConnectors(cardId)
+ }
+ const move = moveEvent => {
+ position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
+ }
+ const stop = () => {
+ if (stopped) return
+ stopped = true
+ document.removeEventListener('pointermove', move)
+ document.removeEventListener('pointerup', stop)
+ document.removeEventListener('pointercancel', stop)
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
+ apply()
+ rememberCardPosition(cardId, position, aliases)
+ state.dragging = false
+ deferCanvasRefresh(120)
+ // No full render: only the dragged card's inline position and its
+ // connectors changed; rebuilding the whole canvas on drop is the jank.
+ }
+ document.addEventListener('pointermove', move)
+ document.addEventListener('pointerup', stop)
+ document.addEventListener('pointercancel', stop)
+ })
+}
+
+function installDragging() {
+ for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
+}
+
+function canvasViewport(target) {
+ return target instanceof Element ? target.closest('.canvas-viewport') : null
+}
+
+function zoomCanvas(viewport, nextZoom, clientX, clientY) {
+ const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
+ if (zoom === state.zoom) return
+ const bounds = viewport.getBoundingClientRect()
+ const localX = clientX - bounds.left
+ const localY = clientY - bounds.top
+ const worldX = (localX - state.canvasCamera.x) / state.zoom
+ const worldY = (localY - state.canvasCamera.y) / state.zoom
+ state.zoom = zoom
+ state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
+ const content = viewport.querySelector('.canvas-content')
+ if (content instanceof HTMLElement) {
+ // Drop the composited layer before zooming: a cached will-change raster
+ // would be upscaled instead of re-rasterized, which was the original
+ // zoom-blur bug. will-change re-applies via .is-panning on the next pan.
+ content.style.willChange = 'auto'
+ applyCanvasTransform()
+ syncCanvasViewport()
+ window.requestAnimationFrame(() => { content.style.willChange = '' })
+ } else {
+ applyCanvasTransform()
+ syncCanvasViewport()
+ }
+ const label = document.querySelector('.canvas-controls span')
+ if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
+}
+
+function zoomCanvasAtCenter(delta) {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ const bounds = viewport.getBoundingClientRect()
+ zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
+}
+
+function focusActiveCard() {
+ const viewport = document.querySelector('.canvas-viewport')
+ if (!(viewport instanceof HTMLElement)) return
+ const cards = state.canvasCards
+ if (cards === undefined || cards.length === 0) return
+ // Drafts win over the active conversation's latest turn; fall back to the
+ // first card. Cards may be unmounted (outside the viewport), so the focus
+ // target comes from the data model, never from DOM queries.
+ const draft = state.draft === null ? undefined
+ : state.draft.kind === 'new' ? { position: { x: 86, y: 82 } } : draftPlacement(cards)
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
+ const card = draft ?? activeCards.at(-1) ?? cards[0]
+ const { x: left, y: top } = card.position
+ const bounds = viewport.getBoundingClientRect()
+ state.canvasCamera = {
+ x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
+ y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
+ }
+ applyCanvasTransform()
+ syncCanvasViewport()
+}
+
+let selectionFollowup = null
+let selectionFollowupFrame = 0
+
+function hideSelectionFollowup() {
+ if (selectionFollowupFrame !== 0) {
+ window.cancelAnimationFrame(selectionFollowupFrame)
+ selectionFollowupFrame = 0
+ }
+ selectionFollowup = null
+ const button = app.querySelector('.selection-followup')
+ if (button instanceof HTMLButtonElement) button.hidden = true
+}
+
+function selectionFollowupTarget(range) {
+ const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
+ const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
+ if (!(start instanceof Element) || !(end instanceof Element)) return null
+ const answer = start.closest('.thread-answer')
+ if (answer instanceof HTMLElement && answer.contains(end)) {
+ const card = answer.closest('.thread-card[data-thread]:not(.draft-card)')
+ if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread }
+ }
+ const messageBody = start.closest('.message-assistant .message-body')
+ const thread = currentThread()
+ if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id }
+ return null
+}
+
+function updateSelectionFollowup() {
+ selectionFollowupFrame = 0
+ const button = app.querySelector('.selection-followup')
+ const selection = window.getSelection()
+ if (!(button instanceof HTMLButtonElement) || state.draft !== null || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup()
+ const text = selection.toString().trim()
+ const range = selection.getRangeAt(0)
+ const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range)
+ const rect = range.getBoundingClientRect()
+ if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup()
+ selectionFollowup = { ...target, text }
+ button.dataset.thread = target.threadId
+ button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px`
+ button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px`
+ button.hidden = false
+}
+
+function queueSelectionFollowup() {
+ if (selectionFollowupFrame !== 0) return
+ selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup)
+}
+
+app.addEventListener('pointerdown', event => {
+ const viewport = canvasViewport(event.target)
+ if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
+ event.preventDefault()
+ const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
+ let pendingCamera = null
+ let frame = 0
+ state.canvasGesture = true
+ viewport.classList.add('is-panning')
+ viewport.setPointerCapture(event.pointerId)
+ const apply = () => {
+ frame = 0
+ if (pendingCamera === null) return
+ state.canvasCamera = pendingCamera
+ pendingCamera = null
+ applyCanvasTransform()
+ syncCanvasViewport()
+ }
+ const move = moveEvent => {
+ pendingCamera = {
+ x: origin.camera.x + moveEvent.clientX - origin.x,
+ y: origin.camera.y + moveEvent.clientY - origin.y,
+ }
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
+ }
+ const stop = () => {
+ viewport.classList.remove('is-panning')
+ document.removeEventListener('pointermove', move)
+ document.removeEventListener('pointerup', stop)
+ document.removeEventListener('pointercancel', stop)
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
+ apply()
+ state.canvasGesture = false
+ deferCanvasRefresh(120)
+ }
+ document.addEventListener('pointermove', move)
+ document.addEventListener('pointerup', stop)
+ document.addEventListener('pointercancel', stop)
+})
+
+app.addEventListener('wheel', event => {
+ const viewport = canvasViewport(event.target)
+ if (!(viewport instanceof HTMLElement)) return
+ const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
+ if (card instanceof HTMLElement) {
+ // Over a card the wheel scrolls that card's own answer with the browser's
+ // native wheel (OS-smooth, never a page jump per notch); the answer's
+ // overscroll-behavior: contain stops the scroll chaining into the canvas.
+ const answer = card.querySelector('.thread-answer')
+ if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
+ deferCanvasRefresh()
+ return
+ }
+ // A card with no scrollable answer swallows the wheel instead of zooming.
+ event.preventDefault()
+ deferCanvasRefresh()
+ return
+ }
+ event.preventDefault()
+ zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
+}, { passive: false })
+
+// Track pointer-down so the card click handler can tell a plain click from a
+// text-selection or drag gesture; acting on the latter would re-render and
+// wipe the user's selection.
+let pointerDownPosition = null
+app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
+app.addEventListener('pointerdown', event => {
+ const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null
+ if (button instanceof HTMLButtonElement) event.preventDefault()
+ else hideSelectionFollowup()
+})
+app.addEventListener('pointerup', queueSelectionFollowup)
+app.addEventListener('scroll', hideSelectionFollowup, true)
+document.addEventListener('selectionchange', queueSelectionFollowup)
+document.addEventListener('keydown', event => {
+ if (event.key !== 'Escape' || state.mode !== 'canvas' || state.inspectorCardId === null) return
+ event.preventDefault()
+ closeCardInspector({ animate: false })
+})
+
+app.addEventListener('click', async event => {
+ const button = event.target.closest('[data-action]')
+ if (!(button instanceof HTMLElement)) {
+ const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
+ if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
+ // A double-click selects a word and a drag selects a range; neither is a
+ // select-click, so leave the selection intact instead of re-rendering.
+ if (event.detail > 1) return
+ if (pointerDownPosition !== null
+ && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
+ const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
+ if (thread === undefined) return
+ const cardId = card.dataset.cardId
+ if (cardId === undefined) return
+ state.activeId = thread.id
+ state.selectedCardId = cardId
+ openCardInspector(cardId)
+ state.error = ''
+ render()
+ void loadThreadHistory(thread)
+ // Bidirectional current-session sync: switch DSH's current session
+ // without closing the map; the client confirms via synapse:current-session.
+ if (thread.dshSessionId !== null) {
+ if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
+ post('synapse:activate-session', { sessionId: thread.dshSessionId })
+ }
+ return
+ }
+ const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
+ try {
+ if (button.dataset.action === 'follow-selection') {
+ const followup = selectionFollowup
+ hideSelectionFollowup()
+ if (followup !== null && thread !== undefined && thread.id === followup.threadId && state.draft === null) openContinue(thread, undefined, followup.text)
+ return
+ }
+ if (button.dataset.action === 'insert-quick-phrase' && button.dataset.quickPhrase !== undefined) insertQuickPhrase(button.dataset.quickPhrase)
+ if (button.dataset.action === 'open-quick-phrase-editor') { state.quickPhraseEditorOpen = true; render() }
+ if (button.dataset.action === 'close-quick-phrase-editor') { state.quickPhraseEditorOpen = false; render() }
+ if (button.dataset.action === 'add-quick-phrase') {
+ const editor = button.closest('.draft-quick-phrase-add')
+ const input = editor?.querySelector('input')
+ if (input instanceof HTMLInputElement && addQuickPhrase(input.value)) {
+ render()
+ window.setTimeout(() => document.querySelector('.draft-quick-phrase-add input')?.focus(), 0)
+ }
+ }
+ if (button.dataset.action === 'remove-quick-phrase') {
+ const index = Number(button.dataset.quickPhraseIndex)
+ if (Number.isInteger(index) && index >= 0 && index < state.quickPhrases.length) {
+ state.quickPhrases.splice(index, 1)
+ persistQuickPhrases()
+ render()
+ }
+ }
+ if (button.dataset.action === 'close') post('synapse:close')
+ if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
+ if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
+ if (button.dataset.action === 'create-session') openNewSession()
+ if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
+ if (button.dataset.action === 'select-thread' && thread !== undefined) {
+ state.mapCardSessionSwitches.clear()
+ state.activeId = thread.id
+ state.selectedCardId = null
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ state.error = ''
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
+ render()
+ void loadThreadHistory(thread)
+ // Bidirectional current-session sync: switch DSH's current session
+ // without closing the map; the client confirms via synapse:current-session.
+ if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
+ }
+ if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null; render(); void loadThreadHistory(thread) }
+ if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
+ if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
+ const cardId = button.dataset.card
+ const collapsing = !state.collapsedCardIds.has(cardId)
+ if (collapsing && state.workspace !== null) {
+ const allCards = conversationCards(state.workspace.threads)
+ const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
+ const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
+ const visibleIds = new Set(visibleCards.map(card => card.id))
+ const draftParentId = draftPlacement(allCards)?.parent.id
+ if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
+ if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
+ }
+ collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
+ persistCollapsedCards()
+ render()
+ window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
+ }
+ if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
+ if (button.dataset.action === 'open-branch' && thread !== undefined) {
+ const requestedSeq = Number(button.dataset.seq)
+ if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
+ const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
+ openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
+ }
+ if (button.dataset.action === 'cancel-draft') { state.draft = null; state.quickPhraseEditorOpen = false; render() }
+ if (button.dataset.action === 'toggle-message' && button.dataset.message !== undefined) { state.expandedMessageIds.has(button.dataset.message) ? state.expandedMessageIds.delete(button.dataset.message) : state.expandedMessageIds.add(button.dataset.message); renderPreservingDetailScroll() }
+ if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId, seq: Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined })
+ if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
+ if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
+ if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
+ if (button.dataset.action === 'focus-active') focusActiveCard()
+ if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
+ if (button.dataset.action === 'layout' && state.workspace !== null) {
+ resetCardPositions()
+ resetCanvasCamera()
+ render()
+ }
+ } catch (error) { setError(error) }
+})
+
+app.addEventListener('change', event => {
+ const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
+ if (quickPhrase instanceof HTMLInputElement) {
+ updateQuickPhrase(Number(quickPhrase.dataset.quickPhraseIndex), quickPhrase.value)
+ return
+ }
+ const select = event.target.closest('[data-action="select-workspace"]')
+ if (!(select instanceof HTMLSelectElement)) return
+ const choice = workspaceChoices().find(item => item.id === select.value)
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ if (choice?.source === 'dsh') {
+ // Map → native sync: switching workspaces moves DSH's current session to
+ // the workspace's most recently updated session, keeping both sides in step.
+ void openDshWorkspace(choice.id).then(opened => {
+ if (!opened) return
+ const threads = state.workspace?.threads ?? []
+ const latest = threads
+ .filter(thread => thread.dshSessionId !== null)
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
+ const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
+ if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
+ }).catch(setError)
+ } else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
+})
+app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
+app.addEventListener('submit', event => {
+ const form = event.target
+ if (!(form instanceof HTMLFormElement)) return
+ if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
+ const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
+ const input = form.querySelector('textarea')
+ if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
+ event.preventDefault()
+ const text = input.value.trim()
+ input.value = ''
+ void sendMessage(thread, text).catch(setError)
+})
+
+window.addEventListener('message', event => {
+ if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
+ const data = event.data
+ if (data.type === 'synapse:map-opened') {
+ // Do NOT reset the camera here: toggling dialog<->map for the same
+ // session must keep the user's viewport. A fresh canvas (canvasView
+ // not initialized) still centers via renderCanvas; a real session switch
+ // re-centers in the current-session handler below.
+ state.mode = 'canvas'
+ render()
+ window.requestAnimationFrame(() => post('synapse:map-ready'))
+ }
+ if (data.type === 'synapse:theme') {
+ document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
+ }
+ if (data.type === 'synapse:workspaces') {
+ state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
+ const current = currentDshWorkspace()
+ if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
+ else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
+ else if (canReplaceView()) render()
+ }
+ if (data.type === 'synapse:current-session') {
+ const previousId = state.currentDsh?.id
+ state.currentDsh = data.session
+ const preserveCanvasCamera = previousId !== data.session?.id && state.mapCardSessionSwitches.delete(data.session?.id)
+ const thread = currentDshThread()
+ if (thread !== undefined) {
+ const preserveSelectedCard = state.activeId === thread.id
+ state.activeId = thread.id
+ if (!preserveSelectedCard) {
+ state.selectedCardId = null
+ state.inspectorCardId = null
+ state.inspectorOpening = false
+ }
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
+ }
+ if (previousId !== data.session?.id) {
+ // A real session switch: re-center on the new session's latest turn,
+ // whether it lives in the same workspace (openCurrentWorkspace returns
+ // false) or a different one (it resets the camera itself).
+ void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
+ if (!opened && canReplaceView()) {
+ render()
+ if (!preserveCanvasCamera) focusActiveCard()
+ }
+ }).catch(setError)
+ }
+ else if (canReplaceView()) render()
+ }
+ if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
+ if (thread !== undefined) {
+ if (data.running === true) {
+ state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
+ // Streaming: patch the live card's answer in place instead of
+ // rebuilding the whole canvas on every chunk; a full render reconciles
+ // at stream end. The detail view is single-thread, so keep its cheap
+ // throttled full render.
+ if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
+ else if (canReplaceView()) scheduleLiveRender()
+ } else {
+ state.liveReplies.delete(data.sessionId)
+ if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
+ }
+ }
+ }
+ if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
+ if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
+})
+
+post('synapse:request-current')
+refreshSummaries().catch(setError)
+let polling = false
+let liveRenderTimer = 0
+let liveCardFrame = 0
+let liveCardSessionId = null
+function scheduleLiveCardUpdate(sessionId) {
+ // Coalesce streaming chunks to one DOM patch per animation frame.
+ liveCardSessionId = sessionId
+ if (liveCardFrame !== 0) return
+ liveCardFrame = window.requestAnimationFrame(() => {
+ liveCardFrame = 0
+ if (liveCardSessionId === null) return
+ const id = liveCardSessionId
+ liveCardSessionId = null
+ applyLiveReplyToCard(id)
+ })
+}
+function applyLiveReplyToCard(sessionId) {
+ if (state.mode !== 'canvas') return
+ // Never patch cards mid-gesture: the reflow would compete with the drag or
+ // pan frame; the next live-reply chunk re-applies after the gesture ends.
+ if (state.dragging || state.canvasGesture) return
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
+ if (thread === undefined) return
+ const live = state.liveReplies.get(sessionId)
+ if (live?.running !== true) return
+ const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
+ const card = cards[cards.length - 1]
+ if (!(card instanceof HTMLElement)) return
+ const answer = card.querySelector('.thread-answer')
+ if (!(answer instanceof HTMLElement)) return
+ const text = live.text
+ answer.innerHTML = text.trim() === ''
+ ? '正在回复
'
+ : `${renderMarkdown(text)}正在回复
`
+}
+function scheduleLiveRender() {
+ if (liveRenderTimer !== 0 || !canReplaceView()) return
+ liveRenderTimer = window.setTimeout(() => {
+ liveRenderTimer = 0
+ if (canReplaceView()) renderPreservingDetailScroll()
+ }, 120)
+}
+async function pollProjection() {
+ if (polling || document.hidden || !canReplaceView()) return
+ polling = true
+ try {
+ await refreshProjection()
+ } finally { polling = false }
+}
+window.setInterval(() => { void pollProjection() }, 1_000)
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/client.js b/apps/deepviewer-adapter/workflows/vendor/synapse/client.js
new file mode 100644
index 0000000..918ea20
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/client.js
@@ -0,0 +1,241 @@
+window.__ModuleLoader__.load({
+ id: 'dsh-synapse',
+ factory: () => {
+ const module = { exports: {} }
+ const currentSession = ctx => {
+ const snapshot = ctx.sessions.list.getSnapshot()
+ const id = snapshot.current
+ if (id === undefined) return null
+ const session = snapshot.byId[id]
+ return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null }
+ }
+ const sessionSnapshot = ctx => {
+ const snapshot = ctx.sessions.list.getSnapshot()
+ return snapshot.ids.map(id => {
+ const session = snapshot.byId[id]
+ return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null, parentId: session.parentId ?? null, blank: session.blank }
+ }).filter(Boolean)
+ }
+ const workspaceSnapshot = ctx => {
+ const sessions = ctx.sessions.list.getSnapshot()
+ const snapshot = ctx.workspaces.list.getSnapshot()
+ const accounted = new Set(snapshot.items.flatMap(workspace => workspace.sessionIds))
+ return [
+ ...snapshot.items.map(workspace => ({ id: workspace.workspaceId, title: workspace.title, path: workspace.path, sessionIds: workspace.sessionIds })),
+ { id: 'dsh-ungrouped', title: '未分组', path: null, sessionIds: sessions.ids.filter(id => !accounted.has(id)) },
+ ]
+ }
+
+ module.exports.inject = ['sessions', 'workspaces']
+ module.exports.apply = ctx => {
+ const prompt = async (sessionId, text) => {
+ const scope = ctx.sessions.scope(sessionId)
+ const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
+ if (session === undefined) throw new Error('关联的 DSH 会话已不可用')
+ const result = await session.prompt([{ type: 'text', text }], 'queue')
+ if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
+ }
+ const style = document.createElement('style')
+ style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}'
+ document.head.append(style)
+ const host = document.createElement('div')
+ host.className = 'dsh-synapse-host'
+ host.innerHTML = '对话 会话地图
'
+ document.body.append(host)
+ const dialogButton = host.querySelector('[data-view="dialog"]')
+ const mapButton = host.querySelector('[data-view="map"]')
+ const overlay = host.querySelector('.dsh-synapse-overlay')
+ const frame = host.querySelector('iframe')
+
+ const setView = view => {
+ const showingMap = view === 'map'
+ dialogButton.classList.toggle('active', !showingMap)
+ dialogButton.setAttribute('aria-pressed', String(!showingMap))
+ mapButton.classList.toggle('active', showingMap)
+ mapButton.setAttribute('aria-pressed', String(showingMap))
+ }
+ const close = () => {
+ window.clearTimeout(mapOpenFallback)
+ mapOpening = false
+ overlay.classList.remove('is-opening')
+ overlay.hidden = true
+ setView('dialog')
+ }
+ const send = (type, payload) => { frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin) }
+ let syncQueued = false
+ let knownSessionIds = new Set()
+ const liveUnsubscribers = new Map()
+ const syncLiveSessions = () => {
+ const snapshot = ctx.sessions.list.getSnapshot()
+ for (const id of snapshot.ids) {
+ if (liveUnsubscribers.has(id)) continue
+ const scope = ctx.sessions.scope(id)
+ const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
+ if (session === undefined) continue
+ const publish = () => {
+ if (overlay.hidden) return
+ const state = session.getSnapshot()
+ const text = state.partial?.blocks.filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
+ send('synapse:live-reply', { sessionId: id, running: state.running, text })
+ }
+ liveUnsubscribers.set(id, session.subscribe(publish))
+ publish()
+ }
+ for (const [id, unsubscribe] of liveUnsubscribers) if (!snapshot.ids.includes(id)) { unsubscribe(); liveUnsubscribers.delete(id) }
+ }
+ const syncSessions = () => {
+ if (syncQueued) return
+ syncQueued = true
+ queueMicrotask(() => {
+ syncQueued = false
+ const sessions = sessionSnapshot(ctx)
+ const sessionIds = new Set(sessions.map(session => session.id))
+ const removedSessionIds = [...knownSessionIds].filter(id => !sessionIds.has(id))
+ knownSessionIds = sessionIds
+ void fetch('/synapse/api/sessions/sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessions, removedSessionIds }) }).catch(() => {})
+ })
+ }
+ const syncTheme = () => {
+ const dark = document.body?.hasAttribute?.('data-ds-dark-theme') === true
+ send('synapse:theme', { dark })
+ }
+ const syncCurrentSession = () => {
+ syncSessions()
+ syncLiveSessions()
+ syncTheme()
+ if (!overlay.hidden) {
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
+ send('synapse:current-session', { session: currentSession(ctx) })
+ }
+ }
+ let mapOpenFallback = 0
+ let mapOpening = false
+ const showMapOverlay = () => {
+ window.clearTimeout(mapOpenFallback)
+ mapOpening = false
+ overlay.hidden = false
+ overlay.classList.remove('is-opening')
+ syncCurrentSession()
+ }
+ const open = () => {
+ window.clearTimeout(mapOpenFallback)
+ mapOpening = true
+ setView('map')
+ // Keep the iframe laid out while hidden so its canvas can receive a
+ // real scroll offset. display:none would clamp scrollTop back to zero.
+ overlay.hidden = false
+ overlay.classList.add('is-opening')
+ window.requestAnimationFrame(() => {
+ send('synapse:map-opened')
+ syncCurrentSession()
+ })
+ mapOpenFallback = window.setTimeout(showMapOverlay, 300)
+ }
+ const onFrameLoad = () => {
+ syncCurrentSession()
+ if (mapOpening) send('synapse:map-opened')
+ }
+ const onMessage = event => {
+ if (event.origin !== location.origin || event.data?.source !== 'dsh-synapse') return
+ if (event.data.type === 'synapse:close') return close()
+ if (event.data.type === 'synapse:map-ready') return showMapOverlay()
+ if (event.data.type === 'synapse:request-current') {
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
+ return send('synapse:current-session', { session: currentSession(ctx) })
+ }
+ if (event.data.type === 'synapse:open-session') {
+ try { ctx.sessions.open(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
+ // Best-effort anchor to the requested turn: chat nodes expose their
+ // source event seq (anchorSeq) and render with data-chat-anchor-key,
+ // so resolve seq -> node key -> scroll once the view materializes.
+ const seq = event.data.seq
+ if (Number.isInteger(seq)) {
+ const tryScroll = attempt => {
+ const scope = ctx.sessions.scope(event.data.sessionId)
+ const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
+ if (session === undefined) return
+ const chat = session.getSnapshot()?.chat
+ if (chat === undefined) return
+ let key = undefined
+ for (const node of chat.nodes.values()) {
+ if (node.anchorSeq === seq) { key = node.key; break }
+ }
+ if (key !== undefined) {
+ const row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(key)}"]`)
+ if (row instanceof HTMLElement) row.scrollIntoView({ block: 'start' })
+ return
+ }
+ if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
+ }
+ window.setTimeout(() => tryScroll(0), 300)
+ }
+ return
+ }
+ if (event.data.type === 'synapse:activate-session') {
+ // Bidirectional current-session sync: switch DSH's current session
+ // without closing the map; the sessions-list subscription re-sends
+ // synapse:current-session so the map follows the new highlight.
+ try { ctx.sessions.open(event.data.sessionId) } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
+ return
+ }
+ if (event.data.type === 'synapse:fork-session') {
+ const atSeq = Number.isInteger(event.data.atSeq) ? event.data.atSeq : undefined
+ ctx.sessions.fork({ sessionId: event.data.sessionId, atSeq, increaseTitle: true }).then(id => {
+ const snapshot = ctx.sessions.list.getSnapshot()
+ send('synapse:forked-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? 'DSH 分支' } })
+ }).catch(() => { send('synapse:bridge-error', { message: 'DSH 分支创建失败,请确认源会话已经完成当前轮次' }) })
+ return
+ }
+ if (event.data.type === 'synapse:send-message') {
+ const text = typeof event.data.text === 'string' ? event.data.text.trim() : ''
+ if (text === '') return send('synapse:bridge-error', { requestId: event.data.requestId, message: '消息不能为空' })
+ prompt(event.data.sessionId, text).then(() => {
+ send('synapse:message-sent', { requestId: event.data.requestId, sessionId: event.data.sessionId })
+ }).catch(error => {
+ send('synapse:bridge-error', { requestId: event.data.requestId, message: error instanceof Error ? error.message : 'DSH 消息发送失败' })
+ })
+ return
+ }
+ if (event.data.type === 'synapse:create-session') {
+ const workspaceId = typeof event.data.workspaceId === 'string' && event.data.workspaceId !== '' && event.data.workspaceId !== 'dsh-ungrouped' ? event.data.workspaceId : undefined
+ const cwd = typeof event.data.cwd === 'string' && event.data.cwd !== '' ? event.data.cwd : undefined
+ const create = workspaceId === undefined ? ctx.sessions.create(cwd === undefined ? {} : { cwd }) : ctx.sessions.create({ workspaceId })
+ create.then(id => {
+ const snapshot = ctx.sessions.list.getSnapshot()
+ send('synapse:created-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? '新会话', cwd: snapshot.byId[id]?.cwd ?? cwd ?? null } })
+ }).catch(() => { send('synapse:bridge-error', { requestId: event.data.requestId, message: 'DSH 会话创建失败,请先在 DSH 选择工作目录' }) })
+ }
+ }
+ const onKeyDown = event => { if (event.key === 'Escape' && !overlay.hidden) close() }
+ // Follow DSH's live theme switch: body[data-ds-dark-theme] is the web
+ // client's dark-mode signal, mirrored into the map iframe via synapse:theme.
+ const themeObserver = typeof MutationObserver === 'undefined'
+ ? null
+ : new MutationObserver(() => syncTheme())
+ if (themeObserver !== null && document.body) {
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
+ }
+ const unsubscribeSessions = ctx.sessions.list.subscribe(syncCurrentSession)
+ const unsubscribeWorkspaces = ctx.workspaces.list.subscribe(syncCurrentSession)
+ dialogButton.addEventListener('click', close)
+ mapButton.addEventListener('click', open)
+ frame.addEventListener('load', onFrameLoad)
+ window.addEventListener('message', onMessage)
+ window.addEventListener('keydown', onKeyDown)
+ ctx.effect(() => () => {
+ dialogButton.removeEventListener('click', close)
+ mapButton.removeEventListener('click', open)
+ frame.removeEventListener('load', onFrameLoad)
+ window.removeEventListener('message', onMessage)
+ window.removeEventListener('keydown', onKeyDown)
+ themeObserver?.disconnect()
+ unsubscribeSessions()
+ unsubscribeWorkspaces()
+ for (const unsubscribe of liveUnsubscribers.values()) unsubscribe()
+ host.remove()
+ style.remove()
+ }, 'synapse: web workspace switch')
+ }
+ return module.exports
+ },
+})
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/cordis.patch.yml b/apps/deepviewer-adapter/workflows/vendor/synapse/cordis.patch.yml
new file mode 100644
index 0000000..652bb79
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/cordis.patch.yml
@@ -0,0 +1,13 @@
+# dsh-synapse only extends the Web profile: it reuses the existing DSH server
+# rather than running a second application process.
+- insert:
+ - id: synapse
+ name: dsh-synapse
+ config:
+ # A profile can override this path in its own cordis.patch.yml.
+ dataFile: !!js dshHomePath('synapse/workspaces.json')
+ autoProjection: true
+ projectionWorkspaceTitle: DSH 任务
+ # Extra authorities the /synapse Host check accepts (host or host:port);
+ # localhost and 127.0.0.1 are always allowed.
+ trustedHosts: []
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/index.js b/apps/deepviewer-adapter/workflows/vendor/synapse/index.js
new file mode 100644
index 0000000..ba692ec
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/index.js
@@ -0,0 +1,829 @@
+import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
+import { randomUUID } from 'node:crypto'
+import { dirname } from 'node:path'
+
+export const name = 'synapse'
+export const inject = ['webServer', 'sessions']
+
+const MAX_BODY_BYTES = 32 * 1024
+const MAX_TITLE_LENGTH = 120
+const MAX_NOTE_LENGTH = 4_000
+// Projected message text cap: longer replies truncate with a marker pointing
+// at the detail view instead of silently cutting mid-sentence.
+const MAX_PROJECTION_LENGTH = 8_000
+const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
+const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
+const LOCK_STALE_MS = 60_000
+// Deferred (event-projection) writes coalesce into one save per window, so a
+// burst of session events costs a single full-state write instead of one per
+// event (issue #13: per-event saves pinned the main thread at ~90% CPU).
+const SAVE_DEBOUNCE_MS = 800
+
+/** JSON persistence for the Synapse workspace graph. */
+export class WorkspaceStore {
+ constructor(dataFile) {
+ if (typeof dataFile !== 'string' || dataFile.length === 0) throw new Error('synapse: config.dataFile must be a non-empty path')
+ this.dataFile = dataFile
+ this.state = undefined
+ this.serial = Promise.resolve()
+ this.ready = this.load()
+ this.lastKnownMtime = null
+ this.externalModWarned = false
+ this.lockWarned = false
+ this.dirty = false
+ this.flushTimer = null
+ }
+
+ async list() {
+ await this.ready
+ return this.state.workspaces.map(workspace => this.summary(workspace))
+ }
+
+ async get(workspaceId) {
+ await this.ready
+ const workspace = this.workspace(workspaceId)
+ return structuredClone(workspace)
+ }
+
+ async create(title) {
+ return this.mutate(() => {
+ const now = new Date().toISOString()
+ const workspace = { id: randomUUID(), title: requiredText(title, MAX_TITLE_LENGTH, 'title'), createdAt: now, updatedAt: now, threads: [] }
+ this.state.workspaces.unshift(workspace)
+ return this.summary(workspace)
+ })
+ }
+
+ async createThread(workspaceId, input) {
+ return this.mutate(() => {
+ const workspace = this.workspace(workspaceId)
+ const now = new Date().toISOString()
+ const thread = this.thread({
+ title: input?.title,
+ parentId: input?.parentId,
+ dshSessionId: input?.dshSessionId,
+ dshSessionTitle: input?.dshSessionTitle,
+ position: input?.position,
+ color: input?.color,
+ now,
+ order: workspace.threads.length,
+ })
+ if (thread.parentId !== null && !workspace.threads.some(item => item.id === thread.parentId)) throw new InputError('分支来源不存在')
+ workspace.threads.push(thread)
+ workspace.updatedAt = now
+ return structuredClone(thread)
+ })
+ }
+
+ async branch(threadId, input) {
+ return this.mutate(() => {
+ const { workspace, thread: parent } = this.locateThread(threadId)
+ const now = new Date().toISOString()
+ const sessionId = typeof input?.dshSessionId === 'string' && input.dshSessionId.length > 0 ? input.dshSessionId : null
+ // A DSH fork emits session/created while the browser receives its fork
+ // response. Either path may win the race, but both must resolve to one node.
+ if (sessionId !== null) {
+ const existing = workspace.threads.find(item => item.dshSessionId === sessionId)
+ if (existing !== undefined) {
+ existing.parentId ??= parent.id
+ if (typeof input?.title === 'string' && input.title.trim() !== '') existing.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
+ if (typeof input?.dshSessionTitle === 'string') existing.dshSessionTitle = input.dshSessionTitle.slice(0, MAX_TITLE_LENGTH)
+ existing.updatedAt = now
+ workspace.updatedAt = now
+ return structuredClone(existing)
+ }
+ }
+ const siblings = workspace.threads.filter(item => item.parentId === parent.id)
+ const thread = this.thread({
+ title: input?.title,
+ parentId: parent.id,
+ dshSessionId: input?.dshSessionId,
+ dshSessionTitle: input?.dshSessionTitle,
+ position: input?.position ?? { x: parent.position.x + 420, y: parent.position.y + siblings.length * 248 },
+ color: input?.color ?? parent.color,
+ now,
+ order: workspace.threads.length,
+ })
+ workspace.threads.push(thread)
+ workspace.updatedAt = now
+ return structuredClone(thread)
+ })
+ }
+
+ /** Keep only the canvas graph in Synapse; DSH remains the source of session truth. */
+ async syncSessions(sessions, removedSessionIds = []) {
+ return this.mutate(() => {
+ if (!Array.isArray(sessions)) throw new InputError('sessions 必须是数组')
+ if (!Array.isArray(removedSessionIds) || removedSessionIds.some(item => typeof item !== 'string')) throw new InputError('removedSessionIds 必须是字符串数组')
+ const blankIds = new Set(sessions.filter(item => item?.blank === true && typeof item.id === 'string').map(item => item.id))
+ const removedIds = new Set(removedSessionIds)
+ for (const workspace of this.state.workspaces) {
+ if (workspace.kind !== 'dsh') continue
+ workspace.threads = workspace.threads.filter(thread => !blankIds.has(thread.dshSessionId) && !removedIds.has(thread.dshSessionId))
+ }
+ this.state.workspaces = this.state.workspaces.filter(workspace => workspace.kind !== 'dsh' || workspace.threads.length > 0)
+ for (const item of sessions) {
+ if (typeof item?.id !== 'string' || item.id === '' || typeof item.cwd !== 'string' || item.cwd === '') continue
+ if (item.blank === true) continue
+ // Canvas archiving is persistent UI state. A normal DSH list refresh
+ // must not recreate a session that the user deliberately archived.
+ if (this.state.hiddenSessionIds.includes(item.id)) continue
+ const workspace = this.dshWorkspace(item.cwd, 'DSH 任务')
+ const session = { id: item.id, header: { meta: { cwd: item.cwd }, parentSession: typeof item.parentId === 'string' ? item.parentId : undefined }, title: typeof item.title === 'string' ? item.title : undefined, events: [] }
+ const thread = this.dshThread(workspace, session)
+ if (typeof item.title === 'string' && item.title.trim() !== '') {
+ thread.title = item.title.slice(0, MAX_TITLE_LENGTH)
+ thread.dshSessionTitle = thread.title
+ }
+ }
+ return this.list()
+ }, { deferred: true })
+ }
+
+ async addMessage(threadId, text) {
+ return this.mutate(() => {
+ const { workspace, thread } = this.locateThread(threadId)
+ const at = new Date().toISOString()
+ const message = { id: randomUUID(), text: requiredText(text, MAX_NOTE_LENGTH, 'text'), kind: 'user', at }
+ thread.messages.push(message)
+ thread.updatedAt = at
+ workspace.updatedAt = at
+ return structuredClone(thread)
+ })
+ }
+
+ async updateThread(threadId, input) {
+ return this.mutate(() => {
+ const { workspace, thread } = this.locateThread(threadId)
+ if (input?.title !== undefined) thread.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
+ if (input?.position !== undefined) thread.position = positionOf(input.position)
+ thread.updatedAt = new Date().toISOString()
+ workspace.updatedAt = thread.updatedAt
+ return structuredClone(thread)
+ })
+ }
+
+ async removeThread(threadId) {
+ return this.mutate(() => {
+ const { workspace, thread } = this.locateThread(threadId)
+ const removal = new Set([thread.id])
+ for (let changed = true; changed;) {
+ changed = false
+ for (const item of workspace.threads) {
+ if (item.parentId !== null && removal.has(item.parentId) && !removal.has(item.id)) { removal.add(item.id); changed = true }
+ }
+ }
+ for (const item of workspace.threads) {
+ if (removal.has(item.id) && item.dshSessionId !== null && !this.state.hiddenSessionIds.includes(item.dshSessionId)) this.state.hiddenSessionIds.push(item.dshSessionId)
+ }
+ workspace.threads = workspace.threads.filter(item => !removal.has(item.id))
+ workspace.updatedAt = new Date().toISOString()
+ if (workspace.threads.length === 0) this.state.workspaces = this.state.workspaces.filter(item => item.id !== workspace.id)
+ return { removed: removal.size }
+ })
+ }
+
+ async clearLegacy(sessions) {
+ return this.mutate(() => {
+ const hidden = new Set(this.state.hiddenSessionIds)
+ for (const workspace of this.state.workspaces) for (const thread of workspace.threads) if (thread.dshSessionId !== null) hidden.add(thread.dshSessionId)
+ for (const session of sessions) hidden.add(session.id)
+ this.state.hiddenSessionIds = [...hidden]
+ this.state.workspaces = []
+ return { cleared: true }
+ })
+ }
+
+ /** Replay one live DSH session into the dedicated projection workspace. */
+ async projectSession(session, replayFrom = 0, workspaceTitle = 'DSH 任务') {
+ return this.mutate(() => {
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
+ const thread = this.dshThread(workspace, session)
+ for (const event of session.events) {
+ if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
+ }
+ return structuredClone(thread)
+ }, { deferred: true })
+ }
+
+ /** Project one committed DSH session event. Repeated sequence numbers are ignored. */
+ async projectEvent(session, event, workspaceTitle = 'DSH 任务') {
+ return this.mutate(() => {
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
+ const thread = this.dshThread(workspace, session)
+ this.projectEventInto(workspace, thread, event)
+ return structuredClone(thread)
+ }, { deferred: true })
+ }
+
+ /** Project a batch of committed events for one session in a single write. */
+ async projectEvents(session, events, workspaceTitle = 'DSH 任务') {
+ if (events.length === 0) return null
+ return this.mutate(() => {
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
+ const thread = this.dshThread(workspace, session)
+ for (const event of events) this.projectEventInto(workspace, thread, event)
+ return structuredClone(thread)
+ }, { deferred: true })
+ }
+
+ async load() {
+ await mkdir(dirname(this.dataFile), { recursive: true })
+ try {
+ const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
+ const { state, migrated } = normalizeState(parsed)
+ this.state = state
+ if (migrated) await this.save()
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
+ this.state = { version: 4, hiddenSessionIds: [], workspaces: [] }
+ await this.save()
+ }
+ }
+
+ async mutate(action, { deferred = false } = {}) {
+ await this.ready
+ const task = this.serial.then(async () => {
+ const result = action()
+ if (deferred) this.markDirty()
+ else await this.save()
+ return result
+ })
+ this.serial = task.catch(() => undefined)
+ return task
+ }
+
+ /** Mark the state dirty and schedule one trailing flush for the window. */
+ markDirty() {
+ this.dirty = true
+ if (this.flushTimer !== null) return
+ this.flushTimer = setTimeout(() => {
+ this.flushTimer = null
+ void this.flush()
+ }, SAVE_DEBOUNCE_MS)
+ }
+
+ /** Persist the current state when dirty, ordered after in-flight mutations. */
+ flush() {
+ if (!this.dirty) return Promise.resolve()
+ this.dirty = false
+ const task = this.serial.then(() => this.save())
+ this.serial = task.catch(() => undefined)
+ return task
+ }
+
+ async save() {
+ // Two dsh web instances sharing one profile clobber each other's canvas
+ // state. Warn loudly instead of silently losing work; a live lock held by
+ // another process or a file mtime that moved since our last write both
+ // indicate a second writer.
+ const before = await this.fileMtime()
+ if (this.lastKnownMtime !== null && before !== null && before !== this.lastKnownMtime) {
+ this.lastKnownMtime = before
+ if (!this.externalModWarned) {
+ this.externalModWarned = true
+ process.stderr.write('synapse: workspaces.json 已被另一个 dsh web 实例修改,本实例的写入可能覆盖其更改——请只运行一个实例\n')
+ }
+ }
+ await this.acquireLock()
+ try {
+ const temporaryFile = `${this.dataFile}.${process.pid}.tmp`
+ await writeFile(temporaryFile, `${JSON.stringify(this.state)}\n`, 'utf8')
+ await rename(temporaryFile, this.dataFile)
+ this.lastKnownMtime = (await stat(this.dataFile)).mtimeMs
+ } finally {
+ await this.releaseLock()
+ }
+ }
+
+ async fileMtime() {
+ try { return (await stat(this.dataFile)).mtimeMs } catch { return null }
+ }
+
+ /** Take an exclusive cross-process lock, breaking a stale one; warn when a live process holds it. */
+ async acquireLock() {
+ const lockFile = `${this.dataFile}.lock`
+ if (await this.tryAcquire(lockFile)) return
+ if (await this.lockIsStale(lockFile)) {
+ await unlink(lockFile).catch(() => {})
+ if (await this.tryAcquire(lockFile)) return
+ }
+ if (!this.lockWarned) {
+ this.lockWarned = true
+ process.stderr.write('synapse: 另一个 dsh web 实例正在写入 workspaces.json——请只运行一个实例,否则画布数据可能互相覆盖\n')
+ }
+ }
+
+ async tryAcquire(lockFile) {
+ try {
+ await writeFile(lockFile, `${process.pid}\n`, { flag: 'wx' })
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ /** A lock is stale when its owner PID is gone or the lock file is older than the stale window. */
+ async lockIsStale(lockFile) {
+ try {
+ const [content, stats] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)])
+ const tooOld = Date.now() - stats.mtimeMs > LOCK_STALE_MS
+ const pid = Number.parseInt(content, 10)
+ if (!Number.isInteger(pid)) return tooOld
+ if (pid === process.pid) return false
+ try {
+ process.kill(pid, 0)
+ return tooOld
+ } catch {
+ return true
+ }
+ } catch {
+ return false
+ }
+ }
+
+ async releaseLock() {
+ await unlink(`${this.dataFile}.lock`).catch(() => {})
+ }
+
+ workspace(workspaceId) {
+ const workspace = this.state.workspaces.find(item => item.id === workspaceId)
+ if (workspace === undefined) throw new NotFoundError('工作空间不存在')
+ return workspace
+ }
+
+ locateThread(threadId) {
+ for (const workspace of this.state.workspaces) {
+ const thread = workspace.threads.find(item => item.id === threadId)
+ if (thread !== undefined) return { workspace, thread }
+ }
+ throw new NotFoundError('节点不存在')
+ }
+
+ dshWorkspace(cwd, fallbackTitle) {
+ let workspace = this.state.workspaces.find(item => item.kind === 'dsh' && item.cwd === cwd)
+ if (workspace !== undefined) return workspace
+ const now = new Date().toISOString()
+ workspace = { id: randomUUID(), kind: 'dsh', cwd, title: workspaceTitle(cwd, fallbackTitle), createdAt: now, updatedAt: now, threads: [] }
+ this.state.workspaces.unshift(workspace)
+ return workspace
+ }
+
+ dshThread(workspace, session) {
+ let thread = workspace.threads.find(item => item.dshSessionId === session.id)
+ if (thread !== undefined) {
+ if (typeof session.title === 'string' && session.title.trim() !== '') {
+ const title = session.title.slice(0, MAX_TITLE_LENGTH)
+ thread.title = title
+ thread.dshSessionTitle = title
+ }
+ // `seedLength` is DSH's durable fork cut. Keep it even after the
+ // session has been restored, when its in-process `firstLiveSeq` moves.
+ const seedLength = session.header?.seedLength
+ if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
+ return thread
+ }
+ const parentSessionId = typeof session.header?.parentSession === 'string' ? session.header.parentSession : null
+ const parent = parentSessionId === null ? undefined : workspace.threads.find(item => item.dshSessionId === parentSessionId)
+ const siblings = workspace.threads.filter(item => item.sourceParentSessionId === parentSessionId)
+ const now = new Date().toISOString()
+ thread = {
+ id: randomUUID(),
+ title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
+ parentId: parent?.id ?? null,
+ sourceParentSessionId: parentSessionId,
+ sourceSeedLength: Number.isSafeInteger(session.header?.seedLength) && session.header.seedLength >= 0 ? session.header.seedLength : null,
+ dshSessionId: session.id,
+ dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
+ color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
+ // DSH projection stores only a neutral semantic anchor. The visual map
+ // lays out visible cards from the current conversation graph each render,
+ // so old/archived session counts must never leak into future coordinates.
+ position: parent === undefined ? { x: 86, y: 82 } : { x: parent.position.x + 400, y: parent.position.y },
+ createdAt: now,
+ updatedAt: now,
+ messages: [],
+ pendingProcess: [],
+ }
+ workspace.threads.push(thread)
+ // A child may arrive before its parent during startup replay. Repair that
+ // relation when the missing parent later reaches the projection.
+ for (const child of workspace.threads) {
+ if (child.sourceParentSessionId === session.id && child.parentId === null) child.parentId = thread.id
+ }
+ workspace.updatedAt = now
+ return thread
+ }
+
+ projectEventInto(workspace, thread, event) {
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
+ thread.title = event.data.title.slice(0, MAX_TITLE_LENGTH)
+ thread.dshSessionTitle = thread.title
+ thread.updatedAt = new Date(event.time).toISOString()
+ workspace.updatedAt = thread.updatedAt
+ return
+ }
+ if (event.type === 'tool/call' || event.type === 'tool/result') {
+ this.foldToolProcess(thread, event)
+ workspace.updatedAt = thread.updatedAt
+ return
+ }
+ const projection = projectableEvent(event)
+ if (projection === null || thread.messages.some(message => message.sourceSeq === event.seq)) return
+ const at = new Date(event.time).toISOString()
+ const message = {
+ id: randomUUID(),
+ text: projection.text,
+ kind: projection.kind,
+ sourceSeq: event.seq,
+ at,
+ ...(projection.kind === 'assistant' || projection.kind === 'error'
+ ? { turn: event.data?.turn, step: event.data?.step, process: [] }
+ : {}),
+ }
+ this.attachPendingProcess(thread, message)
+ thread.messages.push(message)
+ thread.updatedAt = at
+ workspace.updatedAt = at
+ if (thread.dshSessionTitle === null && projection.kind === 'user') {
+ thread.title = titleFromText(projection.text)
+ thread.dshSessionTitle = thread.title
+ }
+ }
+
+ /**
+ * Fold one tool call or result into the assistant message of its own
+ * turn/step, keyed by `callId`, so a tool invocation never becomes a
+ * separate canvas card. If a tool result arrives before its associated
+ * assistant/error message, retain it on the thread until that turn appears.
+ */
+ foldToolProcess(thread, event) {
+ const at = new Date(event.time).toISOString()
+ const data = event.data ?? {}
+ const target = [...thread.messages].reverse().find(message =>
+ (message.kind === 'assistant' || message.kind === 'error')
+ && (message.turn === data.turn && message.step === data.step
+ || message.turn === undefined && message.step === undefined))
+ const process = target === undefined ? (thread.pendingProcess ??= []) : (target.process ??= [])
+ const callId = String(event.type === 'tool/call' ? data.callId : data.message?.source?.callId ?? '')
+ const entry = process.find(item => item.callId === callId)
+ if (event.type === 'tool/call') {
+ if (entry === undefined) {
+ process.push({ callId, turn: data.turn, step: data.step, name: data.name, arguments: data.arguments, result: null, error: null })
+ } else {
+ entry.name = data.name
+ entry.arguments = data.arguments
+ }
+ } else {
+ const outcome = contentText(data.message?.content)
+ const error = errorText(data.error)
+ if (entry === undefined) {
+ process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', arguments: null, result: outcome, error })
+ } else {
+ entry.result = outcome
+ entry.error = error
+ }
+ }
+ thread.updatedAt = at
+ }
+
+ attachPendingProcess(thread, message) {
+ if (!Array.isArray(thread.pendingProcess) || thread.pendingProcess.length === 0 || !Array.isArray(message.process)) return
+ const matching = thread.pendingProcess.filter(entry => entry.turn === message.turn && entry.step === message.step)
+ if (matching.length === 0) return
+ message.process.push(...matching.map(({ turn, step, ...entry }) => entry))
+ thread.pendingProcess = thread.pendingProcess.filter(entry => entry.turn !== message.turn || entry.step !== message.step)
+ }
+
+ thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
+ return {
+ id: randomUUID(),
+ title: requiredText(title, MAX_TITLE_LENGTH, 'title'),
+ parentId: typeof parentId === 'string' && parentId.length > 0 ? parentId : null,
+ dshSessionId: typeof dshSessionId === 'string' && dshSessionId.length > 0 ? dshSessionId : null,
+ dshSessionTitle: typeof dshSessionTitle === 'string' ? dshSessionTitle.slice(0, MAX_TITLE_LENGTH) : null,
+ color: TOPIC_COLORS.includes(color) ? color : TOPIC_COLORS[order % TOPIC_COLORS.length],
+ position: positionOf(position ?? { x: 86 + (order % 3) * 410, y: 82 + Math.floor(order / 3) * 260 }),
+ createdAt: now,
+ updatedAt: now,
+ messages: [],
+ pendingProcess: [],
+ }
+ }
+
+ summary(workspace) {
+ return { id: workspace.id, kind: workspace.kind ?? 'manual', cwd: workspace.cwd ?? null, title: workspace.title, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, threadCount: workspace.threads.length }
+ }
+}
+
+class InputError extends Error {}
+class NotFoundError extends Error {}
+
+function normalizeState(value) {
+ let migrated = false
+ let state
+ if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
+ const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
+ migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
+ const workspaces = value.workspaces.map(workspace => ({
+ ...workspace,
+ threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
+ if (Array.isArray(thread.messages)) {
+ const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
+ if (messages.length !== thread.messages.length) migrated = true
+ return { ...thread, messages }
+ }
+ migrated = true
+ const notes = Array.isArray(thread.notes) ? thread.notes : []
+ const { notes: _notes, ...rest } = thread
+ return { ...rest, messages: notes, pendingProcess: [] }
+ }) : [],
+ }))
+ state = { ...value, version: value.version, hiddenSessionIds, workspaces }
+ } else if (value?.version === 1 && Array.isArray(value.workspaces)) {
+ const now = typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString()
+ state = {
+ version: 3,
+ hiddenSessionIds: [],
+ workspaces: value.workspaces.map((workspace, index) => {
+ const events = Array.isArray(workspace.events) ? workspace.events : []
+ const workspaceNow = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : now
+ return {
+ id: typeof workspace.id === 'string' ? workspace.id : randomUUID(),
+ title: typeof workspace.title === 'string' && workspace.title.trim() ? workspace.title : '未命名工作空间',
+ createdAt: typeof workspace.createdAt === 'string' ? workspace.createdAt : workspaceNow,
+ updatedAt: workspaceNow,
+ threads: events.length === 0 ? [] : [{
+ id: randomUUID(), title: workspace.title || '历史记录', parentId: null, dshSessionId: null, dshSessionTitle: null,
+ color: TOPIC_COLORS[index % TOPIC_COLORS.length], position: { x: 86, y: 82 }, createdAt: workspaceNow, updatedAt: workspaceNow,
+ messages: events.map(event => ({ id: typeof event.id === 'string' ? event.id : randomUUID(), text: String(event.text ?? ''), at: typeof event.at === 'string' ? event.at : workspaceNow })),
+ }],
+ }
+ }),
+ }
+ migrated = true
+ } else {
+ throw new Error('expected Synapse data version 1, 2, 3, or 4')
+ }
+ if (state.version !== 4) {
+ if (foldLegacyToolCards(state.workspaces)) migrated = true
+ state.version = 4
+ migrated = true
+ }
+ return { state, migrated }
+}
+
+/**
+ * Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
+ * preceding assistant message's `process` list, pairing each call with the
+ * result that follows it in order, so every tool invocation lives in one
+ * home: the assistant turn card.
+ */
+function foldLegacyToolCards(workspaces) {
+ let changed = false
+ for (const workspace of workspaces) {
+ for (const thread of workspace.threads ?? []) {
+ if (!Array.isArray(thread.messages)) continue
+ const folded = []
+ let assistant = null
+ let pending = []
+ for (const message of thread.messages) {
+ if (message.kind === 'assistant') {
+ assistant = message
+ assistant.process ??= []
+ pending = []
+ folded.push(message)
+ continue
+ }
+ if (message.kind !== 'tool' && message.kind !== 'tool-result') {
+ folded.push(message)
+ continue
+ }
+ if (assistant === null) {
+ folded.push(message)
+ continue
+ }
+ changed = true
+ if (message.kind === 'tool') {
+ const [name = '工具调用', ...argumentLines] = message.text.split('\n')
+ const entry = { callId: `legacy-${assistant.process.length}`, name, arguments: argumentLines.join('\n'), result: null, error: null }
+ pending.push(entry)
+ assistant.process.push(entry)
+ } else {
+ const entry = pending.shift() ?? (() => {
+ const orphan = { callId: `legacy-orphan-${assistant.process.length}`, name: '工具调用', arguments: null, result: null, error: null }
+ assistant.process.push(orphan)
+ return orphan
+ })()
+ entry.result = message.text
+ }
+ }
+ thread.messages = folded
+ }
+ }
+ return changed
+}
+
+function positionOf(value) {
+ const x = Number(value?.x)
+ const y = Number(value?.y)
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new InputError('position 必须包含有效坐标')
+ return { x: Math.round(Math.max(-2000, Math.min(5000, x))), y: Math.round(Math.max(-2000, Math.min(5000, y))) }
+}
+
+function requiredText(value, maxLength, field) {
+ if (typeof value !== 'string') throw new InputError(`${field} 必须是文本`)
+ const text = value.trim()
+ if (text.length === 0) throw new InputError(`${field} 不能为空`)
+ if (text.length > maxLength) throw new InputError(`${field} 超过长度限制`)
+ return text
+}
+
+function projectableEvent(event) {
+ switch (event.type) {
+ case 'user/message': {
+ const text = contentText(event.data.content)
+ return isRuntimeContextText(text) ? null : noteProjection('user', text)
+ }
+ case 'assistant/message':
+ return noteProjection('assistant', contentText(event.data?.message?.content))
+ case 'todo/write':
+ return noteProjection('todo', Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : '')
+ case 'turn/end': {
+ const reason = event.data?.reason
+ if (reason?.kind === 'error') return noteProjection('error', errorText(reason.error) ?? '本轮执行失败')
+ if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return noteProjection('error', '本轮已取消')
+ return null
+ }
+ default:
+ return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event.type)
+ ? noteProjection('error', errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败')
+ : null
+ }
+}
+
+function errorText(value) {
+ if (typeof value === 'string') return value.trim() || null
+ if (value === null || value === undefined || typeof value !== 'object') return null
+ const name = typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : ''
+ const code = typeof value.code === 'string' && value.code.trim() !== '' ? value.code.trim() : ''
+ const message = typeof value.message === 'string' && value.message.trim() !== '' ? value.message.trim() : ''
+ if (message !== '') return [name, code].filter(Boolean).concat(message).join(': ')
+ return [name, code].filter(Boolean).join(': ') || null
+}
+
+function noteProjection(kind, text) {
+ const normalized = text.trim()
+ if (normalized === '') return null
+ if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
+ return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
+}
+
+function isRuntimeContextText(text) {
+ return typeof text === 'string' && text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
+}
+
+function isRuntimeContextMessage(message) {
+ return message?.kind === 'user' && isRuntimeContextText(message.text)
+}
+
+function contentText(content) {
+ if (!Array.isArray(content)) return ''
+ return content.flatMap(block => {
+ if (block?.type === 'text') return [block.text]
+ if (block?.type === 'tool-call') return [block.name, block.arguments]
+ if (block?.type === 'tool-result') return contentText(block.content)
+ return []
+ }).filter(value => typeof value === 'string' && value.trim() !== '').join('\n')
+}
+
+function titleFromText(text) {
+ const line = text.replaceAll(/\s+/g, ' ').trim()
+ return (line.length > 42 ? `${line.slice(0, 42)}...` : line) || 'DSH 会话'
+}
+
+function sessionCwd(session) {
+ const cwd = session.header?.meta?.cwd ?? session.header?.cwd
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
+}
+
+function workspaceTitle(cwd, fallbackTitle) {
+ if (cwd === '未指定工作目录') return fallbackTitle
+ const segment = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1)
+ return segment && segment.trim() !== '' ? segment : fallbackTitle
+}
+
+async function readJson(req) {
+ const chunks = []
+ let length = 0
+ for await (const chunk of req) {
+ length += chunk.length
+ if (length > MAX_BODY_BYTES) throw new InputError('请求内容过大')
+ chunks.push(chunk)
+ }
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new InputError('请求不是有效 JSON') }
+}
+
+function sendJson(res, status, body) {
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
+ res.end(JSON.stringify(body))
+}
+
+function sendFile(res, contentType, body) {
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
+ res.end(body)
+}
+
+function page() {
+ return `Synapse for DSH
`
+}
+
+/** Mount Synapse routes on the existing DSH Web Server. */
+export function apply(ctx, config) {
+ const store = new WorkspaceStore(config?.dataFile)
+ const autoProjection = config?.autoProjection !== false
+ const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
+ ? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
+ : 'DSH 任务'
+ const reportProjectionFailure = error => {
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
+ }
+ const replaySession = session => {
+ // Forks inherit their parent's log. The canvas already represents that
+ // history through the parent node, so only project the child's live tail.
+ const replayFrom = session.header?.parentSession === undefined ? 0 : session.firstLiveSeq
+ void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
+ }
+ // Buffer live events per session and flush them in one write per microtask,
+ // so a burst of turn events coalesces into a single save instead of N.
+ const projectionQueue = []
+ let projectionScheduled = false
+ const enqueueProjection = (session, event) => {
+ projectionQueue.push({ session, event })
+ if (projectionScheduled) return
+ projectionScheduled = true
+ queueMicrotask(() => {
+ projectionScheduled = false
+ const batch = projectionQueue.splice(0)
+ const bySession = new Map()
+ for (const item of batch) {
+ const entry = bySession.get(item.session.id)
+ if (entry === undefined) bySession.set(item.session.id, [item.session, [item.event]])
+ else entry[1].push(item.event)
+ }
+ for (const [sessionId, [session, events]] of bySession) {
+ void store.projectEvents(session, events, projectionWorkspaceTitle).catch(reportProjectionFailure)
+ }
+ })
+ }
+ if (autoProjection) {
+ ctx.on('session/created', replaySession)
+ ctx.on('session/event', enqueueProjection)
+ for (const session of ctx.sessions.list()) replaySession(session)
+ }
+ // The DSH /api browser-trust fence does not cover /synapse routes, so this
+ // handler checks the Host header itself: localhost is allowed by default and
+ // additional authorities opt in through config.trustedHosts (mirrors the
+ // fence's DNS-rebinding defense).
+ const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean)])
+ const api = async (req, res) => {
+ try {
+ const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
+ if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: '不被信任的 Host' })
+ const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
+ if (path === '/synapse/api/reset' && req.method === 'POST') return sendJson(res, 200, await store.clearLegacy(ctx.sessions.list()))
+ if (path === '/synapse/api/workspaces') {
+ if (req.method === 'GET') return sendJson(res, 200, { workspaces: await store.list() })
+ if (req.method === 'POST') return sendJson(res, 201, { workspace: await store.create((await readJson(req)).title) })
+ }
+ const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]+)$/i.exec(path)
+ if (workspace !== null) {
+ if (req.method === 'GET') return sendJson(res, 200, { workspace: await store.get(workspace[1]) })
+ if (req.method === 'POST') return sendJson(res, 201, { thread: await store.createThread(workspace[1], await readJson(req)) })
+ }
+ const branch = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/branch$/i.exec(path)
+ if (branch !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.branch(branch[1], await readJson(req)) })
+ if (path === '/synapse/api/sessions/sync' && req.method === 'POST') { const body = await readJson(req); return sendJson(res, 200, { workspaces: await store.syncSessions(body.sessions, body.removedSessionIds) }) }
+ const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
+ if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
+ const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
+ if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
+ if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
+ return sendJson(res, 404, { error: '接口不存在' })
+ } catch (error) {
+ if (error instanceof InputError) return sendJson(res, 400, { error: error.message })
+ if (error instanceof NotFoundError) return sendJson(res, 404, { error: error.message })
+ ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
+ return sendJson(res, 500, { error: 'Synapse 数据暂时不可用' })
+ }
+ }
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse', handler: (_req, res) => { res.writeHead(302, { location: '/synapse/' }); res.end() } }), 'synapse: redirect')
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/', handler: (_req, res) => { sendFile(res, 'text/html; charset=utf-8', page()) } }), 'synapse: page')
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/app.js', handler: async (_req, res) => { sendFile(res, 'text/javascript; charset=utf-8', await readFile(new URL('./app.js', import.meta.url), 'utf8')) } }), 'synapse: app')
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/styles.css', handler: async (_req, res) => { sendFile(res, 'text/css; charset=utf-8', await readFile(new URL('./styles.css', import.meta.url), 'utf8')) } }), 'synapse: styles')
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/deepseek-mark.svg', handler: async (_req, res) => { sendFile(res, 'image/svg+xml', await readFile(new URL('./deepseek-mark.svg', import.meta.url), 'utf8')) } }), 'synapse: DeepSeek mark')
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/synapse/api', handler: api }), 'synapse: api')
+}
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/package.json b/apps/deepviewer-adapter/workflows/vendor/synapse/package.json
new file mode 100644
index 0000000..7113319
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "dsh-synapse",
+ "version": "0.4.1",
+ "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
+ "type": "module",
+ "main": "index.js",
+ "exports": {
+ ".": "./index.js",
+ "./client": "./client.js",
+ "./cordis.patch.yml": "./cordis.patch.yml",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "index.js",
+ "client.js",
+ "app.js",
+ "deepseek-mark.svg",
+ "styles.css",
+ "docs",
+ "cordis.patch.yml",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "node --check index.js && node --check client.js && node --check app.js",
+ "prepare": "pnpm run build",
+ "test": "node --test test/*.test.js"
+ },
+ "engines": {
+ "node": ">=22.19.0"
+ },
+ "license": "MIT",
+ "dsh": {
+ "bundle": {
+ "patch": "./cordis.patch.yml"
+ },
+ "client": {
+ "platform": "web",
+ "inject": [
+ "@deepseek-ai/dsh-client-runtime"
+ ],
+ "immediately": false
+ }
+ }
+}
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/styles.css b/apps/deepviewer-adapter/workflows/vendor/synapse/styles.css
new file mode 100644
index 0000000..2ebca98
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/styles.css
@@ -0,0 +1,447 @@
+:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; color: #1f2937; background: #f5f7fa; }
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; background: #f5f7fa; }
+button, select, textarea { font: inherit; }
+button { border: 1px solid #d8dee8; border-radius: 6px; background: #fff; color: #374151; cursor: pointer; min-height: 32px; padding: 0 11px; }
+button:hover { border-color: #9ca3af; background: #f3f4f6; color: #111827; }
+button:disabled { cursor: not-allowed; opacity: .5; }
+button.primary { border-color: #111827; background: #111827; color: #fff; }
+button.primary:hover { border-color: #000; background: #000; color: #fff; }
+.synapse-shell { --sidebar-width: 280px; --sidebar-half: 140px; height: 100vh; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-rows: 42px minmax(0, 1fr); overflow: hidden; }
+.synapse-shell.sidebar-collapsed { --sidebar-width: 56px; --sidebar-half: 28px; }
+.topbar { position: relative; grid-column: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border: 0; background: #fff; }
+.brand, .top-actions, .view-switch, .thread-card-head, .thread-meta, .detail-actions, .compare-title { display: flex; align-items: center; }
+.sidebar-brand-row { box-sizing: border-box; display: flex; align-items: center; gap: 8px; height: 60px; flex: none; margin-bottom: 8px; padding: 8px 0 8px 4px; overflow: hidden; }
+.brand { gap: 8px; min-width: 0; flex: 1; }
+.brand::before { width: 31px; height: 26px; flex: none; content: ''; background: center / contain no-repeat url('/synapse/deepseek-mark.svg'); }
+.brand::after { content: '非线性工作台'; flex: none; border-radius: 3px; background: #111827; padding: 2px 5px; color: #fff; font-size: 10px; font-weight: 400; line-height: 1; white-space: nowrap; }
+.brand-mark { display: none; }
+.brand strong { font-size: 20px; font-weight: 600; letter-spacing: 0; }
+.sidebar-toggle { display: inline-grid; width: 28px; height: 28px; min-height: 28px; flex: none; place-items: center; border: 0; border-radius: 50%; background: transparent; padding: 0; color: #6b7280; }
+.sidebar-toggle:hover { border: 0; background: #eef0f2; color: #374151; }
+.sidebar-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; }
+.top-actions { gap: 10px; }
+.view-switch { position: fixed; z-index: 80; top: 12px; left: 50%; gap: 2px; padding: 3px; transform: translateX(-50%); border: 1px solid #d1d5db; border-radius: 999px; background: rgba(255,255,255,.96); }
+.view-switch button { min-height: 28px; border: 0; border-radius: 999px; background: transparent; padding: 0 11px; color: #64748b; font-size: 12px; font-weight: 650; }
+.view-switch button:hover { background: #e5e7eb; color: #111827; }
+.view-switch button.active { background: #111827; color: #fff; }
+.view-switch button:focus-visible { outline: 2px solid #111827; outline-offset: 2px; }
+.canvas-controls { position: fixed; z-index: 80; top: 14px; right: 16px; display: flex; align-items: center; gap: 5px; }
+.canvas-controls button { min-width: 32px; padding: 0 8px; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
+.canvas-controls button svg { width: 14px; height: 14px; flex: none; }
+.canvas-controls span { min-width: 42px; color: #64748b; font-size: 12px; text-align: center; }
+.sidebar { grid-row: 1 / -1; display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 6px 12px; border-right: 1px solid #e5e7eb; background: #f8f9fa; }
+.new-workspace { display: flex; width: calc(100% - 4px); min-height: auto; height: 38px; align-items: center; justify-content: center; gap: 6px; margin: 0 2px 8px; border-color: rgba(0,0,0,.1); border-radius: 12px; background: #fff; padding: 8px 16px; color: #111827; font-size: 14px; font-weight: 500; }
+.new-session-icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.5; }
+.new-workspace:hover { border-color: rgba(0,0,0,.16); background: #f7f7f8; color: #111827; }
+.workspace-label, .sidebar-heading { color: #7c8798; font-size: 14px; font-weight: 400; letter-spacing: 0; }
+.workspace-label { display: grid; gap: 9px; margin: 16px 4px 0; }
+.workspace-select { position: relative; display: block; }
+.workspace-select svg { position: absolute; top: 50%; left: 9px; width: 15px; height: 15px; transform: translateY(-50%); fill: none; pointer-events: none; stroke: #64748b; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; }
+.workspace-label select { width: 100%; min-height: 33px; border: 1px solid #d8dee8; border-radius: 6px; background: #fff; color: #374151; padding: 0 28px 0 30px; }
+.sidebar-heading { display: flex; align-items: center; justify-content: space-between; margin: 23px 4px 0; padding: 0; }
+.sidebar-heading button { display: grid; width: 24px; min-height: 24px; place-items: center; padding: 0; color: #111827; font-size: 17px; }
+.thread-tree { display: grid; gap: 3px; overflow: auto; padding-bottom: 12px; }
+.tree-row { display: flex; min-width: 0; align-items: center; gap: 8px; border: 0; background: transparent; color: #4b5563; text-align: left; }
+.tree-row:hover { background: #f3f4f6; }
+.tree-row.active { background: #e5e7eb; color: #111827; font-weight: 650; }
+.tree-row span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
+.tree-row i { margin-left: auto; color: #94a3b8; font-size: 10px; font-style: normal; }
+.tree-dot, .topic-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--thread-color, #374151); }
+.tree-row { --thread-color: #3478f6 !important; }
+.tree-empty { padding: 12px 5px; color: #94a3b8; font-size: 12px; }
+.main-stage { position: relative; grid-column: 2; min-width: 0; min-height: 0; overflow: hidden; background: #f5f7fa; }
+.sidebar-collapsed .sidebar { align-items: center; padding: 18px 10px 6px; }
+.sidebar-collapsed .sidebar-brand-row { width: 36px; height: 36px; margin-bottom: 12px; padding: 0; }
+.sidebar-collapsed .brand { display: none; }
+.sidebar-collapsed .sidebar-toggle { width: 36px; height: 36px; min-height: 36px; color: #111827; }
+.sidebar-collapsed .new-workspace { width: 36px; height: 36px; min-height: 36px; margin: 0 0 12px; border-color: transparent; background: transparent; padding: 0; }
+.sidebar-collapsed .new-workspace span, .sidebar-collapsed .workspace-label, .sidebar-collapsed .sidebar-heading, .sidebar-collapsed .thread-tree { display: none; }
+.status-message { position: absolute; z-index: 6; top: 12px; right: 16px; display: flex; align-items: center; gap: 10px; max-width: 400px; padding: 9px 10px 9px 13px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #991b1b; font-size: 12px; box-shadow: 0 3px 10px rgba(127,29,29,.12); }
+.status-message span { flex: 1; line-height: 1.5; }
+.status-message button { display: grid; width: 22px; height: 22px; flex: none; place-items: center; border: 0; border-radius: 50%; background: transparent; padding: 0; color: #b91c1c; font-size: 15px; line-height: 1; }
+.status-message button:hover { background: #fee2e2; }
+.main-stage { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: #fff; }
+.canvas-tabs { display: flex; height: 32px; flex: none; align-items: end; gap: 26px; padding: 0 24px; border-bottom: 1px solid #e5e7eb; background: #fff; }
+.canvas-tabs button { min-height: 32px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; padding: 0 2px; color: #8a919b; font-size: 13px; font-weight: 500; }
+.canvas-tabs button:hover { background: transparent; color: #3478f6; }
+.canvas-tabs button.active { border-bottom-color: #3478f6; color: #3478f6; }
+.canvas-tabs button:disabled { cursor: default; opacity: .4; }
+.canvas-view { position: relative; display: flex; min-height: 0; flex: 1; }
+/* The canvas void is light gray (matching the DSH main stage) instead of pure
+ white, and the pan cursor is a custom dark hand with a light outline so it
+ stays visible on both light and dark canvases (system light-theme pointers
+ vanish on a white void). */
+.canvas-viewport { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; background: #f5f7fa; cursor: url("data:image/svg+xml;utf8, ") 11 12, grab; }
+.canvas-viewport.is-panning { cursor: url("data:image/svg+xml;utf8, ") 11 12, grabbing; user-select: none; }
+/* While panning, promote the canvas to its own composited layer so the
+ browser GPU-transforms the whole subtree instead of repainting every card
+ and connector per frame. Zoom drops the layer to avoid upscaling a stale
+ raster (see zoomCanvas in app.js). */
+.canvas-viewport.is-panning .canvas-content { will-change: transform; }
+.canvas-content { position: absolute; inset: 0; transform-origin: 0 0; }
+.connectors { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
+.connectors path { fill: none; stroke: #aebbc9; stroke-width: 1.5; }
+.connectors path.active-connector { stroke: #3478f6; }
+.connectors .draft-connector { stroke: #3478f6; stroke-dasharray: 5 4; }
+.cards-layer { position: absolute; inset: 0; }
+.thread-card { position: absolute; display: grid; width: 310px; height: 276px; grid-template-rows: auto auto minmax(0, 1fr) auto; border: 1px solid #d8e0e8; border-radius: 7px; background: #fff; box-shadow: 0 3px 11px rgba(33,48,67,.08); overflow: visible; }
+.thread-card:hover { border-color: #9ca3af; box-shadow: 0 8px 20px rgba(33,48,67,.13); }
+.thread-card.selected { box-shadow: 0 0 0 2px rgba(17,24,39,.16), 0 8px 20px rgba(33,48,67,.13); }
+.node-handle { position: absolute; top: -8px; left: 50%; display: grid; width: 28px; height: 15px; min-height: 15px; place-items: center; transform: translateX(-50%); border: 1px solid #d8e0e8; border-radius: 4px; background: #fff; cursor: grab; padding: 0; }
+.node-handle::after { content: '...'; display: block; margin: -5px 0 0 2px; color: #94a3b8; font-size: 11px; letter-spacing: 1.5px; line-height: 1; }
+.thread-card-head { gap: 8px; min-width: 0; padding: 15px 12px 8px; }
+.thread-card-head .topic-dot { background: var(--thread-color); }
+.thread-title { min-width: 0; flex: 1; min-height: 22px; overflow: hidden; border: 0; background: transparent; padding: 0; color: #1f2937; font-size: 13px; font-weight: 720; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
+.thread-title:hover { background: transparent; color: #111827; }
+.graph-continue-button, .graph-fold-button, .graph-branch-button { position: absolute; right: -12px; z-index: 4; display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; border-color: #9ca3af; border-radius: 50%; background: #fff; padding: 0; color: #111827; }
+.graph-continue-button, .graph-fold-button { top: 50%; transform: translateY(-50%); }
+.graph-branch-button { top: calc(50% + 30px); transform: translateY(-50%); }
+.graph-continue-button svg, .graph-fold-button svg, .graph-branch-button svg { width: 13px; height: 13px; }
+.graph-continue-button svg, .graph-fold-button svg { fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.8; }
+.graph-branch-button svg { fill: currentColor; }
+.graph-continue-button:hover, .graph-fold-button:hover, .graph-branch-button:hover { border-color: #111827; background: #f3f4f6; }
+.graph-continue-button:focus-visible, .graph-fold-button:focus-visible, .graph-branch-button:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; }
+.selection-followup { position: fixed; z-index: 90; display: inline-flex; min-height: 30px; align-items: center; gap: 4px; transform: translateX(-100%); border-color: #3478f6; border-radius: 6px; background: #3478f6; padding: 0 8px; color: #fff; font-size: 12px; font-weight: 650; line-height: 1; box-shadow: 0 4px 12px rgba(37,99,235,.24); user-select: none; }
+.selection-followup:hover { border-color: #2563eb; background: #2563eb; color: #fff; }
+.selection-followup svg { width: 14px; height: 14px; flex: none; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.5; }
+.selection-followup[hidden] { display: none; }
+.thread-meta { gap: 7px; padding: 0 12px; color: #94a3b8; font-size: 10px; }
+.thread-meta span + span::before { content: '·'; margin-right: 7px; }
+.thread-meta .card-process-count { display: inline-flex; align-items: center; min-height: 18px; margin-left: auto; border-radius: 9px; background: #eef4ff; padding: 0 6px; color: #3478f6; font-size: 10px; font-weight: 650; white-space: nowrap; }
+.thread-meta .card-process-count::before { content: none; }
+.thread-meta .card-error-status { color: #b42318; font-weight: 650; }
+.thread-answer { min-width: 0; min-height: 0; margin: 10px 14px; overflow-x: hidden; overflow-y: auto; color: #526174; font-size: 12px; line-height: 1.58; overflow-wrap: anywhere; overscroll-behavior: contain; scrollbar-color: #9ca3af transparent; scrollbar-width: thin; }
+.thread-answer::-webkit-scrollbar { width: 6px; height: 0; }
+.thread-answer::-webkit-scrollbar-track { background: transparent; }
+.thread-answer::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: #9ca3af; background-clip: padding-box; }
+.thread-answer::-webkit-scrollbar-thumb:hover { background: #6b7280; }
+.thread-answer p { margin: 0 0 8px; overflow-wrap: anywhere; }
+.thread-answer h1, .thread-answer h2, .thread-answer h3 { margin: 0 0 8px; color: #334155; font-size: 13px; line-height: 1.4; }
+.thread-answer ul, .thread-answer ol { margin: 0 0 8px; padding-left: 19px; }
+.thread-answer li + li { margin-top: 3px; }
+.thread-answer code { overflow-wrap: anywhere; border-radius: 3px; background: #eef2f5; padding: 1px 3px; color: #334155; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; }
+.thread-answer pre { margin: 0 0 8px; max-width: 100%; overflow-x: auto; border: 1px solid #e0e7ee; border-radius: 4px; background: #fff; padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.thread-answer pre code { background: transparent; padding: 0; }
+.thread-answer table { display: block; max-width: 100%; margin: 0 0 8px; overflow-x: auto; border-collapse: collapse; font-size: 11px; }
+.thread-answer th, .thread-answer td { border: 1px solid #d8e0e8; padding: 4px 6px; text-align: left; overflow-wrap: anywhere; white-space: normal; }
+.thread-answer th { background: #f5f7fa; font-weight: 720; }
+.thread-answer-empty { color: #94a3b8; }
+.thread-answer-pending, .message-pending p { color: #374151; font-style: italic; }
+.thread-answer-error { display: -webkit-box; margin: 8px 0 0; overflow: hidden; color: #b42318; font-weight: 620; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
+.thread-card footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; padding: 7px; border-top: 1px solid #edf0f3; }
+.thread-card footer button { min-height: 26px; padding: 0 7px; border: 0; display: inline-flex; align-items: center; gap: 4px; color: #526174; font-size: 11px; }
+.thread-card footer button svg { width: 13px; height: 13px; flex: none; }
+.thread-card footer button:hover { background: #f3f4f6; color: #111827; border-radius: 6px; }
+.thread-card footer .compare-button { margin-right: auto; color: #374151; }
+.thread-card footer .compare-button.selected { background: #e5e7eb; color: #111827; }
+.card-inspector { position: absolute; z-index: 30; top: 0; right: 0; bottom: 0; display: flex; width: min(460px, calc(100% - 48px)); min-width: 360px; flex-direction: column; border-left: 1px solid #dbe3ec; background: #fff; opacity: 1; transform: translateX(0); transition: transform 180ms cubic-bezier(.23,1,.32,1), opacity 150ms ease-out; }
+.card-inspector.is-opening, .card-inspector.is-closing { opacity: 0; transform: translateX(28px); }
+.card-inspector-head { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 12px; align-items: start; padding: 18px 20px 12px; background: #fff; }
+.card-inspector-meta { display: flex; align-items: center; gap: 7px; color: #8390a0; font-size: 11px; }
+.card-inspector-meta span + span::before { content: '·'; margin-right: 7px; color: #bdc7d2; }
+.card-inspector-meta span:last-child { color: inherit; font-weight: inherit; }
+.card-inspector-meta .card-inspector-error-status { color: #b42318; font-weight: 650; }
+.card-inspector-head h2 { margin: 8px 0 0; overflow-wrap: anywhere; color: #172033; font-size: 15px; font-weight: 680; line-height: 1.5; }
+.card-inspector-close { display: grid; width: 32px; height: 32px; min-height: 32px; place-items: center; border: 0; border-radius: 50%; background: transparent; padding: 0; color: #718096; }
+.card-inspector-close:hover { background: #eef2f6; color: #1f2937; }
+.card-inspector-close:active, .card-inspector-actions button:active { transform: scale(.97); }
+.card-inspector-close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
+.card-inspector-scroll { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow-y: auto; padding: 24px 20px 20px; overscroll-behavior: contain; }
+.card-inspector-answer { color: #314057; font-size: 13px; line-height: 1.75; }
+.card-inspector-answer > * + * { margin-top: 12px; }
+.card-inspector-answer p { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
+.card-inspector-answer ul, .card-inspector-answer ol { margin: 0; padding-left: 20px; }
+.card-inspector-answer li + li { margin-top: 5px; }
+.card-inspector-answer h1, .card-inspector-answer h2, .card-inspector-answer h3 { margin: 0; color: #1e293b; font-size: 15px; line-height: 1.45; }
+.card-inspector-answer blockquote { margin: 0; border-left: 3px solid #dbe3ec; padding-left: 10px; color: #64748b; }
+.card-inspector-answer code { overflow-wrap: anywhere; border-radius: 3px; background: #eef2f5; padding: 1px 3px; color: #334155; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
+.card-inspector-answer pre { max-width: 100%; margin: 0; overflow-x: auto; border: 1px solid #e0e7ee; border-radius: 6px; background: #f8fafc; padding: 10px 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.card-inspector-answer pre code { background: transparent; padding: 0; }
+.card-inspector-answer table { display: block; max-width: 100%; margin: 0; overflow-x: auto; border-collapse: collapse; font-size: 12px; }
+.card-inspector-answer th, .card-inspector-answer td { border: 1px solid #d8e0e8; padding: 5px 8px; text-align: left; overflow-wrap: anywhere; white-space: normal; }
+.card-inspector-answer th { background: #f5f7fa; font-weight: 720; }
+.card-inspector-pending { margin: 0; color: #7b8794; font-size: 13px; font-style: italic; }
+.card-inspector-error { margin: 0; border-left: 3px solid #d92d20; padding: 3px 0 3px 10px; color: #7a271a; font-size: 13px; line-height: 1.65; }
+.card-inspector-error strong { display: block; color: #b42318; font-size: 12px; }
+.card-inspector-error p { margin: 5px 0 0; overflow-wrap: anywhere; white-space: pre-wrap; }
+.card-inspector .process-records { margin-top: 22px; border-top-style: solid; padding-top: 12px; }
+.card-inspector .process-records-fold { border-radius: 0; background: transparent; padding: 4px 0; }
+.card-inspector .process-records-fold:hover { background: transparent; }
+.card-inspector-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; padding: 11px 14px; border-top: 1px solid #e7edf3; background: #fff; }
+.card-inspector-actions button { display: inline-flex; min-height: 30px; align-items: center; gap: 4px; padding: 0 9px; font-size: 11px; font-weight: 600; transition: transform 140ms ease-out, border-color 150ms ease, background 150ms ease, color 150ms ease; }
+.card-inspector-actions button svg { width: 13px; height: 13px; flex: none; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; }
+.card-inspector-actions .primary { border-color: #3478f6; background: #3478f6; color: #fff; }
+.card-inspector-actions .primary:hover { border-color: #2563eb; background: #2563eb; }
+.draft-card { z-index: 2; height: auto; min-height: 228px; border-style: dashed; border-color: #9ca3af; box-shadow: 0 7px 19px rgba(17,24,39,.1); }
+.draft-card strong { color: #1f2937; font-size: 13px; }
+.draft-branch-form { display: grid; justify-items: center; gap: 10px; padding: 10px 12px 12px; }
+.draft-quick-phrases { display: flex; width: 100%; flex-wrap: wrap; gap: 6px; }
+.draft-quick-phrase, .draft-quick-phrase-add-button { min-height: 26px; border-color: #d8e0e8; border-radius: 999px; background: #f8fafc; padding: 0 9px; color: #526174; font-size: 11px; font-weight: 600; }
+.draft-quick-phrase:hover, .draft-quick-phrase-add-button:hover { border-color: #9bbcf7; background: #eef4ff; color: #2563eb; }
+.draft-quick-phrase-add-button { display: grid; width: 26px; place-items: center; padding: 0; }
+.draft-quick-phrase-add-button svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.6; }
+.draft-quick-editor { display: grid; width: 100%; gap: 7px; padding-bottom: 2px; }
+.draft-quick-editor-list { display: grid; gap: 5px; }
+.draft-quick-phrase-editor-row, .draft-quick-phrase-add { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 6px; }
+.draft-quick-phrase-editor-row input, .draft-quick-phrase-add input { min-width: 0; height: 28px; border: 1px solid #cbd8e3; border-radius: 5px; background: #fff; padding: 0 8px; color: #334155; font-size: 11px; outline-color: #3478f6; }
+.draft-quick-phrase-editor-row button, .draft-quick-phrase-add button { display: grid; width: 28px; min-height: 28px; place-items: center; border-radius: 5px; padding: 0; }
+.draft-quick-phrase-editor-row button { border-color: #d8e0e8; color: #64748b; }
+.draft-quick-phrase-editor-row button:hover { border-color: #f0b4b4; background: #fef2f2; color: #b91c1c; }
+.draft-quick-phrase-editor-row svg, .draft-quick-phrase-add svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
+.draft-quick-editor-close { justify-self: end; min-height: 26px; border: 0; background: transparent; padding: 0 6px; color: #64748b; font-size: 11px; }
+.draft-quick-editor-close:hover { background: #f3f4f6; color: #111827; }
+.draft-branch-form textarea { width: 100%; min-height: 104px; resize: vertical; border: 1px solid #cbd8e3; border-radius: 5px; padding: 8px 12px; color: #334155; outline-color: #3478f6; }
+.draft-actions { display: flex; width: 100%; justify-content: flex-end; gap: 8px; }
+.draft-actions button { display: grid; width: 32px; height: 32px; min-height: 32px; place-items: center; border-radius: 50%; background: #fff; padding: 0; color: #64748b; line-height: 0; }
+.draft-actions button.primary { border-color: #3478f6; background: #3478f6; color: #fff; }
+.draft-actions svg { display: block; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
+.empty-canvas, .compare-empty { display: grid; align-content: center; justify-items: center; height: 100%; padding: 32px; text-align: center; }
+.empty-canvas strong, .compare-empty strong { color: #334155; font-size: 16px; }
+.empty-canvas p, .compare-empty p { max-width: 460px; color: #64748b; font-size: 13px; line-height: 1.65; }
+.empty-canvas div { display: flex; gap: 8px; }
+.detail-view { box-sizing: border-box; width: min(880px, 100%); height: 100%; min-height: 0; flex: 1; display: flex; flex-direction: column; margin: 0 auto; background: #fbfcfe; }
+.detail-head { flex: none; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 34px 38px 18px; border-bottom: 1px solid #e7edf3; }
+.detail-head-title { min-width: 0; }
+.detail-head-meta { display: flex; align-items: center; gap: 9px; }
+.detail-badge { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; background: #eaf0fa; padding: 3px 9px; color: #3b6fd4; font-size: 11px; font-weight: 750; letter-spacing: .05em; }
+.detail-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.detail-subtitle { overflow: hidden; color: #8a97a8; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.detail-head h1 { margin: 10px 0 0; overflow-wrap: anywhere; color: #152033; font-size: 21px; font-weight: 650; line-height: 1.35; letter-spacing: 0; }
+.detail-head-actions { flex: none; display: flex; gap: 8px; }
+.detail-head-actions button { min-height: 32px; border: 1px solid #d9e1ea; border-radius: 7px; background: #fff; padding: 0 12px; color: #3d4a5c; font-size: 12px; font-weight: 600; }
+.detail-head-actions button:hover { border-color: #b9c7d8; background: #f4f7fb; }
+.detail-head-actions button.primary { border-color: #3478f6; background: #3478f6; color: #fff; }
+.detail-head-actions button.primary:hover { background: #2563eb; }
+.detail-scroll { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding: 22px 38px 24px; }
+.note-empty { padding: 34px 25px; border: 1px dashed #cbd5df; border-radius: 10px; color: #8a97a8; font-size: 13px; text-align: center; }
+.message { padding: 13px 16px; border: 1px solid #e2e8f0; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.03); }
+.message header { display: flex; align-items: center; gap: 7px; color: #475569; font-size: 11px; font-weight: 650; }
+.message time { color: #94a3b8; font-weight: 400; }
+.message-role { color: #334155; }
+.message-avatar { flex: none; width: 18px; height: 18px; border-radius: 50%; background: linear-gradient(135deg, #3478f6, #7c3aed); }
+.message-user { align-self: flex-end; max-width: 84%; border-color: transparent; background: #eaf1ff; box-shadow: none; }
+.message-user header { justify-content: flex-end; color: #5a78b0; }
+.message-user .message-body p { color: #27375f; }
+.message-assistant { border-left: 3px solid #3478f6; }
+.message-error { border-left: 3px solid #dc2626; background: #fef7f7; }
+.message-todo { border-left: 3px solid #b45309; }
+.message-tool, .message-tool-result { border-left: 3px solid #6b7280; }
+.message-pending { border-style: dashed; }
+.message-collapsible.expanded { box-shadow: 0 2px 10px rgba(15,23,42,.06); }
+.message-body { margin-top: 8px; }
+.message-body > * + * { margin-top: 8px; }
+.message-body p { margin: 0; overflow-wrap: anywhere; color: #334155; font-size: 14px; line-height: 1.75; white-space: pre-wrap; }
+.message-body ul, .message-body ol { margin: 0; padding-left: 20px; }
+.message-body li + li { margin-top: 4px; }
+.message-body h1, .message-body h2, .message-body h3 { margin: 0; color: #1e293b; font-size: 15px; }
+.message-body blockquote { margin: 0; border-left: 3px solid #dbe3ec; padding-left: 10px; color: #64748b; }
+.message-body code { overflow-wrap: anywhere; border-radius: 3px; background: #eef2f5; padding: 1px 3px; color: #334155; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; }
+.message-body pre { max-width: 100%; margin: 0; overflow-x: auto; border: 1px solid #e0e7ee; border-radius: 6px; background: #fff; padding: 10px 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.message-body pre code { background: transparent; padding: 0; font-size: 12px; }
+.message-body table { display: block; max-width: 100%; margin: 0; overflow-x: auto; border-collapse: collapse; font-size: 12px; }
+.message-body th, .message-body td { border: 1px solid #d8e0e8; padding: 5px 8px; text-align: left; overflow-wrap: anywhere; white-space: normal; }
+.message-body th { background: #f5f7fa; font-weight: 720; }
+.message-branch { display: inline-flex; align-items: center; gap: 4px; min-height: 22px; margin-left: auto; border: 0; border-radius: 5px; background: transparent; padding: 0 6px; color: #8a97a8; font-size: 11px; }
+.message-branch:hover { background: #eef4ff; color: #3478f6; }
+.message-branch svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
+.message-fold { display: grid; width: 24px; min-height: 24px; place-items: center; margin-left: 0; border: 0; border-radius: 50%; background: transparent; padding: 0; color: #94a3b8; }
+.message-fold:hover { background: #edf3ff !important; color: #3478f6 !important; }
+.message-fold svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; transition: transform .15s ease; }
+.message-collapsible.expanded .message-fold svg { transform: rotate(90deg); }
+.message-summary { overflow: hidden; color: #64748b !important; text-overflow: ellipsis; white-space: nowrap; }
+.message-streaming { display: inline-flex; align-items: center; gap: 6px; color: #6b7280 !important; font-style: italic; }
+.streaming-dot { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; animation: streaming-pulse 1.1s ease-in-out infinite; }
+@keyframes streaming-pulse { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
+.process-records { margin-top: 10px; border-top: 1px dashed #e2e8f0; padding-top: 8px; }
+.process-records-fold { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: #f8fafc; padding: 7px 10px; color: #475569; font-size: 12px; font-weight: 620; text-align: left; }
+.process-records-fold:hover { background: #edf3ff; color: #3478f6; }
+.process-count { display: grid; min-width: 20px; height: 20px; place-items: center; border-radius: 10px; background: #e2e8f0; color: #64748b; font-size: 11px; font-weight: 720; }
+.process-entry { margin-top: 8px; border: 1px solid #e2e8f0; border-radius: 6px; background: #fff; }
+.process-entry-fold { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; border: 0; background: transparent; padding: 8px 10px; color: #334155; font-size: 12px; text-align: left; }
+.process-entry-fold:hover { background: #f8fafc; }
+.process-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.process-status { flex: none; font-size: 11px; }
+.process-status-done { color: #0f766e; }
+.process-status-pending { color: #b45309; }
+.process-status-error { color: #b91c1c; }
+.process-entry-body { display: grid; gap: 8px; padding: 0 10px 10px; }
+.process-args, .process-result, .process-error { margin: 0; padding: 8px 10px; border-radius: 5px; background: #f1f5f9; overflow: auto; color: #334155; font-size: 12px; line-height: 1.55; white-space: pre-wrap; }
+.process-error { background: #fef2f2; color: #991b1b; }
+.message-composer { flex: none; display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: end; padding: 14px 38px 20px; border-top: 1px solid #e7edf3; background: #fbfcfe; }
+.message-composer textarea { min-height: 44px; max-height: 160px; resize: vertical; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff; padding: 11px 13px; color: #334155; font-size: 13px; line-height: 1.6; outline-color: #3478f6; box-shadow: 0 1px 3px rgba(15,23,42,.05); }
+.message-composer button { min-height: 40px; border: 0; border-radius: 10px; background: #3478f6; padding: 0 18px; color: #fff; font-size: 13px; font-weight: 650; }
+.message-composer button:hover { background: #2563eb; }
+.message-composer button:disabled { background: #b9c7d8; cursor: not-allowed; }
+.compare-view { height: 100%; overflow: auto; padding: 43px 38px; }
+.compare-view header { width: min(1050px, 100%); margin: 0 auto 24px; }
+.compare-columns { display: grid; width: min(1050px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 0 auto; }
+.compare-columns article { overflow: hidden; border: 1px solid #dbe3eb; border-radius: 7px; background: #fff; }
+.compare-title { gap: 8px; padding: 16px; border-bottom: 1px solid #e8edf2; }
+.compare-title .topic-dot { background: var(--thread-color, #374151); }
+.compare-title h2 { min-width: 0; flex: 1; margin: 0; overflow: hidden; color: #1e293b; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
+.compare-title button { min-height: 27px; padding: 0 7px; font-size: 11px; }
+.compare-meta { margin: 12px 16px; color: #94a3b8; font-size: 11px; }
+.compare-notes { display: grid; gap: 1px; padding: 0 16px 16px; }
+.compare-notes p { margin: 0; padding: 11px 0; border-top: 1px solid #eef2f5; color: #475569; font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
+@media (max-width: 760px) { .synapse-shell { --sidebar-width: 200px; --sidebar-half: 100px; } .synapse-shell.sidebar-collapsed { --sidebar-width: 56px; --sidebar-half: 28px; } .sidebar { padding: 6px 10px; } .detail-head { display: grid; gap: 12px; padding: 22px 18px 14px; } .detail-scroll { padding: 16px 18px 20px; } .message-composer { padding: 12px 18px 16px; } .message-user { max-width: 92%; } .compare-view { padding: 26px 18px; } .compare-columns { grid-template-columns: 1fr; } .topbar { padding: 0 10px; } .brand strong { display: none; } .card-inspector { top: auto; width: 100%; min-width: 0; max-height: 78%; border-top: 1px solid #dbe3ec; border-left: 0; } .card-inspector.is-opening, .card-inspector.is-closing { transform: translateY(24px); } }
+@media (max-width: 560px) { .synapse-shell { min-width: 0; grid-template-columns: 1fr; } .sidebar { display: none; } .topbar, .main-stage { grid-column: 1; } .view-switch { left: 50%; } .view-switch button { padding: 0 7px; } .top-actions > button { font-size: 11px; padding: 0 7px; } .thread-card { width: 280px; } }
+@media (prefers-reduced-motion: reduce) { .card-inspector { transition: opacity 120ms ease; } .card-inspector.is-opening, .card-inspector.is-closing { transform: none; } }
+
+/* ── Dark theme: follows DSH's body[data-ds-dark-theme] via synapse:theme ── */
+[data-theme="dark"] { color-scheme: dark; }
+[data-theme="dark"] body { background: #0f1115; color: #e6e8eb; }
+[data-theme="dark"] .synapse-shell { background: #0f1115; }
+[data-theme="dark"] .sidebar { border-right-color: #26282d; background: #15171b; }
+[data-theme="dark"] .topbar { background: #15171b; }
+[data-theme="dark"] .main-stage { background: #0f1115; }
+[data-theme="dark"] .canvas-tabs { border-bottom-color: #26282d; background: #15171b; }
+[data-theme="dark"] .canvas-tabs button { color: #8f959e; }
+[data-theme="dark"] .canvas-tabs button.active, [data-theme="dark"] .canvas-tabs button:hover { color: #5b8def; border-bottom-color: #5b8def; }
+[data-theme="dark"] .canvas-viewport { background: #0f1115; }
+[data-theme="dark"] .canvas-content { background: #0f1115; }
+[data-theme="dark"] .connectors path { stroke: #3a3f47; }
+[data-theme="dark"] .connectors path.active-connector { stroke: #5b8def; }
+[data-theme="dark"] .connectors .draft-connector { stroke: #5b8def; }
+[data-theme="dark"] .thread-card { border-color: #2a2d33; background: #1b1c1f; box-shadow: 0 3px 11px rgba(0,0,0,.35); }
+[data-theme="dark"] .thread-card:hover { border-color: #4a4f57; box-shadow: 0 8px 20px rgba(0,0,0,.45); }
+[data-theme="dark"] .thread-card.selected { box-shadow: 0 0 0 2px rgba(91,141,239,.4), 0 8px 20px rgba(0,0,0,.45); }
+[data-theme="dark"] .node-handle { border-color: #2a2d33; background: #1b1c1f; }
+[data-theme="dark"] .node-handle::after { color: #737a83; }
+[data-theme="dark"] .thread-title { color: #e6e8eb; }
+[data-theme="dark"] .thread-title:hover { color: #fff; }
+[data-theme="dark"] .graph-continue-button, [data-theme="dark"] .graph-fold-button, [data-theme="dark"] .graph-branch-button { border-color: #4a4f57; background: #1b1c1f; color: #e6e8eb; }
+[data-theme="dark"] .graph-continue-button:hover, [data-theme="dark"] .graph-fold-button:hover, [data-theme="dark"] .graph-branch-button:hover { border-color: #9ca3af; background: #26282d; }
+[data-theme="dark"] .graph-continue-button:focus-visible, [data-theme="dark"] .graph-fold-button:focus-visible, [data-theme="dark"] .graph-branch-button:focus-visible { outline-color: #7ea6f5; }
+[data-theme="dark"] .selection-followup { border-color: #5b8def; background: #5b8def; color: #fff; box-shadow: 0 4px 12px rgba(0,0,0,.35); }
+[data-theme="dark"] .selection-followup:hover { border-color: #7ea6f5; background: #7ea6f5; color: #fff; }
+[data-theme="dark"] .thread-meta { color: #737a83; }
+[data-theme="dark"] .thread-meta .card-process-count { background: #1e2a44; color: #7ea6f5; }
+[data-theme="dark"] .thread-answer { color: #a7adb5; }
+[data-theme="dark"] .thread-answer h1, [data-theme="dark"] .thread-answer h2, [data-theme="dark"] .thread-answer h3 { color: #d7dae0; }
+[data-theme="dark"] .thread-answer code { background: #26282d; color: #d7dae0; }
+[data-theme="dark"] .thread-answer pre { border-color: #2a2d33; background: #15171b; }
+[data-theme="dark"] .thread-answer th, [data-theme="dark"] .thread-answer td { border-color: #2a2d33; }
+[data-theme="dark"] .thread-answer th { background: #222428; }
+[data-theme="dark"] .thread-answer-empty { color: #737a83; }
+[data-theme="dark"] .thread-answer-pending, [data-theme="dark"] .message-pending p { color: #a7adb5; }
+[data-theme="dark"] .thread-card footer { border-top-color: #23262b; }
+[data-theme="dark"] .thread-card footer button { color: #a7adb5; }
+[data-theme="dark"] .thread-card footer button:hover { background: #26282d; color: #e6e8eb; }
+[data-theme="dark"] .thread-card footer .compare-button { color: #d7dae0; }
+[data-theme="dark"] .thread-card footer .compare-button.selected { background: #2a2d33; color: #fff; }
+[data-theme="dark"] .card-inspector { border-left-color: #2a2d33; background: #15171b; }
+[data-theme="dark"] .card-inspector-head, [data-theme="dark"] .card-inspector-actions { border-color: #2a2d33; background: #1b1c1f; }
+[data-theme="dark"] .card-inspector-meta { color: #737a83; }
+[data-theme="dark"] .card-inspector-meta span + span::before { color: #4a4f57; }
+[data-theme="dark"] .card-inspector-meta span:last-child { color: inherit; }
+[data-theme="dark"] .thread-meta .card-error-status, [data-theme="dark"] .card-inspector-meta .card-inspector-error-status { color: #f87171; }
+[data-theme="dark"] .card-inspector-head h2 { color: #e6e8eb; }
+[data-theme="dark"] .card-inspector-answer { color: #d7dae0; }
+[data-theme="dark"] .card-inspector-answer h1, [data-theme="dark"] .card-inspector-answer h2, [data-theme="dark"] .card-inspector-answer h3 { color: #e6e8eb; }
+[data-theme="dark"] .card-inspector-answer blockquote { border-left-color: #3a3f47; color: #8f959e; }
+[data-theme="dark"] .card-inspector-answer code { background: #26282d; color: #d7dae0; }
+[data-theme="dark"] .card-inspector-answer pre { border-color: #2a2d33; background: #15171b; }
+[data-theme="dark"] .card-inspector-answer th, [data-theme="dark"] .card-inspector-answer td { border-color: #2a2d33; }
+[data-theme="dark"] .card-inspector-answer th { background: #222428; }
+[data-theme="dark"] .card-inspector-pending { color: #8f959e; }
+[data-theme="dark"] .thread-answer-error { color: #f87171; }
+[data-theme="dark"] .card-inspector-error { border-left-color: #f87171; color: #f0a3a3; }
+[data-theme="dark"] .card-inspector-error strong { color: #f87171; }
+[data-theme="dark"] .card-inspector .process-records-fold { color: #a7adb5; }
+[data-theme="dark"] .card-inspector .process-records-fold:hover { color: #7ea6f5; }
+[data-theme="dark"] .card-inspector-close { color: #8f959e; }
+[data-theme="dark"] .card-inspector-close:hover { background: #26282d; color: #e6e8eb; }
+[data-theme="dark"] .card-inspector-actions button { border-color: #3a3f47; background: #222428; color: #c6cbd2; }
+[data-theme="dark"] .card-inspector-actions button:hover { border-color: #4a4f57; background: #26282d; color: #fff; }
+[data-theme="dark"] .card-inspector-actions .primary { border-color: #5b8def; background: #5b8def; color: #fff; }
+[data-theme="dark"] .card-inspector-actions .primary:hover { border-color: #7ea6f5; background: #7ea6f5; }
+[data-theme="dark"] .draft-card { border-color: #4a4f57; box-shadow: 0 7px 19px rgba(0,0,0,.4); }
+[data-theme="dark"] .draft-card strong { color: #e6e8eb; }
+[data-theme="dark"] .draft-quick-phrase, [data-theme="dark"] .draft-quick-phrase-add-button { border-color: #3a3f47; background: #222428; color: #a7adb5; }
+[data-theme="dark"] .draft-quick-phrase:hover, [data-theme="dark"] .draft-quick-phrase-add-button:hover { border-color: #5b8def; background: #1e2a44; color: #7ea6f5; }
+[data-theme="dark"] .draft-quick-phrase-editor-row input, [data-theme="dark"] .draft-quick-phrase-add input { border-color: #2a2d33; background: #15171b; color: #e6e8eb; }
+[data-theme="dark"] .draft-quick-phrase-editor-row button { border-color: #3a3f47; background: #222428; color: #a7adb5; }
+[data-theme="dark"] .draft-quick-phrase-editor-row button:hover { border-color: #b75f5f; background: #2b1a1c; color: #f0a3a3; }
+[data-theme="dark"] .draft-quick-editor-close { color: #a7adb5; }
+[data-theme="dark"] .draft-quick-editor-close:hover { background: #26282d; color: #e6e8eb; }
+[data-theme="dark"] .draft-branch-form textarea { border-color: #2a2d33; background: #15171b; color: #e6e8eb; }
+[data-theme="dark"] .draft-actions button { background: #1b1c1f; color: #a7adb5; }
+[data-theme="dark"] .draft-actions button.primary { background: #3478f6; color: #fff; }
+[data-theme="dark"] .view-switch { border-color: #2a2d33; background: rgba(21,23,27,.96); }
+[data-theme="dark"] .view-switch button { color: #8f959e; }
+[data-theme="dark"] .view-switch button:hover { background: #26282d; color: #e6e8eb; }
+[data-theme="dark"] .view-switch button.active { background: #e6e8eb; color: #111317; }
+[data-theme="dark"] .canvas-controls span { color: #8f959e; }
+[data-theme="dark"] .sidebar-toggle { color: #a7adb5; }
+[data-theme="dark"] .sidebar-toggle:hover { background: #26282d; color: #e6e8eb; }
+[data-theme="dark"] .new-workspace { border-color: rgba(255,255,255,.12); background: #1b1c1f; color: #e6e8eb; }
+[data-theme="dark"] .new-workspace:hover { border-color: rgba(255,255,255,.22); background: #222428; color: #fff; }
+[data-theme="dark"] .workspace-label, [data-theme="dark"] .sidebar-heading { color: #8f959e; }
+[data-theme="dark"] .workspace-label select { border-color: #2a2d33; background: #15171b; color: #e6e8eb; }
+[data-theme="dark"] .workspace-select svg { stroke: #8f959e; }
+[data-theme="dark"] .tree-row { color: #a7adb5; }
+[data-theme="dark"] .tree-row:hover { background: #26282d; }
+[data-theme="dark"] .tree-row.active { background: #2a2d33; color: #e6e8eb; }
+[data-theme="dark"] .tree-row i { color: #737a83; }
+[data-theme="dark"] .tree-empty { color: #737a83; }
+[data-theme="dark"] .brand::after { background: #e6e8eb; color: #111317; }
+[data-theme="dark"] .empty-canvas strong { color: #d7dae0; }
+[data-theme="dark"] .empty-canvas p { color: #8f959e; }
+[data-theme="dark"] .note-empty { border-color: #2a2d33; color: #8f959e; }
+[data-theme="dark"] .status-message { border-color: #7a2e2e; background: #2b1a1c; color: #f0a3a3; box-shadow: 0 3px 10px rgba(0,0,0,.4); }
+[data-theme="dark"] .status-message button { color: #f0a3a3; }
+[data-theme="dark"] .status-message button:hover { background: #3a2326; }
+[data-theme="dark"] .detail-view { background: #0f1115; }
+[data-theme="dark"] .detail-head { border-bottom-color: #26282d; }
+[data-theme="dark"] .detail-badge { background: #1e2a44; color: #7ea6f5; }
+[data-theme="dark"] .detail-subtitle { color: #737a83; }
+[data-theme="dark"] .detail-head h1 { color: #e6e8eb; }
+[data-theme="dark"] .detail-head-actions button { border-color: #2a2d33; background: #15171b; color: #c6cbd2; }
+[data-theme="dark"] .detail-head-actions button:hover { border-color: #4a4f57; background: #222428; }
+[data-theme="dark"] .detail-head-actions button.primary { border-color: #3478f6; background: #3478f6; color: #fff; }
+[data-theme="dark"] .detail-head-actions button.primary:hover { background: #2563eb; }
+[data-theme="dark"] .message { border-color: #2a2d33; background: #1b1c1f; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
+[data-theme="dark"] .message header { color: #a7adb5; }
+[data-theme="dark"] .message time { color: #737a83; }
+[data-theme="dark"] .message-role { color: #d7dae0; }
+[data-theme="dark"] .message-user { border-color: transparent; background: #1e2a45; box-shadow: none; }
+[data-theme="dark"] .message-user header { color: #8fa9d9; }
+[data-theme="dark"] .message-user .message-body p { color: #c9d4ea; }
+[data-theme="dark"] .message-error { border-left-color: #dc2626; background: #2b1a1c; }
+[data-theme="dark"] .message-collapsible.expanded { box-shadow: 0 2px 10px rgba(0,0,0,.35); }
+[data-theme="dark"] .message-body p { color: #d7dae0; }
+[data-theme="dark"] .message-body h1, [data-theme="dark"] .message-body h2, [data-theme="dark"] .message-body h3 { color: #e6e8eb; }
+[data-theme="dark"] .message-body blockquote { border-left-color: #3a3f47; color: #8f959e; }
+[data-theme="dark"] .message-body code { background: #26282d; color: #d7dae0; }
+[data-theme="dark"] .message-body pre { border-color: #2a2d33; background: #15171b; }
+[data-theme="dark"] .message-body pre code { background: transparent; }
+[data-theme="dark"] .message-body th, [data-theme="dark"] .message-body td { border-color: #2a2d33; }
+[data-theme="dark"] .message-body th { background: #222428; }
+[data-theme="dark"] .message-branch { color: #8f959e; }
+[data-theme="dark"] .message-branch:hover { background: #1e2a44; color: #7ea6f5; }
+[data-theme="dark"] .message-fold { color: #737a83; }
+[data-theme="dark"] .message-fold:hover { background: #1e2a44 !important; color: #7ea6f5 !important; }
+[data-theme="dark"] .message-summary { color: #8f959e !important; }
+[data-theme="dark"] .message-streaming { color: #8f959e !important; }
+[data-theme="dark"] .streaming-dot { background: #737a83; }
+[data-theme="dark"] .process-records { border-top-color: #2a2d33; }
+[data-theme="dark"] .process-records-fold { background: #222428; color: #a7adb5; }
+[data-theme="dark"] .process-records-fold:hover { background: #1e2a44; color: #7ea6f5; }
+[data-theme="dark"] .process-count { background: #2a2d33; color: #8f959e; }
+[data-theme="dark"] .process-entry { border-color: #2a2d33; background: #15171b; }
+[data-theme="dark"] .process-entry-fold { color: #d7dae0; }
+[data-theme="dark"] .process-entry-fold:hover { background: #222428; }
+[data-theme="dark"] .process-status-done { color: #34d399; }
+[data-theme="dark"] .process-status-pending { color: #fbbf24; }
+[data-theme="dark"] .process-status-error { color: #f87171; }
+[data-theme="dark"] .process-args, [data-theme="dark"] .process-result { background: #222428; color: #d7dae0; }
+[data-theme="dark"] .process-error { background: #2b1a1c; color: #f0a3a3; }
+[data-theme="dark"] .message-composer { border-top-color: #26282d; background: #0f1115; }
+[data-theme="dark"] .message-composer textarea { border-color: #2a2d33; background: #15171b; color: #e6e8eb; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
+[data-theme="dark"] .message-composer button:disabled { background: #2a2d33; color: #737a83; }
+[data-theme="dark"] button { border-color: #2a2d33; background: #1b1c1f; color: #d7dae0; }
+[data-theme="dark"] button:hover { border-color: #4a4f57; background: #26282d; color: #fff; }
+[data-theme="dark"] button.primary { border-color: #3478f6; background: #3478f6; color: #fff; }
+[data-theme="dark"] button.primary:hover { border-color: #2563eb; background: #2563eb; color: #fff; }
+@media (max-width: 760px) { [data-theme="dark"] .card-inspector { border-top-color: #2a2d33; border-left: 0; } }
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/test/canvas-runtime.test.js b/apps/deepviewer-adapter/workflows/vendor/synapse/test/canvas-runtime.test.js
new file mode 100644
index 0000000..0c39ee9
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/test/canvas-runtime.test.js
@@ -0,0 +1,328 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+
+test('uses one camera transform without browser scroll coordinates', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+
+ assert.match(source, /canvasCamera: \{ x: 0, y: 0 \}/)
+ assert.match(source, /translate\(\$\{state\.canvasCamera\.x\}px, \$\{state\.canvasCamera\.y\}px\) scale\(\$\{state\.zoom\}\)/)
+ assert.doesNotMatch(source, /canvasScroll|canvasPadding|canvasDomShift|canvasMetrics|viewport\.scrollLeft|viewport\.scrollTop/)
+})
+
+test('reuses the live map iframe and retries initialization only after iframe load', async () => {
+ const source = await readFile(new URL('../client.js', import.meta.url), 'utf8')
+ const openFlow = source.slice(source.indexOf('let mapOpenFallback'), source.indexOf('const onMessage'))
+ const open = openFlow.slice(openFlow.indexOf('const open ='), openFlow.indexOf('const onFrameLoad'))
+
+ assert.doesNotMatch(openFlow, /frame\.src\s*=/)
+ assert.match(openFlow, /const onFrameLoad/)
+ assert.match(openFlow, /if \(mapOpening\) send\('synapse:map-opened'\)/)
+ assert.ok(open.indexOf('overlay.hidden = false') < open.indexOf("send('synapse:map-opened')"))
+ assert.match(open, /overlay\.classList\.add\('is-opening'\)/)
+})
+
+test('keeps the canvas viewport across dialog/map toggles and recenters on real session switches', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const mapOpened = source.slice(source.indexOf("if (data.type === 'synapse:map-opened')"), source.indexOf("if (data.type === 'synapse:workspaces')"))
+ const currentSession = source.slice(source.indexOf("if (data.type === 'synapse:current-session')"), source.indexOf("if (data.type === 'synapse:live-reply'"))
+
+ // Reopening the map for the same session must NOT reset the camera: only a
+ // real session switch (current-session id change) re-centers the canvas.
+ assert.doesNotMatch(mapOpened, /resetCanvasCamera\(\)/)
+ assert.match(mapOpened, /state\.mode = 'canvas'\s+render\(\)/)
+ assert.match(currentSession, /previousId !== data\.session\?\.id/)
+ assert.match(currentSession, /focusActiveCard\(\)/)
+})
+
+test('lets the card answer scroll with the native wheel instead of adding deltaY', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const wheel = source.slice(source.indexOf("app.addEventListener('wheel'"), source.indexOf("app.addEventListener('click'"))
+
+ assert.match(wheel, /native wheel/)
+ assert.doesNotMatch(wheel, /scrollTop\s*\+=/)
+})
+
+test('preserves each card answer scroll across canvas re-renders', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const render = source.slice(source.indexOf('function render() {'), source.indexOf('function renderPreservingDetailScroll'))
+
+ assert.match(render, /cardScrollTops/)
+ assert.match(render, /card\.dataset\.cardId/)
+ assert.match(render, /\.thread-card\[data-card-id=/)
+ assert.match(render, /\.thread-answer`\)\s*if \(answer instanceof HTMLElement\) answer\.scrollTop = scrollTop/)
+})
+
+test('activating a session from the map syncs DSH without closing the map', async () => {
+ const source = await readFile(new URL('../client.js', import.meta.url), 'utf8')
+ const activate = source.slice(source.indexOf("'synapse:activate-session'"), source.indexOf("'synapse:fork-session'"))
+
+ assert.match(activate, /ctx\.sessions\.open\(event\.data\.sessionId\)/)
+ assert.doesNotMatch(activate, /close\(\)/)
+})
+
+test('selecting a session in the sidebar syncs the DSH current session', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const selectThread = source.slice(source.indexOf("button.dataset.action === 'select-thread'"), source.indexOf("button.dataset.action === 'show-thread'"))
+
+ assert.match(selectThread, /synapse:activate-session/)
+})
+
+test('clicking a session card syncs the DSH current session', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const cardClick = source.slice(source.indexOf('if (!(button instanceof HTMLElement)) {'), source.indexOf("if (button.dataset.action === 'close')"))
+
+ assert.match(cardClick, /post\('synapse:activate-session', \{ sessionId: thread\.dshSessionId \}\)/)
+})
+
+test('switching sessions from a map card keeps the current camera position', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const cardClick = source.slice(source.indexOf('if (!(button instanceof HTMLElement)) {'), source.indexOf("if (button.dataset.action === 'close')"))
+ const currentSession = source.slice(source.indexOf("if (data.type === 'synapse:current-session')"), source.indexOf("if (data.type === 'synapse:live-reply'"))
+
+ assert.match(cardClick, /mapCardSessionSwitches\.add\(thread\.dshSessionId\)/)
+ assert.match(currentSession, /mapCardSessionSwitches\.delete\(data\.session\?\.id\)/)
+ assert.match(currentSession, /openCurrentWorkspace\(\{ preserveCanvasCamera \}\)/)
+ assert.match(currentSession, /if \(!preserveCanvasCamera\) focusActiveCard\(\)/)
+})
+
+test('keeps conversation highlighting separate from the exact selected card', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8')
+ const connectors = source.slice(source.indexOf('function canvasConnectors'), source.indexOf('function conversationCard(card, graph)'))
+ const card = source.slice(source.indexOf('function conversationCard(card, graph)'), source.indexOf('function draftActions'))
+ const cardClick = source.slice(source.indexOf('if (!(button instanceof HTMLElement)) {'), source.indexOf("if (button.dataset.action === 'close')"))
+ const selectThread = source.slice(source.indexOf("button.dataset.action === 'select-thread'"), source.indexOf("button.dataset.action === 'show-thread'"))
+
+ assert.match(source, /selectedCardId: null/)
+ assert.match(card, /card\.id === state\.selectedCardId/)
+ assert.doesNotMatch(card, /dshThreadId === state\.activeId/)
+ assert.match(connectors, /card\.dshThreadId === state\.activeId && parent\.dshThreadId === state\.activeId/)
+ assert.match(connectors, /active-connector/)
+ assert.match(cardClick, /state\.selectedCardId = cardId/)
+ assert.match(selectThread, /state\.selectedCardId = null/)
+ assert.match(styles, /\.connectors path\.active-connector \{ stroke: #3478f6; \}/)
+ assert.match(styles, /\[data-theme="dark"\] \.connectors path\.active-connector \{ stroke: #5b8def; \}/)
+ assert.doesNotMatch(styles, /\.thread-card\.active/)
+})
+
+test('opens the clicked card in a tool-aware detail inspector', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8')
+ const cardClick = source.slice(source.indexOf('if (!(button instanceof HTMLElement)) {'), source.indexOf("if (button.dataset.action === 'close')"))
+ const inspector = source.slice(source.indexOf('function messagesForCard'), source.indexOf('function renderThread'))
+
+ assert.match(source, /inspectorCardId: null/)
+ assert.match(source, /function openCardInspector/)
+ assert.match(cardClick, /openCardInspector\(cardId\)/)
+ assert.match(inspector, /function messagesForCard/)
+ assert.match(inspector, /function inspectorProcessEntries/)
+ assert.match(inspector, /processRecords\(process/)
+ assert.doesNotMatch(inspector, /threadMessage\(thread, message\)/)
+ assert.match(inspector, /class="card-inspector/)
+ assert.match(inspector, /data-action="open-continue"/)
+ assert.doesNotMatch(inspector, /完整对话/)
+ assert.match(inspector, //)
+ assert.match(inspector, /card\.canContinue === true/)
+ assert.match(inspector, /card-inspector-error/)
+ assert.match(source, /button\.dataset\.action === 'close-card-inspector'/)
+ assert.match(source, /event\.key !== 'Escape'/)
+ assert.match(source, /processCount/)
+ assert.match(styles, /\.card-inspector \{ position: absolute/)
+ assert.match(styles, /\.card-inspector\.is-opening, \.card-inspector\.is-closing/)
+ assert.match(styles, /\.card-inspector-answer/)
+ assert.doesNotMatch(styles, /\.card-inspector \{[^}]*box-shadow/)
+ assert.match(styles, /\.card-inspector-actions button svg/)
+ assert.doesNotMatch(styles, /\.card-inspector-head \{[^}]*border-bottom/)
+ assert.match(styles, /\.card-inspector \{ top: auto; width: 100%/)
+ assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/)
+ assert.match(styles, /\.thread-meta \.card-process-count/)
+})
+
+test('switching the workspace in the map syncs DSH to its first session', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const select = source.slice(source.indexOf("app.addEventListener('change'"), source.indexOf("app.addEventListener('input'"))
+
+ assert.match(select, /choice\.sessionIds\[0\]/)
+ assert.match(select, /post\('synapse:activate-session'/)
+})
+
+test('renders markdown tables and allows higher canvas zoom', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const markdown = source.slice(source.indexOf('function markdownBlock'), source.indexOf('function overlapsCard'))
+
+ assert.match(markdown, //)
+ assert.match(markdown, /isTableDelimiter/)
+ assert.match(source, /Math\.min\(4,/)
+})
+
+test('renders the refactored detail view with role-based messages', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const thread = source.slice(source.indexOf('function renderThread'), source.indexOf('function render()'))
+ const message = source.slice(source.indexOf('function threadMessage'), source.indexOf('function processRecords'))
+
+ assert.match(thread, /detail-scroll/)
+ assert.match(thread, /detail-head/)
+ assert.match(message, /message-avatar/)
+ assert.match(message, /message-body/)
+})
+
+test('persists dragged card positions and can focus the current session', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+
+ assert.match(source, /localStorage\.setItem\(CARD_POSITIONS_KEY/)
+ assert.match(source, /function focusActiveCard\(\)/)
+ assert.match(source, /data-action="focus-active"/)
+})
+
+test('switching workspaces syncs DSH to the most recently updated session', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const select = source.slice(source.indexOf("app.addEventListener('change'"), source.indexOf("app.addEventListener('input'"))
+
+ assert.match(select, /updatedAt/)
+ assert.match(select, /post\('synapse:activate-session'/)
+})
+
+test('mirrors DSH theme changes into the map', async () => {
+ const clientSource = await readFile(new URL('../client.js', import.meta.url), 'utf8')
+ const appSource = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+
+ assert.match(clientSource, /data-ds-dark-theme/)
+ assert.match(clientSource, /synapse:theme/)
+ assert.match(appSource, /data\.type === 'synapse:theme'/)
+ assert.match(appSource, /document\.documentElement\.dataset\.theme/)
+})
+
+test('leaves text selections inside cards intact', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const cardClick = source.slice(source.indexOf('if (!(button instanceof HTMLElement)) {'), source.indexOf("if (button.dataset.action === 'close')"))
+
+ assert.match(cardClick, /event\.detail > 1/)
+ assert.match(cardClick, /Math\.hypot/)
+ assert.match(source, /pointerDownPosition = \{ x: event\.clientX/)
+})
+
+test('opens a prefilled follow-up draft from selected answer text', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const selection = source.slice(source.indexOf('function selectionFollowupTarget'), source.indexOf("app.addEventListener('pointerdown', event => {"))
+ const click = source.slice(source.indexOf("if (button.dataset.action === 'follow-selection')"), source.indexOf("if (button.dataset.action === 'close')"))
+
+ assert.match(source, /class="selection-followup"/)
+ assert.match(selection, /\.thread-answer/)
+ assert.match(selection, /\.message-assistant \.message-body/)
+ assert.match(selection, /text === '' \|\| text\.length > 4000/)
+ assert.match(selection, /text\.length > 4000/)
+ assert.match(click, /openContinue\(thread, undefined, followup\.text\)/)
+ assert.match(source, /state\.draft = \{ kind: 'continue', parentId: parent\.id, anchorId, text, sending: false \}/)
+})
+
+test('renders editable quick phrases in follow-up and branch drafts', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8')
+
+ assert.match(source, /DEFAULT_QUICK_PHRASES = \['展开说明', '举例', '通俗易懂', '对比解释'\]/)
+ assert.match(source, /QUICK_PHRASES_KEY/)
+ assert.match(source, /data-action="insert-quick-phrase"/)
+ assert.match(source, /data-action="open-quick-phrase-editor"/)
+ assert.match(source, /data-action="remove-quick-phrase"/)
+ assert.match(source, /function insertQuickPhrase/)
+ assert.match(source, /persistQuickPhrases\(\)/)
+ assert.match(styles, /\.draft-quick-phrases/)
+ assert.match(styles, /\[data-theme="dark"\] \.draft-quick-phrase/)
+})
+
+test('caches markdown rendering and patches the live card without a full render', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const live = source.slice(source.indexOf('function scheduleLiveCardUpdate'), source.indexOf('async function pollProjection'))
+
+ assert.match(source, /const markdownCache = new Map\(\)/)
+ assert.match(source, /MARKDOWN_CACHE_LIMIT/)
+ assert.match(source, /function scheduleLiveCardUpdate/)
+ assert.match(live, /function applyLiveReplyToCard/)
+ assert.match(live, /requestAnimationFrame/)
+})
+
+test('renders a follow-up plus on final cards and fold plus branch controls on non-final cards', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const card = source.slice(source.indexOf('function conversationCard'), source.indexOf('function draftActions'))
+
+ assert.match(card, /class="graph-continue-button"/)
+ assert.match(card, /data-action="open-continue"/)
+ assert.match(card, /aria-label="添加追问"/)
+ assert.match(card, /childCount === 0 \|\| card\.canContinue === true \? ''/)
+ assert.match(card, /class="graph-fold-button/)
+ assert.match(card, /data-action="toggle-card-children"/)
+ assert.match(card, /aria-expanded=/)
+ assert.match(card, /M3\.5 8h9/)
+ assert.match(card, /M8 3\.5v9/)
+ assert.match(card, /childCount === 0 \|\| card\.canContinue === true \|\| !Number\.isInteger\(card\.answer\?\.sourceSeq\)/)
+ assert.match(card, /class="graph-branch-button"/)
+ assert.match(card, /aria-label="在新对话中分支"/)
+ assert.match(card, /M13\.0762 1\.37207C14\.0846/)
+ assert.doesNotMatch(card, />追问<\/button>/)
+ assert.doesNotMatch(card, />分支<\/button>/)
+ assert.doesNotMatch(card, /class="branch-button"/)
+})
+
+test('positions the latest plus at the connector and the branch icon below the fold control', async () => {
+ const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8')
+
+ assert.match(styles, /\.graph-continue-button, \.graph-fold-button \{ top: 50%; transform: translateY\(-50%\); \}/)
+ assert.match(styles, /\.graph-branch-button \{ top: calc\(50% \+ 30px\); transform: translateY\(-50%\); \}/)
+ assert.match(styles, /\.graph-continue-button svg, \.graph-fold-button svg, \.graph-branch-button svg/)
+ assert.match(styles, /\[data-theme="dark"\] \.graph-continue-button, \[data-theme="dark"\] \.graph-fold-button, \[data-theme="dark"\] \.graph-branch-button/)
+})
+
+test('persists graph collapse choices and renders connectors from visible cards only', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const canvas = source.slice(source.indexOf('function renderCanvas'), source.indexOf('function isProcessMessage'))
+ const toggle = source.slice(source.indexOf("button.dataset.action === 'toggle-card-children'"), source.indexOf("button.dataset.action === 'open-continue'"))
+
+ assert.match(source, /COLLAPSED_CARDS_KEY/)
+ assert.match(source, /localStorage\.setItem\(COLLAPSED_CARDS_KEY/)
+ assert.match(canvas, /const graph = conversationGraphView\(allCards\)/)
+ assert.match(canvas, /const cards = graph\.cards/)
+ assert.match(canvas, /canvasConnectors\(cards\)/)
+ assert.match(toggle, /state\.collapsedCardIds\.(?:has|delete|add)/)
+ assert.match(toggle, /persistCollapsedCards\(\)/)
+})
+
+test('identifies when an anchored draft has no visible parent card', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const placement = source.slice(source.indexOf('function draftPlacement'), source.indexOf('function draftCard'))
+
+ assert.match(placement, /draft\.anchorId === undefined/)
+ assert.match(placement, /cards\.find\(card => card\.id === draft\.anchorId\)/)
+ assert.match(placement, /if \(parent === undefined\) return null/)
+})
+
+test('prevents collapse from hiding drafts or the active conversation and restores focus', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const toggle = source.slice(source.indexOf("button.dataset.action === 'toggle-card-children'"), source.indexOf("button.dataset.action === 'open-continue'"))
+
+ assert.match(toggle, /const visibleCards = conversationGraphView\(allCards, nextCollapsed\)\.cards/)
+ assert.match(toggle, /draftPlacement\(allCards\)\?\.parent\.id/)
+ assert.match(toggle, /请先完成或取消正在编辑的追问或分支/)
+ assert.match(toggle, /当前会话位于这个后续分支中/)
+ assert.match(toggle, /window\.setTimeout/)
+ assert.match(toggle, /\.focus\(\)/)
+})
+
+test('reveals hidden ancestor paths when a conversation becomes current', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const reveal = source.slice(source.indexOf('function revealConversationThread'), source.indexOf('function canvasConnectors'))
+ const current = source.slice(source.indexOf("data.type === 'synapse:current-session'"), source.indexOf("data.type === 'synapse:live-reply'"))
+
+ assert.match(reveal, /state\.collapsedCardIds\.delete\(parentId\)/)
+ assert.match(reveal, /persistCollapsedCards\(\)/)
+ assert.match(current, /revealConversationThread\(conversationCards\(state\.workspace\.threads\), thread\.id\)/)
+})
+
+test('prunes persisted collapsed state when a conversation is archived', async () => {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const archive = source.slice(source.indexOf('async function archiveThread'), source.indexOf('function openContinue'))
+
+ assert.match(archive, /state\.collapsedCardIds/)
+ assert.match(archive, /key\.startsWith\(`\$\{id\}:`\)/)
+ assert.match(archive, /persistCollapsedCards\(\)/)
+})
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/test/conversation-cards.test.js b/apps/deepviewer-adapter/workflows/vendor/synapse/test/conversation-cards.test.js
new file mode 100644
index 0000000..7663a4f
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/test/conversation-cards.test.js
@@ -0,0 +1,373 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+import vm from 'node:vm'
+
+async function loadConversationCards() {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const start = source.indexOf('function overlapsCard')
+ const end = source.indexOf('function canvasConnectors')
+ const context = { globalThis: {}, CARD_WIDTH: 310, CARD_HEIGHT: 276, CARD_GAP_Y: 42, CAMERA_INSET_X: 56, CAMERA_INSET_Y: 56, messagesFor: thread => thread.messages, state: { branchAnchors: new Map(), cardPositions: new Map(), liveReplies: new Map(), collapsedCardIds: new Set() } }
+ vm.createContext(context)
+ vm.runInContext(`${source.slice(start, end)};globalThis.conversationCards = conversationCards;globalThis.conversationGraphView = conversationGraphView;globalThis.initialCanvasCamera = initialCanvasCamera`, context)
+ return { conversationCards: context.globalThis.conversationCards, conversationGraphView: context.globalThis.conversationGraphView, initialCanvasCamera: context.globalThis.initialCanvasCamera, state: context.state }
+}
+
+test('projects each user question in one DSH session as a connected canvas card', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一个问题', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一个回答草稿', sourceSeq: 2 },
+ { kind: 'assistant', text: '第一个最终回答', sourceSeq: 3 },
+ { kind: 'user', text: '第二个问题', sourceSeq: 4 },
+ { kind: 'assistant', text: '第二个最终回答', sourceSeq: 5 },
+ ],
+ }])
+
+ assert.equal(cards.length, 2)
+ assert.equal(cards[0].question, '第一个问题')
+ assert.equal(cards[0].answer.text, '第一个最终回答')
+ assert.equal(cards[1].question, '第二个问题')
+ assert.equal(cards[1].parentId, cards[0].id)
+ assert.equal(cards[1].position.x, cards[0].position.x + 365)
+ assert.equal(cards[0].canContinue, undefined)
+ assert.equal(cards[1].canContinue, true)
+})
+
+test('keeps a failed turn visible when Harness produces no assistant message', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const [card] = conversationCards([{
+ id: 'session-error', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '调用搜索', sourceSeq: 1 },
+ { kind: 'error', text: 'QuotaExceeded: INSUFFICIENT_BALANCE: 余额不足', sourceSeq: 2 },
+ ],
+ }])
+
+ assert.equal(card.answer, null)
+ assert.equal(card.error.text, 'QuotaExceeded: INSUFFICIENT_BALANCE: 余额不足')
+ assert.equal(card.canContinue, true)
+})
+
+test('connects a restored fork to its DSH seed boundary, not its canvas position', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ {
+ id: 'parent', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一轮回答', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 5 },
+ { kind: 'assistant', text: '第二轮回答', sourceSeq: 6 },
+ { kind: 'user', text: '第三轮', sourceSeq: 9 },
+ { kind: 'assistant', text: '第三轮回答', sourceSeq: 10 },
+ ],
+ },
+ {
+ id: 'child', parentId: 'parent', sourceSeedLength: 8, position: { x: 9999, y: -9999 },
+ messages: [
+ { kind: 'user', text: '分支问题', sourceSeq: 9 },
+ { kind: 'assistant', text: '分支回答', sourceSeq: 10 },
+ ],
+ },
+ ])
+
+ const parentTurns = cards.filter(card => card.dshThreadId === 'parent')
+ const childTurn = cards.find(card => card.dshThreadId === 'child')
+ assert.equal(childTurn.parentId, parentTurns[1].id)
+})
+
+test('uses a restored child message sequence to reconnect a legacy fork at its user turn', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ {
+ id: 'parent', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '你好', sourceSeq: 7 },
+ { kind: 'assistant', text: '你好,我是助手。', sourceSeq: 111 },
+ { kind: 'user', text: '你是谁', sourceSeq: 118 },
+ { kind: 'assistant', text: '我是 DSH。', sourceSeq: 278 },
+ ],
+ },
+ {
+ id: 'child', parentId: 'parent', sourceSeedLength: null, position: { x: 1200, y: 900 },
+ messages: [
+ { kind: 'user', text: '代码是什么', sourceSeq: 121 },
+ { kind: 'assistant', text: '代码是指令。', sourceSeq: 569 },
+ ],
+ },
+ ])
+
+ const parentTurns = cards.filter(card => card.dshThreadId === 'parent')
+ const childTurn = cards.find(card => card.dshThreadId === 'child')
+ assert.equal(childTurn.parentId, parentTurns[1].id)
+})
+
+test('places a fork beside the exact parent turn while avoiding overlap', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ {
+ id: 'parent', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一轮回答', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 5 },
+ { kind: 'assistant', text: '第二轮回答', sourceSeq: 6 },
+ { kind: 'user', text: '第三轮', sourceSeq: 9 },
+ { kind: 'assistant', text: '第三轮回答', sourceSeq: 10 },
+ ],
+ },
+ {
+ id: 'child', parentId: 'parent', sourceSeedLength: 8, position: { x: 86, y: 900 },
+ messages: [
+ { kind: 'user', text: '第二轮分支', sourceSeq: 9 },
+ { kind: 'assistant', text: '分支回答', sourceSeq: 10 },
+ ],
+ },
+ ])
+
+ const parentTurns = cards.filter(card => card.dshThreadId === 'parent')
+ const childTurn = cards.find(card => card.dshThreadId === 'child')
+ assert.equal(childTurn.parentId, parentTurns[1].id)
+ assert.equal(childTurn.position.x, parentTurns[1].position.x + 365)
+ assert.ok(childTurn.position.y > parentTurns[1].position.y)
+})
+
+test('keeps every turn of one branch on the same horizontal lane', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ {
+ id: 'parent', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一轮回答', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 5 },
+ { kind: 'assistant', text: '第二轮回答', sourceSeq: 6 },
+ ],
+ },
+ {
+ id: 'child', parentId: 'parent', sourceSeedLength: 7, position: { x: 999, y: 999 },
+ messages: [
+ { kind: 'user', text: '分支第一轮', sourceSeq: 7 },
+ { kind: 'assistant', text: '分支第一轮回答', sourceSeq: 8 },
+ { kind: 'user', text: '分支第二轮', sourceSeq: 9 },
+ { kind: 'assistant', text: '分支第二轮回答', sourceSeq: 10 },
+ { kind: 'user', text: '分支第三轮', sourceSeq: 11 },
+ { kind: 'assistant', text: '分支第三轮回答', sourceSeq: 12 },
+ ],
+ },
+ ])
+
+ const childTurns = cards.filter(card => card.dshThreadId === 'child')
+ assert.equal(new Set(childTurns.map(card => card.position.y)).size, 1)
+ assert.equal(childTurns[1].position.x, childTurns[0].position.x + 365)
+ assert.equal(childTurns[2].position.x, childTurns[1].position.x + 365)
+})
+
+test('moves automatically placed cards below an occupied card instead of overlapping it', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ { id: 'first', parentId: null, position: { x: 86, y: 82 }, messages: [{ kind: 'user', text: '第一条', sourceSeq: 1 }] },
+ { id: 'second', parentId: null, position: { x: 86, y: 220 }, messages: [{ kind: 'user', text: '第二条', sourceSeq: 1 }] },
+ ])
+
+ assert.equal(cards[0].position.y, 82)
+ assert.ok(cards[1].position.y >= 400)
+})
+
+test('honors an in-memory dragged card position even far from the natural layout', async () => {
+ const { conversationCards, state } = await loadConversationCards()
+ state.cardPositions.set('session-1:turn:1', { x: 1280, y: 1280 })
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一个问题', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一个回答', sourceSeq: 2 },
+ ],
+ }])
+
+ assert.equal(cards[0].position.x, 1280)
+ assert.equal(cards[0].position.y, 1280)
+})
+
+test('does not move later turns when an earlier card is dragged', async () => {
+ const { conversationCards, state } = await loadConversationCards()
+ state.cardPositions.set('session-1:turn:1', { x: 1280, y: 1280 })
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一个问题', sourceSeq: 1 },
+ { kind: 'assistant', text: '第一个回答', sourceSeq: 2 },
+ { kind: 'user', text: '第二个问题', sourceSeq: 3 },
+ { kind: 'assistant', text: '第二个回答', sourceSeq: 4 },
+ ],
+ }])
+
+ assert.equal(cards[0].position.x, 1280)
+ assert.equal(cards[1].position.x, 451)
+ assert.equal(cards[1].position.y, 82)
+})
+
+test('keeps a dragged pending turn position after DSH assigns a source sequence', async () => {
+ const { conversationCards, state } = await loadConversationCards()
+ state.cardPositions.set('session-1:turn-index:0', { x: 740, y: 360 })
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null, position: { x: 86, y: 82 },
+ messages: [
+ { kind: 'user', text: '第一个问题', sourceSeq: 21 },
+ { kind: 'assistant', text: '第一个回答', sourceSeq: 22 },
+ ],
+ }])
+
+ assert.equal(cards[0].position.x, 740)
+ assert.equal(cards[0].position.y, 360)
+})
+
+test('derives root card positions from the visible graph instead of stale persisted thread pixels', async () => {
+ const { conversationCards } = await loadConversationCards()
+ const cards = conversationCards([
+ { id: 'dirty-root', parentId: null, position: { x: 86, y: 3200 }, messages: [{ kind: 'user', text: '脏坐标会话', sourceSeq: 1 }] },
+ ])
+
+ assert.equal(cards[0].position.x, 86)
+ assert.equal(cards[0].position.y, 82)
+})
+
+test('focuses a new-session draft before existing cards when initializing the canvas', async () => {
+ const { initialCanvasCamera, state } = await loadConversationCards()
+ state.draft = { kind: 'new', text: '', sending: false }
+ state.zoom = 1
+ const camera = initialCanvasCamera([{ id: 'old-card', position: { x: 86, y: 1600 } }])
+
+ assert.equal(camera.x, -30)
+ assert.equal(camera.y, -26)
+})
+
+test('collapsing a conversation card hides its complete linear descendant chain', async () => {
+ const { conversationCards, conversationGraphView } = await loadConversationCards()
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null,
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '回答一', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 3 },
+ { kind: 'assistant', text: '回答二', sourceSeq: 4 },
+ { kind: 'user', text: '第三轮', sourceSeq: 5 },
+ { kind: 'assistant', text: '回答三', sourceSeq: 6 },
+ ],
+ }])
+
+ const graph = conversationGraphView(cards, new Set([cards[0].id]))
+ assert.deepEqual(Array.from(graph.cards, card => card.id), [cards[0].id])
+ assert.equal(graph.childCounts.get(cards[0].id), 1)
+ assert.equal(graph.descendantCounts.get(cards[0].id), 2)
+})
+
+test('collapsing a fork point hides all branch descendants without hiding another root', async () => {
+ const { conversationCards, conversationGraphView } = await loadConversationCards()
+ const cards = conversationCards([
+ {
+ id: 'parent', parentId: null,
+ messages: [
+ { kind: 'user', text: '父问题', sourceSeq: 1 },
+ { kind: 'assistant', text: '父回答', sourceSeq: 2 },
+ { kind: 'user', text: '父追问', sourceSeq: 5 },
+ { kind: 'assistant', text: '父追问回答', sourceSeq: 6 },
+ ],
+ },
+ {
+ id: 'child', parentId: 'parent', sourceSeedLength: 4,
+ messages: [
+ { kind: 'user', text: '分支问题', sourceSeq: 4 },
+ { kind: 'assistant', text: '分支回答', sourceSeq: 5 },
+ { kind: 'user', text: '分支追问', sourceSeq: 6 },
+ ],
+ },
+ { id: 'other-root', parentId: null, messages: [{ kind: 'user', text: '独立会话', sourceSeq: 1 }] },
+ ])
+ const forkPoint = cards.find(card => card.dshThreadId === 'parent' && card.turnIndex === 0)
+ const graph = conversationGraphView(cards, new Set([forkPoint.id]))
+
+ assert.deepEqual(Array.from(graph.cards, card => card.question).sort(), ['父问题', '独立会话'].sort())
+ assert.equal(graph.childCounts.get(forkPoint.id), 2)
+ assert.equal(graph.descendantCounts.get(forkPoint.id), 3)
+})
+
+test('expanding a card restores descendants at their original coordinates', async () => {
+ const { conversationCards, conversationGraphView } = await loadConversationCards()
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null,
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '回答一', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 3 },
+ ],
+ }])
+ const originalPositions = cards.map(card => ({ ...card.position }))
+
+ assert.equal(conversationGraphView(cards, new Set([cards[0].id])).cards.length, 1)
+ const expanded = conversationGraphView(cards, new Set()).cards
+ assert.equal(expanded.length, 2)
+ assert.deepEqual(expanded.map(card => ({ ...card.position })), originalPositions)
+})
+
+test('a new descendant remains hidden while its ancestor is collapsed', async () => {
+ const { conversationCards, conversationGraphView } = await loadConversationCards()
+ const initialCards = conversationCards([{
+ id: 'session-1', parentId: null,
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '回答一', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 3 },
+ ],
+ }])
+ const collapsedId = initialCards[0].id
+ const updatedCards = conversationCards([{
+ id: 'session-1', parentId: null,
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '回答一', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 3 },
+ { kind: 'assistant', text: '回答二', sourceSeq: 4 },
+ { kind: 'user', text: '后来新增的第三轮', sourceSeq: 5 },
+ ],
+ }])
+
+ const graph = conversationGraphView(updatedCards, new Set([collapsedId]))
+ assert.deepEqual(Array.from(graph.cards, card => card.question), ['第一轮'])
+ assert.equal(graph.descendantCounts.get(collapsedId), 2)
+})
+
+test('nested collapsed nodes remain visible when their ancestor is expanded', async () => {
+ const { conversationCards, conversationGraphView } = await loadConversationCards()
+ const cards = conversationCards([{
+ id: 'session-1', parentId: null,
+ messages: [
+ { kind: 'user', text: '第一轮', sourceSeq: 1 },
+ { kind: 'assistant', text: '回答一', sourceSeq: 2 },
+ { kind: 'user', text: '第二轮', sourceSeq: 3 },
+ { kind: 'assistant', text: '回答二', sourceSeq: 4 },
+ { kind: 'user', text: '第三轮', sourceSeq: 5 },
+ ],
+ }])
+
+ const nested = conversationGraphView(cards, new Set([cards[0].id, cards[1].id]))
+ assert.deepEqual(Array.from(nested.cards, card => card.question), ['第一轮', '第二轮'])
+ const childOnly = conversationGraphView(cards, new Set([cards[1].id]))
+ assert.deepEqual(Array.from(childOnly.cards, card => card.question), ['第一轮', '第二轮'])
+})
+
+test('cyclic collapsed roots stay visible and count unique descendants', async () => {
+ const { conversationGraphView } = await loadConversationCards()
+ const cards = [
+ { id: 'a', parentId: 'b', dshThreadId: 'a' },
+ { id: 'b', parentId: 'a', dshThreadId: 'b' },
+ ]
+ const graph = conversationGraphView(cards, new Set(['a', 'b']))
+
+ assert.deepEqual(Array.from(graph.cards, card => card.id), ['a', 'b'])
+ assert.equal(graph.descendantCounts.get('a'), 1)
+ assert.equal(graph.descendantCounts.get('b'), 1)
+})
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/test/markdown-renderer.test.js b/apps/deepviewer-adapter/workflows/vendor/synapse/test/markdown-renderer.test.js
new file mode 100644
index 0000000..13e935d
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/test/markdown-renderer.test.js
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import vm from 'node:vm'
+import test from 'node:test'
+
+async function loadRenderer() {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const start = source.indexOf('const escapeHtml')
+ const end = source.indexOf('function canvasConnectors')
+ const context = { globalThis: {} }
+ vm.createContext(context)
+ vm.runInContext(`${source.slice(start, end)};globalThis.renderMarkdown = renderMarkdown`, context)
+ return context.globalThis.renderMarkdown
+}
+
+test('renders PowerShell marker-only diagnostic lines without stalling', async () => {
+ const renderMarkdown = await loadRenderer()
+ const input = 'cmd : Access is denied.\nAt line:1 char:1\n+ \n+ ~~~~~\n + CategoryInfo : PermissionDenied'
+ const result = renderMarkdown(input)
+
+ assert.match(result, /cmd : Access is denied/)
+ assert.match(result, /CategoryInfo/)
+})
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/test/message-projection.test.js b/apps/deepviewer-adapter/workflows/vendor/synapse/test/message-projection.test.js
new file mode 100644
index 0000000..2c7174d
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/test/message-projection.test.js
@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+import vm from 'node:vm'
+
+async function loadMessagesFromEvents() {
+ const source = await readFile(new URL('../app.js', import.meta.url), 'utf8')
+ const start = source.indexOf('function messagesFromEvents')
+ const end = source.indexOf('async function loadThreadHistory')
+ const context = { globalThis: {} }
+ vm.createContext(context)
+ vm.runInContext(`${source.slice(start, end)};globalThis.messagesFromEvents = messagesFromEvents`, context)
+ return context.globalThis.messagesFromEvents
+}
+
+test('does not turn DSH runtime context into a question card', async () => {
+ const messagesFromEvents = await loadMessagesFromEvents()
+ const messages = messagesFromEvents([
+ { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\nPolicy details.' }] } },
+ { type: 'user/message', seq: 2, time: 2, data: { content: [{ type: 'text', text: '你是谁' }] } },
+ ])
+
+ assert.deepEqual(messages.map(message => message.text), ['你是谁'])
+})
diff --git a/apps/deepviewer-adapter/workflows/vendor/synapse/test/workspace-store.test.js b/apps/deepviewer-adapter/workflows/vendor/synapse/test/workspace-store.test.js
new file mode 100644
index 0000000..f021fb0
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/vendor/synapse/test/workspace-store.test.js
@@ -0,0 +1,299 @@
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import test from 'node:test'
+import { WorkspaceStore } from '../index.js'
+
+test('persists a workspace, a DSH-linked thread, and a message', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-'))
+ const dataFile = join(directory, 'state.json')
+ const store = new WorkspaceStore(dataFile)
+ const workspace = await store.create('调研 DSH 插件')
+ const thread = await store.createThread(workspace.id, { title: 'DSH 会话', dshSessionId: 'session-1' })
+ await store.addMessage(thread.id, '确定使用已有 Web Server')
+
+ const saved = await new WorkspaceStore(dataFile).get(workspace.id)
+ assert.equal(saved.title, '调研 DSH 插件')
+ assert.equal(saved.threads[0].dshSessionId, 'session-1')
+ assert.equal(saved.threads[0].messages[0].text, '确定使用已有 Web Server')
+ assert.match(await readFile(dataFile, 'utf8'), /"version": ?4/)
+})
+
+test('projects committed DSH events once, folds tool process into the assistant card, and keeps fork lineage', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-projection-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const parent = {
+ id: 'session-parent', header: {}, firstLiveSeq: 0,
+ events: [
+ { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: '分析登录异常' }] } },
+ { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: '我来检查。' }] } } },
+ { type: 'tool/call', seq: 2, time: 3, data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"pnpm test"}' } },
+ { type: 'tool/result', seq: 3, time: 4, data: { turn: 1, step: 1, message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'text', text: 'ok' }] } } },
+ ],
+ }
+ await store.projectSession(parent)
+ await store.projectEvent(parent, parent.events[2])
+ const child = { id: 'session-child', header: { parentSession: 'session-parent' }, firstLiveSeq: 4, events: [] }
+ await store.projectSession(child, child.firstLiveSeq)
+
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ const parentThread = graph.threads.find(thread => thread.dshSessionId === 'session-parent')
+ const childThread = graph.threads.find(thread => thread.dshSessionId === 'session-child')
+ assert.equal(workspace.title, 'DSH 任务')
+ assert.equal(parentThread.messages.length, 2)
+ assert.equal(parentThread.messages[0].kind, 'user')
+ assert.equal(parentThread.messages[1].kind, 'assistant')
+ assert.equal(parentThread.messages[1].process.length, 1)
+ assert.equal(parentThread.messages[1].process[0].name, 'bash')
+ assert.equal(parentThread.messages[1].process[0].result, 'ok')
+ assert.equal(parentThread.messages[1].process[0].error, null)
+ assert.equal(childThread.parentId, parentThread.id)
+})
+
+test('projects a batch of session events in a single write', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-batch-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const session = {
+ id: 'session-batch', header: { meta: { cwd: 'C:\\work\\batch' } }, firstLiveSeq: 0,
+ events: [
+ { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: '批量问题' }] } },
+ { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: '批量回答' }] } } },
+ { type: 'tool/call', seq: 2, time: 3, data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' } },
+ { type: 'tool/result', seq: 3, time: 4, data: { turn: 1, step: 1, message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'text', text: 'ok' }] } } },
+ ],
+ }
+ await store.projectEvents(session, session.events)
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ const thread = graph.threads[0]
+ assert.equal(thread.messages.length, 2)
+ assert.equal(thread.messages[0].text, '批量问题')
+ assert.equal(thread.messages[1].process.length, 1)
+ assert.equal(thread.messages[1].process[0].result, 'ok')
+ assert.match(await readFile(join(directory, 'state.json'), 'utf8'), /"version": ?4/)
+})
+
+test('retains a tool failure and exposes a failed turn without assistant text', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-error-projection-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.projectSession({
+ id: 'session-error', header: { meta: { cwd: 'C:\\work\\errors' } }, firstLiveSeq: 0,
+ events: [
+ { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: '搜索竞品' }] } },
+ { type: 'tool/call', seq: 2, time: 2, data: { turn: 7, step: 1, callId: 'search-1', name: 'web_search', arguments: '{"query":"竞品"}' } },
+ { type: 'tool/result', seq: 3, time: 3, data: { turn: 7, step: 1, error: { name: 'QuotaExceeded', code: 'INSUFFICIENT_BALANCE', message: '余额不足' }, message: { source: { kind: 'tool', callId: 'search-1' }, content: [] } } },
+ { type: 'turn/end', seq: 4, time: 4, data: { turn: 7, step: 1, reason: { kind: 'error', error: { name: 'QuotaExceeded', code: 'INSUFFICIENT_BALANCE', message: '余额不足' } } } },
+ ],
+ })
+
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ const [user, failure] = graph.threads[0].messages
+ assert.equal(user.kind, 'user')
+ assert.equal(failure.kind, 'error')
+ assert.equal(failure.text, 'QuotaExceeded: INSUFFICIENT_BALANCE: 余额不足')
+ assert.equal(failure.process.length, 1)
+ assert.equal(failure.process[0].error, 'QuotaExceeded: INSUFFICIENT_BALANCE: 余额不足')
+ assert.equal(graph.threads[0].pendingProcess.length, 0)
+})
+
+test('migrates v3 tool cards into the assistant process records', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-migrate-'))
+ const dataFile = join(directory, 'state.json')
+ await writeFile(dataFile, JSON.stringify({
+ version: 3,
+ hiddenSessionIds: [],
+ workspaces: [{
+ id: 'w-1', kind: 'dsh', cwd: 'C:\\work\\migrate', title: 'migrate',
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+ threads: [{
+ id: 't-1', title: '会话', parentId: null, dshSessionId: 's-1', dshSessionTitle: null,
+ color: '#0f766e', position: { x: 86, y: 82 }, sourceSeedLength: null,
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+ messages: [
+ { id: 'm-1', kind: 'user', text: '帮我检查', at: '2026-01-01T00:00:00.000Z' },
+ { id: 'm-2', kind: 'assistant', text: '好的。', at: '2026-01-01T00:00:00.100Z' },
+ { id: 'm-3', kind: 'tool', text: 'read\n{"file_path":"a.js"}', at: '2026-01-01T00:00:00.200Z' },
+ { id: 'm-4', kind: 'tool-result', text: 'file content', at: '2026-01-01T00:00:00.300Z' },
+ { id: 'm-5', kind: 'tool', text: 'bash\n{"cmd":"pwd"}', at: '2026-01-01T00:00:00.400Z' },
+ ],
+ }],
+ }],
+ }, null, 2))
+ const store = new WorkspaceStore(dataFile)
+ const graph = await store.get('w-1')
+ const thread = graph.threads[0]
+ assert.equal(thread.messages.length, 2)
+ assert.equal(thread.messages[1].process.length, 2)
+ assert.equal(thread.messages[1].process[0].name, 'read')
+ assert.equal(thread.messages[1].process[0].arguments, '{"file_path":"a.js"}')
+ assert.equal(thread.messages[1].process[0].result, 'file content')
+ assert.equal(thread.messages[1].process[1].name, 'bash')
+ assert.equal(thread.messages[1].process[1].result, null)
+ assert.match(await readFile(dataFile, 'utf8'), /"version": ?4/)
+})
+
+test('does not persist the DSH runtime context as a user conversation turn', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-runtime-context-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.projectSession({
+ id: 'runtime-context', header: { meta: { cwd: 'C:\\work\\canvas' } }, firstLiveSeq: 0,
+ events: [
+ { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: '你好' }] } },
+ { type: 'user/message', seq: 2, time: 2, data: { content: [{ type: 'text', text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\\nPolicy.' }] } },
+ { type: 'assistant/message', seq: 3, time: 3, data: { message: { content: [{ type: 'text', text: '你好,我是助手。' }] } } },
+ ],
+ })
+
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ assert.deepEqual(graph.threads[0].messages.map(message => message.text), ['你好', '你好,我是助手。'])
+})
+
+test('merges a browser fork callback with an already projected DSH fork', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-fork-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const parent = { id: 'parent', header: {}, firstLiveSeq: 0, events: [] }
+ const child = { id: 'child', header: { parentSession: 'parent' }, firstLiveSeq: 0, events: [] }
+ const parentThread = await store.projectSession(parent)
+ await store.projectSession(child, child.firstLiveSeq)
+ const merged = await store.branch(parentThread.id, { title: '替代方案', dshSessionId: 'child', dshSessionTitle: '替代方案' })
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ assert.equal(graph.threads.length, 2)
+ assert.equal(merged.dshSessionId, 'child')
+ assert.equal(merged.parentId, parentThread.id)
+})
+
+test('groups DSH sessions by their working directory', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-cwd-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.projectSession({ id: 'alpha', header: { meta: { cwd: 'C:\\work\\alpha' } }, firstLiveSeq: 0, events: [] })
+ await store.projectSession({ id: 'beta', header: { meta: { cwd: 'C:\\work\\beta' } }, firstLiveSeq: 0, events: [] })
+ const workspaces = await store.list()
+ assert.equal(workspaces.length, 2)
+ assert.deepEqual(new Set(workspaces.map(workspace => workspace.cwd)), new Set(['C:\\work\\alpha', 'C:\\work\\beta']))
+})
+
+test('syncs non-blank DSH sessions into the matching canvas', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-sync-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.syncSessions([
+ { id: 'blank', title: '空会话', cwd: 'C:\\work\\canvas', blank: true },
+ { id: 'parent', title: '主问题', cwd: 'C:\\work\\canvas', blank: false },
+ { id: 'child', title: '替代路线', cwd: 'C:\\work\\canvas', parentId: 'parent', blank: false },
+ ])
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ const parent = graph.threads.find(thread => thread.dshSessionId === 'parent')
+ const child = graph.threads.find(thread => thread.dshSessionId === 'child')
+ assert.equal(graph.threads.length, 2)
+ assert.equal(parent.title, '主问题')
+ assert.equal(child.parentId, parent.id)
+})
+
+test('keeps DSH projection coordinates neutral instead of stacking by historical session count', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-neutral-position-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.syncSessions([
+ { id: 'first', title: '第一条', cwd: 'C:\\work\\canvas', blank: false },
+ { id: 'second', title: '第二条', cwd: 'C:\\work\\canvas', blank: false },
+ ])
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+
+ assert.deepEqual(graph.threads.map(thread => thread.position), [{ x: 86, y: 82 }, { x: 86, y: 82 }])
+})
+
+test('removes the canvas node when DSH removes the session', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-removed-session-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ await store.syncSessions([{ id: 'removed', title: '将被归档', cwd: 'C:\\work\\canvas', blank: false }])
+ await store.syncSessions([], ['removed'])
+ assert.equal((await store.list()).length, 0)
+})
+
+test('removing a DSH node prevents replay from restoring it', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-remove-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const session = { id: 'remove-me', header: { meta: { cwd: 'C:\\work\\remove' } }, firstLiveSeq: 0, events: [] }
+ const thread = await store.projectSession(session)
+ await store.removeThread(thread.id)
+ await store.projectSession(session)
+ assert.equal((await store.list()).length, 0)
+})
+
+test('archived canvas nodes stay hidden during a later DSH session sync', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-archive-sync-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const session = { id: 'archived', title: '已归档', cwd: 'C:\\work\\archive', blank: false }
+ await store.syncSessions([session])
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ await store.removeThread(graph.threads[0].id)
+ await store.syncSessions([session])
+ assert.equal((await store.list()).length, 0)
+})
+
+test('does not rewrite an up-to-date v4 file on load', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-idempotent-'))
+ const dataFile = join(directory, 'state.json')
+ const state = {
+ version: 4,
+ hiddenSessionIds: [],
+ workspaces: [{
+ id: 'w-1', kind: 'dsh', cwd: 'C:\\work\\x', title: 'x',
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+ threads: [{
+ id: 't-1', title: 's', parentId: null, dshSessionId: 's-1', dshSessionTitle: null,
+ color: '#0f766e', position: { x: 86, y: 82 }, sourceSeedLength: null,
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+ messages: [{ id: 'm-1', kind: 'assistant', text: 'hi', sourceSeq: 1, at: '2026-01-01T00:00:00.000Z', process: [] }],
+ }],
+ }],
+ }
+ await writeFile(dataFile, JSON.stringify(state))
+ const before = (await stat(dataFile)).mtimeMs
+ await new Promise(resolve => setTimeout(resolve, 80))
+ await new WorkspaceStore(dataFile).ready
+ const after = (await stat(dataFile)).mtimeMs
+ assert.equal(after, before)
+})
+
+test('coalesces deferred projection saves into one write', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-debounce-'))
+ const dataFile = join(directory, 'state.json')
+ const store = new WorkspaceStore(dataFile)
+ const session = { id: 's1', header: { meta: { cwd: 'C:\\work\\x' } }, firstLiveSeq: 0 }
+ // Two deferred projections land inside one debounce window: no disk write yet.
+ await store.projectEvents(session, [{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'a' }] } }])
+ await store.projectEvents(session, [{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'b' }] } } }])
+ const before = (await stat(dataFile)).mtimeMs
+ await new Promise(resolve => setTimeout(resolve, 60))
+ const mid = (await stat(dataFile)).mtimeMs
+ assert.equal(mid, before)
+ // A manual flush persists the coalesced state in one save.
+ await store.flush()
+ const after = (await stat(dataFile)).mtimeMs
+ assert.notEqual(after, before)
+ const parsed = JSON.parse(await readFile(dataFile, 'utf8'))
+ assert.equal(parsed.workspaces[0].threads[0].messages.length, 2)
+})
+
+test('truncates over-long projections with a detail-view marker', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-synapse-truncate-'))
+ const store = new WorkspaceStore(join(directory, 'state.json'))
+ const session = { id: 's1', header: { meta: { cwd: 'C:\\work\\x' } }, firstLiveSeq: 0 }
+ await store.projectEvents(session, [
+ { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: '问' }] } },
+ { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'y'.repeat(9_000) }] } } },
+ ])
+ await store.flush()
+ const [workspace] = await store.list()
+ const graph = await store.get(workspace.id)
+ const assistant = graph.threads[0].messages.find(message => message.kind === 'assistant')
+ assert.equal(assistant.text.length, 8_000 + '\n——…(详情查看全文)'.length)
+ assert.ok(assistant.text.endsWith('——…(详情查看全文)'))
+})
diff --git a/apps/deepviewer-adapter/workflows/workspace-delivery.ts b/apps/deepviewer-adapter/workflows/workspace-delivery.ts
new file mode 100644
index 0000000..b9ccfe0
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/workspace-delivery.ts
@@ -0,0 +1,162 @@
+/** A user delivery is a workspace file, never an attachment-store object. */
+import { constants } from 'node:fs'
+import { createHash, randomUUID } from 'node:crypto'
+import { open, mkdir, lstat, realpath, link, unlink } from 'node:fs/promises'
+import type { FileHandle } from 'node:fs/promises'
+import { basename, extname, isAbsolute, join, relative } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+
+function within(root: string, path: string): boolean {
+ const part = relative(root, path)
+ return part !== '..' && !part.startsWith('../') && !isAbsolute(part)
+}
+
+/** Detect formats from bytes, not an opaque storage ID or a misleading suffix. */
+export function deliveryExtension(header: Buffer, name: string): string {
+ if (header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png'
+ if (header[0] === 255 && header[1] === 216 && header[2] === 255) return '.jpg'
+ if (/^GIF8[79]a/.test(header.toString('ascii', 0, 6))) return '.gif'
+ if (header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WEBP') return '.webp'
+ if (header.toString('ascii', 0, 5) === '%PDF-') return '.pdf'
+ if (header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WAVE') return '.wav'
+ const suffix = extname(name).toLowerCase()
+ if (header.toString('ascii', 4, 8) === 'ftyp') return ['.mp4', '.mov', '.m4a', '.avif', '.heic'].includes(suffix) ? suffix : '.mp4'
+ if (header.toString('ascii', 0, 3) === 'ID3') return '.mp3'
+ if (header[0] === 80 && header[1] === 75 && header[2] === 3 && header[3] === 4) {
+ return ['.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp', '.epub', '.zip'].includes(suffix) ? suffix : '.zip'
+ }
+ return /^\.[a-z0-9]{1,10}$/.test(suffix) ? suffix : ''
+}
+
+async function digest(handle: FileHandle, signal: AbortSignal): Promise {
+ const hash = createHash('sha256')
+ const buffer = Buffer.alloc(64 * 1024)
+ let position = 0
+ for (;;) {
+ signal.throwIfAborted()
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position)
+ if (!bytesRead) break
+ hash.update(buffer.subarray(0, bytesRead))
+ position += bytesRead
+ }
+ return hash.digest('hex')
+}
+
+function outputName(source: string, extension: string, hash: string): string {
+ let stem = basename(source, extname(source))
+ if (/^[a-f\d]{32,}$/i.test(stem)) stem = ['.png', '.jpg', '.gif', '.webp'].includes(extension) ? 'image' : 'file'
+ stem = stem.normalize('NFKC').replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, '-').replace(/^\.+/, '').trim().slice(0, 72) || 'file'
+ return `${stem}-${hash.slice(0, 12)}${extension || '.bin'}`
+}
+
+/** Bounded-memory copy, no replacement of any user-owned file. */
+async function publish(source: FileHandle, directory: string, name: string, signal: AbortSignal): Promise {
+ const temporary = join(directory, `.delivery-${randomUUID()}.tmp`)
+ const target = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
+ const hash = createHash('sha256')
+ let size = 0
+ try {
+ const buffer = Buffer.alloc(64 * 1024)
+ for (;;) {
+ signal.throwIfAborted()
+ const { bytesRead } = await source.read(buffer, 0, buffer.length, size)
+ if (!bytesRead) break
+ const bytes = buffer.subarray(0, bytesRead)
+ hash.update(bytes)
+ let offset = 0
+ while (offset < bytesRead) {
+ const written = await target.write(bytes, offset, bytesRead - offset, size + offset)
+ if (written.bytesWritten === 0) throw new Error('Unable to save output file')
+ offset += written.bytesWritten
+ }
+ size += bytesRead
+ }
+ await target.sync()
+ await target.close()
+ const sha = hash.digest('hex')
+ const header = Buffer.alloc(32)
+ const read = await source.read(header, 0, header.length, 0)
+ const filename = outputName(name, deliveryExtension(header.subarray(0, read.bytesRead), name), sha)
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ signal.throwIfAborted()
+ // The parent must still be the real workspace directory at publication time.
+ if ((await lstat(directory)).isSymbolicLink() || await realpath(directory) !== directory) throw new Error('Output directory changed')
+ const path = join(directory, attempt === 0 ? filename : `${basename(filename, extname(filename))}-${attempt}${extname(filename)}`)
+ try {
+ await link(temporary, path)
+ return path
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ let existing: FileHandle | undefined
+ try {
+ existing = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
+ const info = await existing.stat()
+ if (info.isFile() && info.size === size && await digest(existing, signal) === sha) return path
+ } catch { signal.throwIfAborted() } finally { await existing?.close() }
+ }
+ }
+ throw new Error('Unable to allocate a unique delivery filename')
+ } finally {
+ await target.close().catch(() => {})
+ await unlink(temporary).catch(() => {})
+ }
+}
+
+/** Resolve a previously generated cache/object path to its original-quality workspace delivery. */
+function recordedDelivery(session: Session, path: string): PresentedFile | undefined {
+ for (const event of session.snapshotEvents().toReversed()) {
+ if (event.type !== 'deliverables/presented') continue
+ const match = event.data.files.find(file => file.path === path || Array.isArray(file.sourcePaths) && file.sourcePaths.includes(path))
+ if (match) return match
+ }
+ return undefined
+}
+
+/** Local DeepViewer boundary shared by automatic and explicit delivery. Does not mutate original files. */
+export async function prepareWorkspaceDeliveries(ctx: Context, session: Session, files: readonly PresentedFile[], signal: AbortSignal): Promise {
+ const cwd = session.header.cwd
+ if (!cwd) throw new Error('File delivery requires a workspace. Select a workspace before generating files.')
+ const rootTarget = await ctx.fs.resolve(cwd, { signal })
+ const hostRoot = ctx.fs.processPath(rootTarget)
+ if (!hostRoot) throw new Error('Workspace does not expose a local delivery directory')
+ const root = await realpath(hostRoot)
+ const prepared = new Map()
+ for (const file of files) {
+ signal.throwIfAborted()
+ const initial = await ctx.fs.resolve(file.path, { cwd, signal })
+ const previous = recordedDelivery(session, initial.displayPath) ?? recordedDelivery(session, file.path)
+ const sourcePath = previous?.path ?? file.path
+ const target = await ctx.fs.resolve(sourcePath, { cwd, signal })
+ const entry = await ctx.fs.lstat(sourcePath, { cwd }, signal)
+ const info = await ctx.fs.stat(target, signal)
+ const host = ctx.fs.processPath(target)
+ if (entry?.type !== 'file' || info?.type !== 'file' || !host) throw new Error('Delivery source is missing or is not a regular local file')
+ const realSource = await realpath(host)
+ const handle = await open(realSource, constants.O_RDONLY | constants.O_NOFOLLOW)
+ try {
+ if (!(await handle.stat()).isFile()) throw new Error('Delivery source is not a file')
+ const header = Buffer.alloc(32)
+ const { bytesRead } = await handle.read(header, 0, header.length, 0)
+ const extension = deliveryExtension(header.subarray(0, bytesRead), realSource)
+ const currentExtension = extname(realSource).toLowerCase()
+ const formatCorrect = !extension || currentExtension === extension || extension === '.jpg' && currentExtension === '.jpeg'
+ let path = realSource
+ if (!within(root, realSource) || !formatCorrect) {
+ if (ctx.sandboxPolicy.resolve({ session }).mode === 'read-only') throw new Error('Workspace is read-only; generated file has not been delivered. Change the workspace permission before retrying present; do not regenerate the image.')
+ const directory = join(root, 'outputs')
+ await mkdir(directory, { mode: 0o700 }).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error })
+ if (!(await lstat(directory)).isDirectory() || await realpath(directory) !== directory) throw new Error('outputs must be a real directory inside the workspace')
+ path = await publish(handle, directory, basename(realSource), signal)
+ }
+ signal.throwIfAborted()
+ const mapped = ctx.fs.processPathFromHostPath(path)
+ if (!mapped) throw new Error('Delivered file has no execution-world path')
+ const sourcePaths = [...new Set([file.path, initial.displayPath, ...(file.sourcePaths ?? []), ...(previous?.sourcePaths ?? [])])].filter(source => source !== mapped)
+ prepared.set(mapped, { path: mapped, ...(file.description === undefined ? {} : { description: file.description }), ...(sourcePaths.length ? { sourcePaths } : {}) })
+ } finally { await handle.close() }
+ }
+ return [...prepared.values()]
+}
diff --git a/apps/deepviewer-adapter/workflows/workspace-rpc.ts b/apps/deepviewer-adapter/workflows/workspace-rpc.ts
new file mode 100644
index 0000000..4b9b222
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/workspace-rpc.ts
@@ -0,0 +1,19 @@
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import type {} from '@deepseek-ai/dsh-agent-preset-registry'
+import type {} from '@deepseek-ai/dsh-session-query'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { workspaceOperation } from './workspace.ts'
+export const WORKSPACE_CHANNEL = '/deepviewer-workspace'
+export function registerWorkspaceOperations(ctx: Context): void {
+ ctx.inject(['connection', 'webServer'], rpcCtx => rpcCtx.connection.rpc.handle(WORKSPACE_CHANNEL, async (method, input) => {
+ try {
+ if (!input || typeof input !== 'object') throw new Error('Invalid workspace request')
+ const payload = input as Record
+ if (typeof payload.sessionId !== 'string' || payload.sessionId.length > 256) throw new Error('Select a Session')
+ using observation = await ctx.sessionQuery.observeSession(payload.sessionId as SessionId)
+ if (observation.header.cwd === undefined || (observation.projections?.values.agentPreset ?? observation.header.agentPreset) === 'deepviewer-chat') throw new Error('Workspace operations are unavailable in Chat')
+ return { ok: true, value: await workspaceOperation(observation.header.cwd, method, payload) }
+ } catch (error) { return { ok: false, error: { code: 'deepviewer/workspace', message: error instanceof Error ? error.message : String(error), details: {} } } }
+ }, rpcCtx))
+}
diff --git a/apps/deepviewer-adapter/workflows/workspace.ts b/apps/deepviewer-adapter/workflows/workspace.ts
new file mode 100644
index 0000000..f230d94
--- /dev/null
+++ b/apps/deepviewer-adapter/workflows/workspace.ts
@@ -0,0 +1,82 @@
+import { constants } from 'node:fs'
+import { open, realpath, stat, rename, unlink } from 'node:fs/promises'
+import { createHash, randomUUID } from 'node:crypto'
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
+import * as git from './vendor/git.ts'
+const MAX_TEXT_BYTES = 2 * 1024 * 1024
+const locks = new Map>()
+export async function workspacePath(cwd: string, input: string): Promise {
+ const root = await realpath(cwd), target = await realpath(resolve(root, input)), child = relative(root, target)
+ if (child === '' || child === '..' || child.startsWith('../') || isAbsolute(child)) throw new Error('File must be inside this workspace')
+ return target
+}
+const versionOf = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
+export async function readEditor(cwd: string, input: string) {
+ const path = await workspacePath(cwd, input), handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
+ try {
+ const info = await handle.stat()
+ if (!info.isFile() || info.size > MAX_TEXT_BYTES) throw new Error('Editor supports text files up to 2 MiB')
+ const bytes = await handle.readFile()
+ if (bytes.length > MAX_TEXT_BYTES || bytes.includes(0)) throw new Error('Binary or oversized file cannot be edited')
+ return { path, text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), version: versionOf(bytes) }
+ } finally { await handle.close() }
+}
+export async function saveEditor(cwd: string, input: string, text: string, expected: string) {
+ const path = await workspacePath(cwd, input), previous = locks.get(path) ?? Promise.resolve()
+ const operation = previous.catch(() => undefined).then(async () => {
+ if (Buffer.byteLength(text, 'utf8') > MAX_TEXT_BYTES) throw new Error('Editor supports text files up to 2 MiB')
+ const current = await readEditor(cwd, path)
+ if (current.version !== expected) throw new Error('File changed since it was opened. Reload before saving.')
+ const info = await stat(path), temporary = join(dirname(path), `.deepviewer-edit-${randomUUID()}.tmp`)
+ const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, info.mode & 0o777)
+ try {
+ await handle.writeFile(text, 'utf8'); await handle.sync(); await handle.close()
+ const verified = await readEditor(cwd, path)
+ if (verified.version !== expected || await workspacePath(cwd, input) !== path) throw new Error('File changed during save. Reload before saving.')
+ await rename(temporary, path)
+ return { path, text, version: versionOf(Buffer.from(text)) }
+ } finally { await handle.close().catch(() => undefined); await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
+ })
+ locks.set(path, operation)
+ try { return await operation } finally { if (locks.get(path) === operation) locks.delete(path) }
+}
+function pathArgument(input: unknown): string {
+ if (typeof input !== 'string' || input === '' || isAbsolute(input) || input.split(/[\\/]/).includes('..') || input.includes('\0')) throw new Error('Select one workspace-relative path')
+ return input
+}
+/** Fixed operations and argument vectors; no arbitrary shell or implicit broad staging. */
+export async function workspaceOperation(cwd: string, method: string, payload: Record): Promise {
+ if (method === 'editor.read') return readEditor(cwd, String(payload.path ?? ''))
+ if (method === 'editor.save') {
+ if (typeof payload.text !== 'string' || typeof payload.version !== 'string') throw new Error('Invalid save request')
+ return saveEditor(cwd, String(payload.path ?? ''), payload.text, payload.version)
+ }
+ if (!['git.status', 'git.branches', 'git.log', 'git.diff', 'git.stage', 'git.unstage', 'git.commit', 'git.checkout'].includes(method)) throw new Error('Unsupported workspace operation')
+ // Repository discovery may only select a descendant of the Session's actual workspace.
+ const selected = typeof payload.repository === 'string' ? await realpath(payload.repository) : undefined
+ if (selected) { const child = relative(await realpath(cwd), selected); if (child === '..' || child.startsWith('../') || isAbsolute(child)) throw new Error('Repository is outside this workspace') }
+ const roots = await git.repoRoots(cwd), root = await realpath(cwd)
+ for (const repository of roots) { const child = relative(root, await realpath(repository)); if (child === '..' || child.startsWith('../') || isAbsolute(child)) throw new Error('Repository is outside this workspace') }
+ if (selected && !roots.includes(selected)) throw new Error('Select a discovered workspace repository')
+ if (method === 'git.stage' || method === 'git.unstage') {
+ const path = pathArgument(payload.path), status = await git.status(cwd, selected)
+ if (!status.entries.some(entry => entry.path === path)) throw new Error('Select one changed file, not a directory')
+ }
+ if (method === 'git.status') return git.status(cwd, selected)
+ if (method === 'git.branches') return git.branches(cwd, selected)
+ if (method === 'git.log') return git.log(cwd, 30, 0, selected)
+ if (method === 'git.diff') return git.diff(cwd, payload.path === undefined ? undefined : pathArgument(payload.path), payload.staged === true, selected)
+ if (method === 'git.stage') return git.stage(cwd, pathArgument(payload.path), selected)
+ if (method === 'git.unstage') return git.unstage(cwd, pathArgument(payload.path), selected)
+ if (method === 'git.commit') {
+ if (typeof payload.message !== 'string' || !payload.message.trim() || payload.message.length > 8192) throw new Error('Enter a commit message')
+ return git.commit(cwd, payload.message.trim(), selected)
+ }
+ if (method === 'git.checkout') {
+ if (typeof payload.branch !== 'string') throw new Error('Select a branch')
+ const branches = await git.branches(cwd, selected)
+ if (!branches.names.includes(payload.branch)) throw new Error('Unknown existing branch')
+ return git.checkout(cwd, payload.branch, selected)
+ }
+ throw new Error('Unsupported workspace operation')
+}
diff --git a/apps/deepviewer-desktop/assets/deepviewer-icon-dark-1024.png b/apps/deepviewer-desktop/assets/deepviewer-icon-dark-1024.png
new file mode 100644
index 0000000..13da831
Binary files /dev/null and b/apps/deepviewer-desktop/assets/deepviewer-icon-dark-1024.png differ
diff --git a/apps/deepviewer-desktop/package.json b/apps/deepviewer-desktop/package.json
index 5c6599a..f52e29a 100644
--- a/apps/deepviewer-desktop/package.json
+++ b/apps/deepviewer-desktop/package.json
@@ -1,45 +1,66 @@
{
"name": "@deepviewer/desktop",
"productName": "DeepViewer",
- "version": "0.3.3",
- "buildNumber": 75,
- "previewRelease": "0.3.3-preview.1",
+ "version": "0.5.0",
+ "buildNumber": 90,
+ "previewRelease": "0.5.0-preview.1",
"private": true,
- "description": "DeepViewer desktop shell for DeepSeek Harness",
+ "description": "DeepViewer agent workspace for DeepSeek Harness",
"license": "MIT",
"type": "module",
- "main": ".desktop/build/main.js",
+ "main": "lib/main.js",
"scripts": {
- "build": "node scripts/bump-build-number.mjs && vite build --config vite.main.config.ts && vite build --config vite.preload.config.ts && vite build --config vite.renderer.config.ts",
- "dev": "node scripts/sync-upstream-overrides.mjs --build && node scripts/dev.mjs",
+ "build": "node scripts/bump-build-number.mjs && node scripts/build-native-desktop.mjs",
+ "dev": "node scripts/dev.mjs",
"dev:restart": "node scripts/dev.mjs --restart",
- "preview": "node scripts/sync-upstream-overrides.mjs --build && pnpm build && node scripts/package.mjs --preview",
- "release": "node scripts/sync-upstream-overrides.mjs --build && pnpm build && pnpm run runtime && node scripts/package.mjs --sign && node scripts/notarize.mjs",
+ "preview": "node scripts/package-native.mjs --preview",
+ "release": "node scripts/package-native.mjs --release",
"start": "pnpm build && electron .",
- "runtime": "node scripts/build-runtime.mjs",
- "runtime:arm64": "node scripts/build-runtime.mjs --arch=arm64",
- "package": "pnpm build && pnpm run runtime && node scripts/package.mjs",
- "package:arm64": "pnpm build && pnpm run runtime:arm64 && node scripts/package.mjs --arch=arm64",
- "test": "vitest run",
+ "runtime": "node scripts/build-native-desktop.mjs --runtime",
+ "runtime:arm64": "node scripts/build-native-desktop.mjs --runtime",
+ "package": "node scripts/package-native.mjs",
+ "package:arm64": "node scripts/package-native.mjs --arch=arm64",
+ "test": "pnpm smoke",
"typecheck": "tsc --noEmit",
- "smoke": "node scripts/smoke-responses-scanner.mjs && node scripts/smoke-model-scanner.mjs && vitest run test/runtime-manager.test.ts test/app-lifecycle.test.ts",
- "smoke:data": "node --expose-internals scripts/smoke-user-data.mjs"
+ "smoke": "vitest run test/native-workspace.test.ts test/native-package-closure.test.ts test/native-migration.test.ts test/native-modules.test.ts test/user-data-migration.test.ts test/profile-migration.test.ts test/dock-theme.test.ts test/network-policy.test.ts test/network-access.test.ts test/network-bridge.test.ts test/subscriptions-v4.test.ts test/subscriptions-ui-adapter.test.ts test/development-workflow.test.ts test/development-inputs.test.ts test/native-release-audit.test.ts test/native-build-metadata.test.ts test/native-package-metadata.test.ts",
+ "smoke:data": "node --expose-internals scripts/smoke-user-data.mjs",
+ "build:dev": "node scripts/bump-build-number.mjs && node scripts/build-native-desktop.mjs",
+ "smoke:native": "node scripts/smoke-native-host.mjs && node scripts/smoke-native-renderer.mjs && node scripts/smoke-native-web-state.mjs && node scripts/smoke-native-updates.mjs",
+ "publish:update": "node scripts/publish-native-update.mjs --publish",
+ "smoke:plugins": "node scripts/smoke-model-capabilities-host.mjs && node ../dsh-plugin-model-capabilities/scripts/smoke-runtime.mjs",
+ "smoke:contracts": "node scripts/smoke-native-adapter-contracts.mjs",
+ "smoke:payload": "node scripts/prepare-native-runtime.mjs && node scripts/smoke-native-payload.mjs",
+ "smoke:source": "node scripts/smoke-native-contract-source.mjs",
+ "smoke:synapse": "node --test ../deepviewer-adapter/tests/synapse-host.test.mjs && node scripts/smoke-native-synapse-client.mjs && node scripts/smoke-native-synapse-seams.mjs && node scripts/smoke-native-synapse-canvas.mjs",
+ "smoke:ui-regression": "node scripts/smoke-native-ui-regression.mjs"
},
"devDependencies": {
"@electron/asar": "4.2.1",
"@electron/get": "5.1.0",
"@electron/packager": "20.3.0",
"@types/node": "24.10.1",
- "electron": "43.4.0",
+ "electron": "44.0.0",
"pnpm": "11.19.0",
"typescript": "6.0.3",
"vite": "8.2.1",
- "vitest": "4.1.10"
+ "vitest": "4.1.10",
+ "@codemirror/commands": "6.11.1",
+ "@codemirror/lang-css": "6.3.1",
+ "@codemirror/lang-html": "6.4.12",
+ "@codemirror/lang-javascript": "6.2.5",
+ "@codemirror/lang-json": "6.0.2",
+ "@codemirror/lang-markdown": "6.5.2",
+ "@codemirror/lang-python": "6.2.1",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/state": "6.7.5",
+ "@codemirror/view": "6.43.12",
+ "@codemirror/search": "6.7.2",
+ "@types/js-yaml": "4.0.9"
},
"dependencies": {
- "dsh-better-sidebar": "0.19.1",
"dsh-plugin-subscriptions": "0.3.1",
"ipaddr.js": "2.5.0",
- "socks": "2.8.7"
+ "socks": "2.8.7",
+ "js-yaml": "4.1.1"
}
}
diff --git a/apps/deepviewer-desktop/scripts/adapt-better-sidebar-ui.mjs b/apps/deepviewer-desktop/scripts/adapt-better-sidebar-ui.mjs
index c863a0f..2ccc376 100644
--- a/apps/deepviewer-desktop/scripts/adapt-better-sidebar-ui.mjs
+++ b/apps/deepviewer-desktop/scripts/adapt-better-sidebar-ui.mjs
@@ -1,4 +1,5 @@
-import { readFileSync, writeFileSync } from 'node:fs'
+import { removeBottomWorkbench, stripWorkbenchStyles } from './remove-bottom-workbench.mjs'
+import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { resolve } from 'node:path'
// Only the pinned published bundle is adapted. Fail on upstream drift.
@@ -6,12 +7,40 @@ function replaceOnce(source, before, after) {
if (source.split(before).length !== 2) throw new Error(`Sidebar desktop adapter anchor mismatch: ${before.slice(0, 90)}`)
return source.replace(before, after)
}
+// RC2 navigation belongs to uiWorkspace; catalog ownership is exposed through projections.
+export function deepviewerMainSession(snapshot) {
+ return Object.values(snapshot.byId).find(row => (row.retainedBy.mainView ?? 0) > 0)?.id
+}
+export function deepviewerSubagentCatalogs(snapshot) {
+ return Object.fromEntries(Object.entries(snapshot.projectionsBySession).map(([id, projection]) => [id, {
+ state: projection.state === 'idle' ? projection.values.subagentCatalog === undefined ? 'loading' : 'ready' : projection.state,
+ error: projection.error,
+ entries: (projection.values.subagentCatalog ?? []).map(entry => ({
+ ...entry, activity: snapshot.byId[entry.id]?.running === true ? 'running' : 'inactive',
+ })),
+ }]))
+}
export function adaptBetterSidebarUI(root) {
- const marker = '// deepviewer-sidebar-management-v1'
- for (const file of ['client.js', 'client-editor.js']) {
+ const marker = '// deepviewer-sidebar-management-dsh017-v1'
+ for (const file of ['client.js', 'client-registry.js', 'client-editor.js']) {
const path = resolve(root, 'lib', file)
+ if (file === 'client-registry.js' && !existsSync(path)) continue
let source = readFileSync(path, 'utf8')
if (source.includes(marker)) continue
+ source = source.replace(/\b(Icon[A-Za-z]+)(?:14|16)\b/gu, '$1Regular')
+ if (file !== 'client-editor.js') {
+ source = replaceOnce(source, 'return registerTurnTailInterception(ctx, sidebarStore);', 'return () => {}; // RC2 deliverables owns turn-tail cards and resource opening; do not duplicate removed produced-file chips.')
+ if (source.split('ctx.sessions.list.getSnapshot().current').length !== 3) throw new Error('Sidebar session selection anchors drifted')
+ source = source.replaceAll('ctx.sessions.list.getSnapshot().current', 'deepviewerMainSession(ctx.sessions.list.getSnapshot())')
+ source = replaceOnce(source, 'const current = sessionList.current;', 'const current = deepviewerMainSession(sessionList);')
+ source = replaceOnce(source, '() => list.subagentsByParent ?? {}, [list.subagentsByParent]', '() => deepviewerSubagentCatalogs(list), [list.projectionsBySession, list.byId]')
+ source = replaceOnce(source, 'sessions.setSubagentCatalogOpen?.(parentSessionId, open);', 'if (open) void sessions.refreshProjections(parentSessionId);')
+ source = source.replaceAll('sessions.setSubagentCatalogOpen?.(parentSessionId, false);', '{} // Projection reads own no Session reference to release.')
+ source = replaceOnce(source, 'sessions.openSubagent?.(address);', 'ctx.get("uiWorkspace").openSession(address);')
+ source = replaceOnce(source, 'sessions.open?.(rootId);', 'ctx.get("uiWorkspace").openSession(rootId);')
+ source = replaceOnce(source, 'sessions.refreshSubagents?.(parentSessionId);', 'void sessions.refreshProjections(parentSessionId);')
+ source = `${deepviewerMainSession.toString()}\n${deepviewerSubagentCatalogs.toString()}\n// deepviewer-rc2-session-helpers-end\n${source}`
+ }
source = source.replaceAll('侧边卡片', '侧栏管理').replace('管理侧栏管理的显示内容与默认行为', '管理侧栏内容与默认行为').replaceAll('settingsNav: "Side card"', 'settingsNav: "Sidebar Management"')
source = replaceOnce(source, 'function SandboxStatusBar(props) {', 'function SandboxStatusBar(props) {\nreturn null; // Desktop preview policy is fixed; no temporary unlock UI.')
if (file === 'client-editor.js') {
@@ -45,6 +74,10 @@ export function adaptBetterSidebarUI(root) {
source = replaceOnce(source, 'Enter a URL to start browsing (sandbox mode)', 'Enter a URL to start browsing')
source = replaceOnce(source, 'function BrowserView(props) {',
readFileSync(new URL('./sidebar-browser-client.js', import.meta.url), 'utf8') + '\nfunction LegacyBrowserView(props) {')
+ // RC2 defaults to one instance per kind and unmounts hidden bodies unless opted in.
+ source = replaceOnce(source, 'kind: descriptor.id,', `kind: descriptor.id,
+ multiple: descriptor.id === "browser",
+ keepMounted: ["browser", "editor", "terminal"].includes(descriptor.id),`)
source = replaceOnce(source, 'const options = {\n\t\t\t\t\t\tparams: entry.params,', `if (entry.tabKind === "browser") {
const address = 'dsh-resource://browser/' + crypto.randomUUID() + '?url=' + encodeURIComponent(entry.params?.url ?? '');
if (onScreen) api.openResource(address, { kind: "browser", revealIfOpened: false });
@@ -73,7 +106,7 @@ export function adaptBetterSidebarUI(root) {
const rowEnd = source.indexOf('\n\t\t\t\t\t\t]', title)
if (title < 0 || rowStart < 0 || rowEnd < 0) throw new Error('Sidebar position row anchor missing')
source = source.slice(0, rowStart) + 'null' + source.slice(rowEnd)
- for (const key of ['browserNoSandbox', 'browserAllowedLoopback', 'htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe']) {
+ for (const key of ['bottomPanelAutoTerminal', 'browserNoSandbox', 'browserAllowedLoopback', 'htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe']) {
const pattern = new RegExp('\\{\\s*key: "' + key + '",[\\s\\S]*?\\}')
if (!pattern.test(source)) throw new Error(`Sidebar preference anchor missing: ${key}`)
source = source.replace(pattern, 'null')
@@ -105,6 +138,17 @@ export function adaptBetterSidebarUI(root) {
`
source = source.replace(cssMatch[0], `const css$5 = ${JSON.stringify(css)};`)
}
+ if (file !== 'client-editor.js') source = removeBottomWorkbench(source)
+ source = stripWorkbenchStyles(source).replace(/^\/\/# sourceMappingURL=.*$/gmu, '')
writeFileSync(path, marker + '\n' + source)
+ rmSync(path + '.map', { force: true })
}
+ for (const name of ['client-terminal.js', 'client-mermaid.js']) {
+ const path = resolve(root, 'lib', name)
+ if (!existsSync(path)) continue
+ const source = readFileSync(path, 'utf8')
+ const trimmed = stripWorkbenchStyles(source).replace(/^\/\/# sourceMappingURL=.*$/gmu, '')
+ if (trimmed !== source) { writeFileSync(path, trimmed); rmSync(path + '.map', { force: true }) }
+ }
+
}
diff --git a/apps/deepviewer-desktop/scripts/adapt-subscriptions-plugin.mjs b/apps/deepviewer-desktop/scripts/adapt-subscriptions-plugin.mjs
index 697fef4..8db1088 100644
--- a/apps/deepviewer-desktop/scripts/adapt-subscriptions-plugin.mjs
+++ b/apps/deepviewer-desktop/scripts/adapt-subscriptions-plugin.mjs
@@ -1,8 +1,9 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
+import { subscriptionMessages } from './subscriptions-v4-messages.mjs'
-export const SUBSCRIPTIONS_UI_ADAPTER_ID = 'deepviewer-remaining-usage-dsh015-v2'
-export const SUBSCRIPTIONS_DSH_PEER_VERSION = '0.1.5-rc.2'
+export const SUBSCRIPTIONS_UI_ADAPTER_ID = 'deepviewer-remaining-usage-dsh020-v1'
+export const SUBSCRIPTIONS_DSH_PEER_VERSION = '0.2.0-rc.2'
const subscriptionsPluginName = 'dsh-plugin-subscriptions'
const subscriptionsPluginVersion = '0.3.1'
@@ -99,7 +100,7 @@ function adaptedSubscriptionsManifest(manifestPath) {
}
manifest.peerDependencies[peer] = SUBSCRIPTIONS_DSH_PEER_VERSION
}
- manifest.peerDependencies['@deepseek-ai/cordis'] = '4.0.2'
+ manifest.peerDependencies['@deepseek-ai/cordis'] = '4.0.4'
manifest.files = [...new Set([...manifest.files, 'lib/LICENSE.dsh-gallery'])]
manifest.dsh.client.inject = manifest.dsh.client.inject.map(name => name === '@deepseek-ai/dsh-client-runtime' ? '@deepseek-ai/dsh-client-ui-session' : name)
return `${JSON.stringify(manifest, null, 2)}\n`
@@ -132,6 +133,14 @@ export function adaptSubscriptionsPlugin(pluginRoot) {
const hostPath = join(pluginRoot, 'lib', 'index.js')
const originalHost = readFileSync(hostPath, 'utf8')
let adaptedHost = replaceRequired(originalHost, 'CONTEXT_WINDOW_EXCEEDED_CODE, CallId,', 'CONTEXT_WINDOW_EXCEEDED_CODE, ToolCallId as CallId,', 'DSH tool-call identity rename')
+ const imageStart = adaptedHost.indexOf('async function resolveImages(messages, attachments, signal) {')
+ const imageEnd = adaptedHost.indexOf('\n//#endregion', imageStart)
+ if (imageStart < 0 || imageEnd < imageStart) throw new Error('subscriptions image resolver is missing')
+ const resolver = `${subscriptionMessages.toString()}\nasync function resolveImages(messages, attachments, signal) {\n return subscriptionMessages(messages, attachments, signal, offloadedImageText);\n}\n`
+ if (!adaptedHost.includes('function subscriptionMessages(')) {
+ adaptedHost = adaptedHost.slice(0, imageStart) + resolver + adaptedHost.slice(imageEnd)
+ }
+ adaptedHost = replaceRequired(adaptedHost, 'CONTEXT_WINDOW_EXCEEDED_CODE, ToolCallId as CallId,', 'offloadedImageText, CONTEXT_WINDOW_EXCEEDED_CODE, ToolCallId as CallId,', 'DSH V4 image omission')
adaptedHost = replaceRequired(adaptedHost,
'type: "function",\n\t\tname: tool.name,\n\t\tdescription: tool.description,\n\t\tparameters: tool.parameters',
// Responses may normalize an omitted strict flag and require optional
@@ -144,7 +153,7 @@ export function adaptSubscriptionsPlugin(pluginRoot) {
'DSH scoped RPC registration')
adaptedHost = replaceRequired(adaptedHost,
'}, { authority: "loopback" }), "dsh-plugin-subscriptions: /subscriptions-auth rpc channel");',
- '}); // DeepViewer: authenticated Connection owns the route lifetime.',
+ '}, ctx$1); // DeepViewer: authenticated Connection owns the route lifetime.',
'DSH authenticated RPC authority')
const hostChanged = adaptedHost !== originalHost
if (hostChanged) writeFileSync(hostPath, adaptedHost)
diff --git a/apps/deepviewer-desktop/scripts/audit-native-payload.mjs b/apps/deepviewer-desktop/scripts/audit-native-payload.mjs
new file mode 100644
index 0000000..de31cd5
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/audit-native-payload.mjs
@@ -0,0 +1,32 @@
+import { execFileSync } from 'node:child_process'
+import { closeSync, existsSync, globSync, lstatSync, openSync, readSync, realpathSync, readFileSync, rmSync } from 'node:fs'
+import { dirname, isAbsolute, join, relative } from 'node:path'
+/** Vendored pnpm carries optional binaries for every platform; retain only manifest-declared compatible packages. */
+export function pruneForeignRuntimePackages(root) {
+ const removed = []
+ const accepts = (values, value) => !values || (!values.includes(`!${value}`) && (!values.some(item => !item.startsWith('!')) || values.includes(value)))
+ for (const file of globSync('**/package.json', { cwd: root, dot: true })) {
+ const manifest = join(root, file); if (!existsSync(manifest)) continue
+ const data = JSON.parse(readFileSync(manifest, 'utf8'))
+ if (!accepts(data.os,'darwin') || !accepts(data.cpu,'arm64')) { removed.push({ name: data.name, path: dirname(file) }); rmSync(dirname(manifest), { recursive: true }) }
+ }
+ return removed
+}
+export function auditNativePayload(root) {
+ const directory = realpathSync(root), binaries = []
+ for (const path of globSync('**/*', { cwd: directory, dot: true })) {
+ const absolute = join(directory, path), info = lstatSync(absolute)
+ if (info.isSymbolicLink()) {
+ const child = relative(directory, realpathSync(absolute)); if (child === '..' || child.startsWith('../') || isAbsolute(child)) throw new Error(`Escaping payload link: ${path}`)
+ continue
+ }
+ if (!info.isFile() || info.size < 4) continue
+ const bytes = Buffer.alloc(4), fd = openSync(absolute, 'r'); try { readSync(fd, bytes, 0, 4, 0) } finally { closeSync(fd) }
+ if (!['feedface','cefaedfe','feedfacf','cffaedfe','cafebabe','bebafeca','cafebabf','bfbafeca'].includes(bytes.toString('hex'))) continue
+ const archs = execFileSync('/usr/bin/lipo', ['-archs', absolute], { encoding: 'utf8' }).trim().split(/\s+/)
+ if (!archs.includes('arm64')) throw new Error(`Non-arm64 Mach-O: ${path} (${archs.join(',')})`)
+ binaries.push({ path, archs })
+ }
+ if (!binaries.length) throw new Error('Payload has no verified Mach-O binaries')
+ return { machOBinaries: binaries.length, binaries }
+}
diff --git a/apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs b/apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
new file mode 100644
index 0000000..8019385
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/bootstrap-native-upstream.mjs
@@ -0,0 +1,7 @@
+import { execFileSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { upstreamRoot, harnessCommit, prepareUpstream } from '../../deepviewer-adapter/scripts/prepare-upstream.mjs'
+if (!existsSync(upstreamRoot)) execFileSync('git', ['clone', '--depth', '1', '--branch', 'dsh-v0.2.0-rc.2', 'https://github.com/deepseek-ai/deepseek-harness.git', upstreamRoot], { stdio: 'inherit' })
+if (execFileSync('git', ['rev-parse', 'HEAD'], { cwd: upstreamRoot, encoding: 'utf8' }).trim() !== harnessCommit) throw new Error('Existing upstream checkout has a different identity; it was not modified')
+prepareUpstream()
+console.info('DeepViewer pinned DSH 0.2.0-rc.2 adapter contracts prepared')
diff --git a/apps/deepviewer-desktop/scripts/build-model-capabilities-plugin.mjs b/apps/deepviewer-desktop/scripts/build-model-capabilities-plugin.mjs
new file mode 100644
index 0000000..c3c861e
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/build-model-capabilities-plugin.mjs
@@ -0,0 +1,62 @@
+import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { spawnSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+
+const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+export const modelCapabilitiesPluginName = '@deepviewer/dsh-plugin-model-capabilities'
+export const modelCapabilitiesPluginVersion = '1.0.2'
+const sourceFiles = ['package.json', 'cordis.patch.yml', 'LICENSE', 'UPSTREAM.md', 'tsconfig.json']
+
+export function buildModelCapabilitiesPlugin(upstream = resolve(project, 'upstream/deepseek-harness')) {
+ const source = resolve(project, 'apps/dsh-plugin-model-capabilities')
+ const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8'))
+ if (manifest.name !== modelCapabilitiesPluginName || manifest.version !== modelCapabilitiesPluginVersion) throw new Error('Model capabilities plugin identity mismatch')
+ const target = resolve(upstream, 'node_modules', modelCapabilitiesPluginName)
+ const hash = createHash('sha256')
+ const scan = relative => {
+ for (const entry of readdirSync(join(source, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+ const child = join(relative, entry.name)
+ if (entry.isDirectory()) scan(child)
+ else if (entry.isFile()) hash.update(child).update(readFileSync(join(source, child)))
+ else throw new Error('Plugin source must contain regular files only')
+ }
+ }
+ scan('src')
+ for (const file of sourceFiles) hash.update(file).update(readFileSync(join(source, file)))
+ hash.update(readFileSync(fileURLToPath(import.meta.url)))
+ hash.update(readFileSync(join(upstream, 'packages/client/tsdown.client.ts')))
+ const digest = hash.digest('hex'), stamp = join(target, '.build-stamp')
+ const outputs = ['lib/index.js', 'lib/client.js', 'lib/client.js.map', 'cordis.patch.yml', 'LICENSE']
+ if (existsSync(stamp) && readFileSync(stamp, 'utf8') === digest && outputs.every(file => existsSync(join(target, file)))) return target
+
+ mkdirSync(target, { recursive: true })
+ for (const directory of ['lib', 'src']) rmSync(join(target, directory), { recursive: true, force: true })
+ cpSync(join(source, 'src'), join(target, 'src'), { recursive: true })
+ for (const file of sourceFiles) cpSync(join(source, file), join(target, file))
+ const peers = {
+ '@deepseek-ai/cordis': 'vendor/cordis', '@deepseek-ai/schemastery': 'vendor/schemastery',
+ '@deepseek-ai/dsh-api-remotes': 'packages/api/remotes', '@deepseek-ai/dsh-credentials': 'packages/credentials/credentials',
+ '@deepseek-ai/dsh-home-paths': 'packages/util/home-paths', '@deepseek-ai/dsh-llm-pi-ai': 'packages/llm/llm-pi-ai',
+ react: 'packages/client/ui-renderer/node_modules/react', '@types/react': 'packages/client/ui-renderer/node_modules/@types/react',
+ '@types/node': 'node_modules/@types/node',
+ }
+ for (const name of ['ui-settings', 'ui-settings-models', 'ui-slots', 'locale']) peers['@deepseek-ai/dsh-client-' + name] = 'packages/client/' + name
+ for (const [name, directory] of Object.entries(peers)) {
+ const link = join(target, 'node_modules', name)
+ mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(resolve(upstream, directory), link, 'dir')
+ }
+ const checked = spawnSync(process.execPath, [join(upstream, 'node_modules/typescript/bin/tsc'), '-p', join(target, 'tsconfig.json'), '--noEmit'], { cwd: target, stdio: 'inherit' })
+ if (checked.status !== 0) throw new Error('Model capabilities RC2 typecheck failed')
+ // Use the pinned DSH bundler for native staging; no registry or temporary toolchain dependency.
+ const config = join(target, 'tsdown.config.mjs')
+ writeFileSync(config, `import { clientBundle } from ${JSON.stringify(join(upstream, 'packages/client/tsdown.client.ts'))}\nexport default clientBundle('${modelCapabilitiesPluginName}', ['src/index.mjs'])\n`)
+ const result = spawnSync(join(upstream, 'node_modules/.bin/tsdown'), ['--config', config], {
+ cwd: target, stdio: 'inherit', env: { ...process.env, DSH_EXTERNAL_WORKSPACE_MANIFEST: join(target, 'package.json') },
+ })
+ if (result.status !== 0 || outputs.some(file => !existsSync(join(target, file)))) throw new Error('Model capabilities native build failed')
+ writeFileSync(stamp, digest)
+ return target
+}
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) buildModelCapabilitiesPlugin()
diff --git a/apps/deepviewer-desktop/scripts/build-native-desktop.mjs b/apps/deepviewer-desktop/scripts/build-native-desktop.mjs
new file mode 100644
index 0000000..8024323
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/build-native-desktop.mjs
@@ -0,0 +1,67 @@
+import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { createRequire } from 'node:module'
+import { stageAdapter } from '../../deepviewer-adapter/scripts/stage.mjs'
+import { buildModelCapabilitiesPlugin } from './build-model-capabilities-plugin.mjs'
+import { stageSubscriptionsPlugin } from './stage-subscriptions.mjs'
+import { compileNativeAppIcon } from './native-app-icon.mjs'
+import { stageNativeModules } from '../../deepviewer-adapter/scripts/stage-native-modules.mjs'
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const root = resolve(appRoot, '../..'), upstream = join(root, 'upstream/deepseek-harness')
+const product = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8'))
+function run(command, args, cwd = appRoot) { const r = spawnSync(command, args, { cwd, stdio: 'inherit', env: process.env }); if (r.status !== 0) throw new Error(`${command} ${args.join(' ')} failed`) }
+if (!existsSync(join(upstream, '.git'))) throw new Error('Initialize the pinned DSH checkout with scripts/bootstrap-native-upstream.mjs')
+run(process.execPath, [join(root, 'apps/deepviewer-adapter/scripts/prepare-upstream.mjs')], root)
+if (!process.argv.includes('--skip-core-build')) run('pnpm', ['run', 'build'], upstream)
+const adapter = stageAdapter(upstream)
+buildModelCapabilitiesPlugin(upstream)
+stageSubscriptionsPlugin()
+const nativeCli = stageNativeModules(upstream, join(appRoot, '.desktop/native-cli'))
+if (process.argv.includes('--core-only')) { console.info('DeepViewer core and adapter/plugin bundles prepared'); process.exit(0) }
+const { register } = await import(createRequire(join(upstream, 'package.json')).resolve('tsx/esm/api'))
+register()
+const { prepareDevelopmentProject } = await import(join(upstream, 'apps/desktop/scripts/development-project.ts'))
+const desktopRequire = createRequire(join(upstream, 'apps/desktop/package.json'))
+const electron = createRequire(join(appRoot, 'package.json'))('electron')
+const nodeVersion = spawnSync(electron, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }).stdout.trim()
+const projectDir = join(appRoot, '.desktop/native-runtime')
+const version = JSON.parse(readFileSync(join(upstream, 'apps/cli/package.json'), 'utf8')).version
+const pnpmVersion = JSON.parse(readFileSync(join(root, 'node_modules/pnpm/package.json'), 'utf8')).version
+prepareDevelopmentProject({ projectDir, cliDir: nativeCli, hostDir: join(upstream, 'apps/desktop-host'), dependencyDir: join(upstream, 'node_modules/.pnpm/node_modules'), release: { schemaVersion: 1, version, hostProtocolVersion: 4, nodeVersion, pnpmVersion }, target: 'mac-arm64' })
+const descriptorPath = join(projectDir, 'desktop-runtime.json'), descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8'))
+for (const name of ['@deepviewer/adapter', '@deepviewer/dsh-plugin-model-capabilities', 'dsh-plugin-subscriptions']) {
+ const link = join(projectDir, 'node_modules', name); mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(join(upstream, 'node_modules', name), link)
+ const manifest = JSON.parse(readFileSync(join(link, 'package.json'), 'utf8')); descriptor.sharedPackages.push({ name, version: manifest.version, path: `node_modules/${name}` })
+}
+writeFileSync(descriptorPath, JSON.stringify(descriptor, null, 2)+'\n')
+for (const name of ['electron-updater', 'semver', 'ws', '@deepseek-ai/cordis', '@deepseek-ai/dsh-api-gateway', 'pnpm']) {
+ const link = join(appRoot, 'node_modules', name), directory = name === 'pnpm' ? join(root, 'node_modules/pnpm') : dirname(desktopRequire.resolve(`${name}/package.json`))
+ mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(directory, link)
+}
+run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.main.config.ts'])
+mkdirSync(join(appRoot, 'lib'), { recursive: true })
+cpSync(join(appRoot, '.desktop/build/main.js'), join(appRoot, 'lib/main.js'))
+// The official bundle reads its adjacent manifest via ../package.json.
+cpSync(join(appRoot, 'package.json'), join(appRoot, 'lib/package.json'))
+rmSync(join(appRoot, 'lib/official'), { recursive: true, force: true })
+cpSync(join(upstream, 'apps/desktop/lib'), join(appRoot, 'lib/official'), { recursive: true })
+rmSync(join(appRoot, 'renderer'), { recursive: true, force: true })
+cpSync(join(upstream, 'apps/desktop/renderer'), join(appRoot, 'renderer'), { recursive: true })
+appendFileSync(join(appRoot, 'lib/official/preload-app.cjs'), '\n' + readFileSync(join(root, 'apps/deepviewer-adapter/desktop/preload-state.cjs'), 'utf8'))
+cpSync(join(upstream, 'apps/desktop/lib/preload-platform-account.cjs'), join(appRoot, 'lib/preload-platform-account.cjs'))
+const assets = join(upstream, 'apps/web/dist/assets/deepviewer'); mkdirSync(assets, { recursive: true }); cpSync(join(adapter, 'assets'), assets, { recursive: true })
+cpSync(join(upstream, 'apps/desktop/scripts/node-bin'), join(appRoot, 'scripts/node-bin'), { recursive: true })
+await compileNativeAppIcon(appRoot)
+if (process.argv.includes('--runtime')) {
+ const { preparePrimaryRuntime } = await import(join(upstream, 'scripts/primary-runtime/prepare.ts'))
+ await preparePrimaryRuntime({ target: 'mac-arm64', output: join(appRoot, '.desktop/runtime'), cache: join(appRoot, '.desktop/downloads'), version: product.version })
+ const link = join(appRoot, '.desktop/primary-runtime'); rmSync(link, { recursive: true, force: true }); symlinkSync(join(appRoot, '.desktop/runtime/primary-runtime'), link)
+} else if (!existsSync(join(appRoot, '.desktop/primary-runtime/runtime.json'))) {
+ // Build our own payload by default. An explicit existing runtime is only a development smoke fixture.
+ const fixture = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR
+ if (fixture) { if (!existsSync(join(fixture, 'runtime.json'))) throw new Error('Invalid primary-runtime fixture') }
+ else { const { preparePrimaryRuntime } = await import(join(upstream, 'scripts/primary-runtime/prepare.ts')); await preparePrimaryRuntime({ target: 'mac-arm64', output: join(appRoot, '.desktop/runtime'), cache: join(appRoot, '.desktop/downloads'), version: product.version }); symlinkSync(join(appRoot, '.desktop/runtime/primary-runtime'), join(appRoot, '.desktop/primary-runtime')) }
+}
+console.info(`DeepViewer ${product.version} official DesktopHost staged, core=${version}`)
diff --git a/apps/deepviewer-desktop/scripts/build-reasoning-plugin.mjs b/apps/deepviewer-desktop/scripts/build-reasoning-plugin.mjs
index eeab5d7..5badda8 100644
--- a/apps/deepviewer-desktop/scripts/build-reasoning-plugin.mjs
+++ b/apps/deepviewer-desktop/scripts/build-reasoning-plugin.mjs
@@ -25,11 +25,22 @@ export function buildReasoningPlugin(upstream = resolve(project, 'upstream/deeps
rmSync(resolve(target, 'lib'), { recursive: true, force: true })
cpSync(source, target, { recursive: true })
const peers = { '@deepseek-ai/cordis': 'vendor/cordis', '@deepseek-ai/dsh-api-remotes': 'packages/api/remotes', '@deepseek-ai/dsh-settings': 'packages/settings/settings', '@deepseek-ai/dsh-credentials': 'packages/credentials/credentials', '@deepseek-ai/dsh-home-paths': 'packages/util/home-paths' }
- for (const name of ['ui-settings', 'ui-settings-models', 'ui-slots', 'locale', 'connection']) peers['@deepseek-ai/dsh-client-' + name] = 'packages/client/' + name
+ for (const name of ['ui-settings', 'ui-settings-models', 'ui-slots', 'ui-renderer', 'locale', 'connection']) peers['@deepseek-ai/dsh-client-' + name] = 'packages/client/' + name
+ peers.react = 'packages/client/ui-renderer/node_modules/react'
+ peers['@types/react'] = 'packages/client/ui-renderer/node_modules/@types/react'
for (const [name, directory] of Object.entries(peers)) {
const link = resolve(target, 'node_modules', name)
mkdirSync(dirname(link), { recursive: true }); rmSync(link, { recursive: true, force: true }); symlinkSync(resolve(upstream, directory), link, 'dir')
}
+ // Typecheck the external Client against the built RC2 contract, not just tsdown's transpilation.
+ writeFileSync(resolve(target, 'deepviewer-client-globals.d.ts'), 'declare module "*.module.css" { const classes: Record; export default classes }\n')
+ writeFileSync(resolve(target, 'deepviewer-client.tsconfig.json'), JSON.stringify({
+ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', strict: true, skipLibCheck: true,
+ noEmit: true, allowImportingTsExtensions: true, allowJs: true, checkJs: false, types: ['react', 'node'], lib: ['ES2022', 'DOM'] },
+ include: ['src/client/**/*', 'deepviewer-client-globals.d.ts'], exclude: [],
+ }, null, 2))
+ const checked = spawnSync(process.execPath, [resolve(upstream, 'node_modules/typescript/bin/tsc'), '-p', resolve(target, 'deepviewer-client.tsconfig.json')], { cwd: target, stdio: 'inherit' })
+ if (checked.status !== 0) throw new Error('Reasoning plugin RC2 Client typecheck failed')
writeFileSync(resolve(target, 'tsdown.config.mjs'), `import { clientBundle } from ${JSON.stringify(resolve(upstream, 'packages/client/tsdown.client.ts'))}\nexport default clientBundle('${reasoningPluginName}', ['src/index.ts'])\n`)
// Invoke the checked-in workspace binary directly. `pnpm exec` may trigger a
// registry-backed dependency reconciliation when this nested package is
diff --git a/apps/deepviewer-desktop/scripts/build-runtime.mjs b/apps/deepviewer-desktop/scripts/build-runtime.mjs
index 26eb5e5..2af95b5 100644
--- a/apps/deepviewer-desktop/scripts/build-runtime.mjs
+++ b/apps/deepviewer-desktop/scripts/build-runtime.mjs
@@ -1,5 +1,5 @@
import { stageBetterSidebar, validateBetterSidebar, betterSidebarName, betterSidebarVersion } from './stage-better-sidebar.mjs'
-import { buildReasoningPlugin, reasoningPluginName } from './build-reasoning-plugin.mjs'
+import { buildModelCapabilitiesPlugin, modelCapabilitiesPluginName, modelCapabilitiesPluginVersion } from './build-model-capabilities-plugin.mjs'
import { createHash } from 'node:crypto'
import { execFileSync, spawn } from 'node:child_process'
import {
@@ -32,9 +32,8 @@ const packRoots = [
resolve(upstreamRoot, 'dist', 'deepviewer', 'vendor'),
resolve(upstreamRoot, 'dist', 'deepviewer', 'dsh'),
]
-const electronVersion = '43.4.0'
-const expectedHarnessCommit = 'fb2c4b9e698e30edb738bca4cf0618587db7d203'
-const expectedHarnessVersion = '0.1.5-rc.2'
+const expectedHarnessCommit = '477b4f420553e8a52c2fbccc464d7561b239c443'
+const expectedHarnessVersion = '0.1.7-rc.2'
const subscriptionsPluginName = 'dsh-plugin-subscriptions'
const subscriptionsPluginVersion = '0.3.1'
const appManifest = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8'))
@@ -81,6 +80,12 @@ function packedIdentity(tarball) {
if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string') {
throw new Error(`invalid npm package identity in ${tarball}`)
}
+ // A source-tree boot cannot detect a YAML overlay omitted from npm's files list.
+ const declaredPatches = manifest.dsh?.bundle?.patch
+ for (const patch of typeof declaredPatches === 'string' ? [declaredPatches] : Array.isArray(declaredPatches) ? declaredPatches : []) {
+ if (typeof patch !== 'string' || patch.startsWith('/') || patch.split('/').includes('..')) throw new Error(`Invalid bundle patch in ${manifest.name}`)
+ tarEntry(archive, `package/${patch.replace(/^\.\//u, '')}`)
+ }
return { name: manifest.name, version: manifest.version }
}
@@ -259,21 +264,21 @@ function sanitizeReleaseBuildPaths(runtimeRoot) {
visit(runtimeRoot)
}
-function packReasoningPlugin() {
- const source = buildReasoningPlugin(upstreamRoot)
- const destination = resolve(projectRoot, '.runtime/inputs/deepviewer-reasoning-0.1.0')
+function packModelCapabilitiesPlugin() {
+ const source = buildModelCapabilitiesPlugin(upstreamRoot)
+ const destination = resolve(projectRoot, `.runtime/inputs/deepviewer-model-capabilities-${modelCapabilitiesPluginVersion}`)
rmSync(destination, { recursive: true, force: true }); mkdirSync(destination, { recursive: true })
// Only package canonical build outputs; cloud-conflict copies in lib/ are not release inputs.
const packageSource = join(destination, 'package-source')
mkdirSync(join(packageSource, 'lib'), { recursive: true })
- for (const file of ['package.json', 'LICENSE', 'cordis.patch.yml', 'lib/index.js', 'lib/client.js', 'lib/client.js.map']) {
+ for (const file of ['package.json', 'LICENSE', 'UPSTREAM.md', 'cordis.patch.yml', 'lib/index.js', 'lib/client.js', 'lib/client.js.map']) {
cpSync(join(source, file), join(packageSource, file))
}
execFileSync('npm', ['pack', '--ignore-scripts', '--pack-destination', destination], { cwd: packageSource, stdio: ['ignore', 'pipe', 'pipe'] })
const files = readdirSync(destination).filter(name => name.endsWith('.tgz'))
- if (files.length !== 1) throw new Error('Expected one reasoning plugin tarball')
+ if (files.length !== 1) throw new Error('Expected one model capabilities plugin tarball')
const tarball = join(destination, files[0])
- if (packedIdentity(tarball).name !== reasoningPluginName) throw new Error('Reasoning plugin identity mismatch')
+ if (packedIdentity(tarball).name !== modelCapabilitiesPluginName) throw new Error('Model capabilities plugin identity mismatch')
return tarball
}
@@ -298,7 +303,7 @@ if (upstreamCommit !== expectedHarnessCommit) {
}
const dependencies = packedDependencies()
dependencies.set(subscriptionsPluginName, packSubscriptionsPlugin())
-dependencies.set(reasoningPluginName, packReasoningPlugin())
+dependencies.set(modelCapabilitiesPluginName, packModelCapabilitiesPlugin())
dependencies.set(betterSidebarName, packBetterSidebar())
const sourceSnapshot = localSnapshot ? {
kind: 'local-development', baseCommit: expectedHarnessCommit, headCommit: upstreamCommit,
@@ -338,12 +343,12 @@ for (const arch of architectures) {
const packagedPlugins = [sanitizeSubscriptionsPlugin(runtimeRoot)]
const sidebarManifest = validateBetterSidebar(join(runtimeRoot, "node_modules", betterSidebarName))
- if (sidebarManifest.deepviewerAdapter !== "deepviewer-dsh015-sidebar-management-v2") throw new Error("Better Sidebar runtime auth adapter missing")
+ if (sidebarManifest.deepviewerAdapter !== "deepviewer-dsh017-sidebar-management-v1") throw new Error("Better Sidebar runtime auth adapter missing")
packagedPlugins.push({ name: betterSidebarName, version: betterSidebarVersion, license: "MIT" })
for (const file of ['lib/index.js', 'lib/client.js', 'cordis.patch.yml', 'LICENSE']) {
- if (!existsSync(join(runtimeRoot, 'node_modules', reasoningPluginName, file))) throw new Error('Reasoning plugin runtime file missing: ' + file)
+ if (!existsSync(join(runtimeRoot, 'node_modules', modelCapabilitiesPluginName, file))) throw new Error('Model capabilities plugin runtime file missing: ' + file)
}
- packagedPlugins.push({ name: reasoningPluginName, version: '0.1.0', license: 'MIT' })
+ packagedPlugins.push({ name: modelCapabilitiesPluginName, version: modelCapabilitiesPluginVersion, license: 'MIT' })
const entry = join(runtimeRoot, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
if (!existsSync(entry)) throw new Error(`installed Harness entry is missing at ${entry}`)
const spawnHelper = join(runtimeRoot, 'node_modules', 'node-pty', 'prebuilds', `darwin-${arch}`, 'spawn-helper')
@@ -355,6 +360,8 @@ for (const arch of architectures) {
const requiredNativeFiles = [
join(runtimeRoot, 'node_modules', 'node-pty', 'prebuilds', `darwin-${arch}`, 'pty.node'),
join(runtimeRoot, 'node_modules', '@koromix', `koffi-darwin-${arch}`, `darwin_${arch}`, 'koffi.node'),
+ // RC2 persistence now depends on the published POSIX flock binding.
+ join(runtimeRoot, 'node_modules', '@deepseek-ai', `node-addon-system-darwin-${arch}`, 'bin', 'system.node'),
]
for (const path of requiredNativeFiles) {
if (!existsSync(path)) throw new Error(`required darwin-${arch} native module is missing: ${path}`)
diff --git a/apps/deepviewer-desktop/scripts/capture-harness-overlay.mjs b/apps/deepviewer-desktop/scripts/capture-harness-overlay.mjs
new file mode 100644
index 0000000..7b9bd99
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/capture-harness-overlay.mjs
@@ -0,0 +1,42 @@
+/** Capture reviewed source only; never stage the developer's working index. */
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const core = resolve(root, 'upstream/deepseek-harness')
+const output = resolve(root, 'upstream/snapshots/deepviewer-dsh-017rc2')
+const baseCommit = '477b4f420553e8a52c2fbccc464d7561b239c443'
+const hash = data => createHash('sha256').update(data).digest('hex')
+const scratch = mkdtempSync(resolve(tmpdir(), 'deepviewer-source-index-'))
+const git = (args, env = {}) => execFileSync('git', args, { cwd: core, env: { ...process.env, ...env }, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
+try {
+ if (git(['rev-parse', 'HEAD']).trim() !== baseCommit) throw new Error('Capture requires the pinned RC2 base')
+ const paths = [...new Set([
+ ...git(['diff', '--name-only', 'HEAD', '-z']).split('\0'),
+ ...git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0'),
+ ])].filter(path => /^(packages\/|apps\/|scripts\/|docs\/|tsconfig[^/]*\.json$|pnpm-lock\.yaml$)/u.test(path)
+ && !/\/(lib|dist|node_modules|\.dsh-build)\//u.test(path)).sort()
+ const env = { GIT_INDEX_FILE: resolve(scratch, 'index') }
+ git(['read-tree', baseCommit], env)
+ for (let start = 0; start < paths.length; start += 100) git(['add', '-A', '--', ...paths.slice(start, start + 100)], env)
+ const sourceTree = git(['write-tree'], env).trim()
+ const patch = git(['diff', '--cached', '--binary', '--full-index', baseCommit], env)
+ // Independently replay on a clean base index, including binary assets.
+ mkdirSync(output, { recursive: true })
+ const patchPath = resolve(output, 'harness.patch')
+ writeFileSync(patchPath, patch)
+ git(['read-tree', baseCommit], env)
+ git(['apply', '--cached', '--whitespace=nowarn', patchPath], env)
+ if (git(['write-tree'], env).trim() !== sourceTree) throw new Error('Patch replay changed the captured source tree')
+ const manifest = {
+ schemaVersion: 1, product: 'DeepViewer', harnessVersion: '0.1.7-rc.2', baseCommit, sourceTree,
+ patchSha256: hash(patch), source: 'https://github.com/deepseek-ai/deepseek-harness',
+ files: paths.map(path => ({ path, sha256: existsSync(resolve(core, path)) ? hash(readFileSync(resolve(core, path))) : null })),
+ }
+ writeFileSync(resolve(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
+ console.log(`Captured and replay-verified ${paths.length} source files (${Buffer.byteLength(patch)} patch bytes).`)
+} finally { rmSync(scratch, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/dev.mjs b/apps/deepviewer-desktop/scripts/dev.mjs
index 9a4bf5f..32c9b44 100644
--- a/apps/deepviewer-desktop/scripts/dev.mjs
+++ b/apps/deepviewer-desktop/scripts/dev.mjs
@@ -3,21 +3,18 @@ import { createHash } from 'node:crypto'
import { existsSync, readFileSync, rmSync, watch } from 'node:fs'
import { createConnection, createServer } from 'node:net'
import { tmpdir } from 'node:os'
-import { dirname, join, normalize, resolve } from 'node:path'
+import { dirname, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
+import { createDevelopmentChangeDetector, shouldRestartForDevelopmentPath } from './development-inputs.mjs'
+import { prepareDevelopmentShell } from './development-shell.mjs'
+
+export { shouldRestartForDevelopmentPath } from './development-inputs.mjs'
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const projectRoot = resolve(appRoot, '..', '..')
const require = createRequire(import.meta.url)
const electronExecutable = require('electron')
-const watchedRootEntries = new Set([
- 'package.json',
- 'tsconfig.json',
- 'vite.main.config.ts',
- 'vite.preload.config.ts',
- 'vite.renderer.config.ts',
-])
function manifestWithoutBuildNumber() {
try {
@@ -39,12 +36,6 @@ export function createManifestChangeDetector(readFingerprint = manifestWithoutBu
}
}
-export function shouldRestartForDevelopmentPath(path) {
- if (typeof path !== 'string' || path === '') return false
- const normalized = normalize(path).replaceAll('\\', '/')
- return normalized === 'src' || normalized.startsWith('src/') || watchedRootEntries.has(normalized)
-}
-
export function developmentControlSocketPath(root = projectRoot) {
const owner = typeof process.getuid === 'function' ? process.getuid() : 'unknown'
const projectHash = createHash('sha256').update(resolve(root)).digest('hex').slice(0, 12)
@@ -132,15 +123,17 @@ async function runDevelopmentRunner() {
pendingReason = undefined
await stopElectron(electronChild)
electronChild = undefined
- process.stdout.write(`DeepViewer Dev build: ${currentReason}\n`)
+ process.stdout.write(`${new Date().toISOString()} DeepViewer Dev build: ${currentReason}\n`)
+ let developmentExecutable
try {
- await run('pnpm', ['build'], { cwd: appRoot })
+ await run('pnpm', ['build:dev'], { cwd: appRoot })
+ developmentExecutable = await prepareDevelopmentShell(electronExecutable, appRoot)
} catch (error) {
process.stderr.write(`DeepViewer Dev build failed: ${error instanceof Error ? error.message : String(error)}\n`)
continue
}
if (pendingReason !== undefined || shuttingDown) continue
- const launchedChild = spawn(electronExecutable, ['.'], {
+ const launchedChild = spawn(developmentExecutable, ['.'], {
cwd: appRoot,
env: {
...process.env,
@@ -182,18 +175,42 @@ async function runDevelopmentRunner() {
server.listen(socketPath, resolvePromise)
})
- const manifestChanged = createManifestChangeDetector()
+ const inputsChanged = createDevelopmentChangeDetector(appRoot)
+ const externalInputs = [
+ { path: 'apps/deepviewer-adapter', directories: ['client', 'desktop', 'migrations', 'workflows', 'compatibility', 'scripts', 'assets'], files: ['package.json', 'cordis.patch.yml', 'native-modules.patch.yml'] },
+ { path: 'apps/dsh-plugin-model-capabilities', directories: ['src'], files: ['package.json', 'cordis.patch.yml', 'tsconfig.json', 'LICENSE', 'UPSTREAM.md'] },
+ ].map(input => ({ ...input, changed: createDevelopmentChangeDetector(resolve(projectRoot, input.path), input) }))
+ const pendingInputChanges = new Set()
+ let inputReadRetries = 0
+ const checkInputs = () => {
+ debounceTimer = undefined
+ if (shuttingDown) return
+ let changed
+ try {
+ for (const path of inputsChanged()) pendingInputChanges.add(path)
+ for (const input of externalInputs) for (const path of input.changed()) pendingInputChanges.add(`${input.path}/${path}`)
+ changed = [...pendingInputChanges]
+ pendingInputChanges.clear()
+ } catch (error) {
+ // Editors may briefly remove a source or leave partial JSON during a save.
+ if (inputReadRetries++ < 3) debounceTimer = setTimeout(checkInputs, 300)
+ else process.stderr.write(`DeepViewer Dev could not read inputs: ${error.message}; waiting for the next file event.\n`)
+ return
+ }
+ inputReadRetries = 0
+ if (changed.length > 0) void requestRestart(`content changed: ${changed.join(', ')}`)
+ }
const sourceWatcher = watch(appRoot, { recursive: true }, (_eventType, filename) => {
if (!shouldRestartForDevelopmentPath(filename)) return
- // `pnpm build` increments package.json's buildNumber. That metadata change
- // is part of the build itself and must not trigger a second build loop;
- // substantive manifest edits still restart the development app.
- if (filename === 'package.json' && !manifestChanged()) return
+ inputReadRetries = 0
if (debounceTimer !== undefined) clearTimeout(debounceTimer)
- debounceTimer = setTimeout(() => {
- void requestRestart(`changed ${filename}`)
- }, 200)
+ debounceTimer = setTimeout(checkInputs, 300)
})
+ const externalWatchers = externalInputs.map(input => watch(resolve(projectRoot, input.path), { recursive: true }, () => {
+ inputReadRetries = 0
+ if (debounceTimer !== undefined) clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(checkInputs, 300)
+ }))
const shutdown = async signal => {
if (shuttingDown) return
@@ -201,6 +218,7 @@ async function runDevelopmentRunner() {
process.stdout.write(`DeepViewer Dev stopping (${signal}).\n`)
if (debounceTimer !== undefined) clearTimeout(debounceTimer)
sourceWatcher.close()
+ for (const watcher of externalWatchers) watcher.close()
await stopElectron(electronChild)
await new Promise(resolvePromise => server.close(resolvePromise))
rmSync(socketPath, { force: true })
diff --git a/apps/deepviewer-desktop/scripts/development-inputs.mjs b/apps/deepviewer-desktop/scripts/development-inputs.mjs
new file mode 100644
index 0000000..323d57e
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/development-inputs.mjs
@@ -0,0 +1,86 @@
+import { createHash } from 'node:crypto'
+import { readFileSync, readdirSync } from 'node:fs'
+import { join, normalize } from 'node:path'
+
+const watchedDirectories = ['src/main', 'assets']
+const watchedFiles = new Set([
+ 'package.json',
+ 'tsconfig.json',
+ 'vite.main.config.ts',
+ 'vite.preload.config.ts',
+ 'vite.renderer.config.ts',
+ 'scripts/sync-deepviewer-branding.mjs',
+ 'scripts/sync-upstream-overrides.mjs',
+ 'scripts/stage-subscriptions.mjs',
+ 'scripts/adapt-subscriptions-plugin.mjs',
+ 'scripts/subscriptions-v4-messages.mjs',
+ 'scripts/prepare-subscriptions-client.mjs',
+ 'scripts/build-native-desktop.mjs',
+ 'scripts/bootstrap-native-upstream.mjs',
+ 'scripts/build-model-capabilities-plugin.mjs',
+ 'scripts/native-app-icon.mjs',
+ 'scripts/development-shell.mjs',
+])
+
+function isTemporaryName(name) {
+ return name.startsWith('.') || name.endsWith('~') || /\.(?:swp|swx|tmp)$/u.test(name)
+}
+
+export function shouldRestartForDevelopmentPath(path) {
+ if (typeof path !== 'string' || path === '') return false
+ const relative = normalize(path).replaceAll('\\', '/')
+ if (relative.split('/').some(isTemporaryName)) return false
+ return watchedFiles.has(relative)
+ || watchedDirectories.some(directory => relative === directory || relative.startsWith(`${directory}/`))
+}
+
+function snapshot(root, directories = watchedDirectories, inputs = watchedFiles) {
+ const files = new Map()
+ const record = relative => {
+ let content = readFileSync(join(root, relative))
+ if (relative === 'package.json') {
+ const manifest = JSON.parse(content.toString('utf8'))
+ delete manifest.buildNumber
+ content = JSON.stringify(manifest)
+ }
+ files.set(relative, createHash('sha256').update(content).digest('hex'))
+ }
+ const visit = relative => {
+ let entries
+ try {
+ entries = readdirSync(join(root, relative), { withFileTypes: true })
+ } catch (error) {
+ if (error.code === 'ENOENT') return
+ throw error
+ }
+ for (const entry of entries) {
+ if (isTemporaryName(entry.name)) continue
+ const child = `${relative}/${entry.name}`
+ if (entry.isDirectory()) visit(child)
+ else if (entry.isFile()) record(child)
+ }
+ }
+ for (const directory of directories) visit(directory)
+ for (const relative of inputs) {
+ try {
+ record(relative)
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error
+ }
+ }
+ return files
+}
+
+// Directory events can represent atomic saves. Rescan inputs instead of dropping
+// those events, while ignoring metadata-only changes and identical rewrites.
+export function createDevelopmentChangeDetector(root, { directories = watchedDirectories, files = watchedFiles } = {}) {
+ let previous = snapshot(root, directories, files)
+ return () => {
+ // An incomplete read must not replace the last valid snapshot.
+ const current = snapshot(root, directories, files)
+ const changed = [...new Set([...previous.keys(), ...current.keys()])]
+ .filter(path => previous.get(path) !== current.get(path)).sort()
+ previous = current
+ return changed
+ }
+}
diff --git a/apps/deepviewer-desktop/scripts/development-shell.mjs b/apps/deepviewer-desktop/scripts/development-shell.mjs
new file mode 100644
index 0000000..696fd9a
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/development-shell.mjs
@@ -0,0 +1,63 @@
+import { execFile } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { constants } from 'node:fs'
+import { copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+import { compileNativeAppIcon } from './native-app-icon.mjs'
+
+const execute = promisify(execFile)
+
+export function developmentShellDirectory(appRoot) {
+ const project = createHash('sha256').update(resolve(appRoot)).digest('hex').slice(0, 16)
+ return join(tmpdir(), 'deepviewer-development-shell', project)
+}
+
+// Give the development process its own native app bundle, without modifying
+// the shared Electron installation.
+export async function prepareDevelopmentShell(electronExecutable, appRoot) {
+ const icon = await compileNativeAppIcon(appRoot)
+ const sourceBundle = dirname(dirname(dirname(electronExecutable)))
+ // Keep generated signed bundles outside cloud-synchronized source folders:
+ // file providers can add Finder metadata after xattr cleanup and break signing.
+ const output = developmentShellDirectory(appRoot)
+ const bundle = join(output, 'DeepViewer Dev.app')
+ const executable = join(bundle, 'Contents', 'MacOS', 'Electron')
+ const manifest = JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8'))
+ const fingerprint = [sourceBundle, manifest.devDependencies.electron, manifest.version,
+ manifest.buildNumber, icon.fingerprint, await readFile(new URL(import.meta.url), 'utf8')].join('\n')
+ const stamp = join(output, 'source.stamp')
+ if (await readFile(stamp, 'utf8').catch(() => '') === fingerprint) {
+ try {
+ await execute('/usr/bin/codesign', ['--verify', '--deep', '--strict', bundle])
+ return executable
+ } catch {
+ // Recreate a modified carrier instead of launching an invalid cached app.
+ }
+ }
+ await mkdir(output, { recursive: true, mode: 0o700 })
+ await rm(bundle, { recursive: true, force: true })
+ await cp(sourceBundle, bundle, { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE })
+ const resources = join(bundle, 'Contents', 'Resources')
+ await copyFile(icon.icns, join(resources, 'DeepViewer.icns'))
+ await cp(icon.dockThemes, join(resources, 'DeepViewerDockThemes'), { recursive: true })
+ const plist = join(bundle, 'Contents', 'Info.plist')
+ for (const [key, value] of Object.entries({
+ CFBundleName: 'DeepViewer Dev', CFBundleDisplayName: 'DeepViewer Dev',
+ CFBundleIdentifier: 'com.deepviewer.desktop.dev',
+ CFBundleIconFile: 'DeepViewer.icns', CFBundleShortVersionString: manifest.version,
+ CFBundleVersion: String(manifest.buildNumber),
+ })) {
+ await execute('/usr/bin/plutil', ['-replace', key, '-string', value, plist])
+ }
+ // The inherited Electron bundle may declare a layered icon; use our ICNS.
+ await execute('/usr/bin/plutil', ['-remove', 'CFBundleIconName', plist]).catch(() => {})
+ // Copied artwork and the Electron carrier can retain Finder/resource-fork
+ // metadata. Remove it only from this generated bundle before ad-hoc signing.
+ await execute('/usr/bin/xattr', ['-cr', bundle])
+ await execute('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', '--timestamp=none', bundle])
+ await execute('/usr/bin/codesign', ['--verify', '--deep', '--strict', bundle])
+ await writeFile(stamp, fingerprint)
+ return executable
+}
diff --git a/apps/deepviewer-desktop/scripts/fixtures/full-sidebar-browser-smoke.cjs b/apps/deepviewer-desktop/scripts/fixtures/full-sidebar-browser-smoke.cjs
index ae21300..bcd11f7 100644
--- a/apps/deepviewer-desktop/scripts/fixtures/full-sidebar-browser-smoke.cjs
+++ b/apps/deepviewer-desktop/scripts/fixtures/full-sidebar-browser-smoke.cjs
@@ -17,7 +17,7 @@ app.whenReady().then(async()=>{
await new Promise(r=>server.listen(0,'127.0.0.1',r));const origin=`http://127.0.0.1:${server.address().port}`
win=new BrowserWindow({show:false,width:1500,height:950,webPreferences:{preload,webviewTag:true,contextIsolation:true,nodeIntegration:false,sandbox:true}})
await win.loadURL(config.origin)
- await until(()=>run(`!!document.querySelector('[data-dsh-better-sidebar]')`),'app mount')
+ await until(()=>run(`!!document.querySelector('[data-deepviewer-sidebar-runtime]')`),'app mount')
await run(`[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='继续')?.click()`)
await until(()=>run(`!![...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='稍后配置')`),'isolated onboarding')
await run(`[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='稍后配置').click()`)
diff --git a/apps/deepviewer-desktop/scripts/fixtures/sidebar-renderer-smoke.cjs b/apps/deepviewer-desktop/scripts/fixtures/sidebar-renderer-smoke.cjs
index 631aa01..8326e85 100644
--- a/apps/deepviewer-desktop/scripts/fixtures/sidebar-renderer-smoke.cjs
+++ b/apps/deepviewer-desktop/scripts/fixtures/sidebar-renderer-smoke.cjs
@@ -1,4 +1,4 @@
-// Isolated, hidden renderer startup check; no user profile or UI interactions.
+// Isolated hidden renderer. DEEPVIEWER_SMOKE_MOUNT_ONLY=1 excludes all UI interactions.
const { app, BrowserWindow, session } = require('electron')
const { readFileSync, writeFileSync } = require('node:fs')
const { dirname, join } = require('node:path')
@@ -16,7 +16,7 @@ app.whenReady().then(async () => {
window.webContents.on('console-message', (_event, level, message) => { if (level >= 3) errors.push(message) })
await window.loadURL(config.origin)
for (let i = 0; i < 100; i++) {
- const mounted = await window.webContents.executeJavaScript("document.querySelector('[data-dsh-better-sidebar]') !== null")
+ const mounted = await window.webContents.executeJavaScript("document.querySelector('[data-deepviewer-sidebar-runtime]') !== null")
if (mounted) {
await new Promise(r => setTimeout(r, 500))
if (errors.length) throw new Error(errors.join('\n'))
@@ -39,6 +39,17 @@ app.whenReady().then(async () => {
setTimeout(() => resolve(false), 5000);
})`)
if (previewReady?.ready !== 'yes' || previewReady?.storage !== 'ok') throw new Error('HTML preview failed: ' + JSON.stringify(previewReady) + ' ' + errors.join('; '))
+ if (process.env.DEEPVIEWER_SMOKE_MOUNT_ONLY === '1') {
+ const bottomMounted = await window.webContents.executeJavaScript(
+ "document.querySelector('[data-dsh-bottom-panel], [data-dsh-bottom-toggle], [data-dsh-panel-host]') !== null",
+ )
+ if (bottomMounted) throw new Error('Removed bottom workbench still mounted')
+ if (errors.length) throw new Error(errors.join('\n'))
+ clearTimeout(timer)
+ console.log('SIDEBAR_RENDERER_MOUNTED (startup only; no UI interaction)')
+ app.exit(0)
+ return
+ }
const settingsOpened = await window.webContents.executeJavaScript(`(() => {
const button = [...document.querySelectorAll('button')].find(b => ['设置', 'Settings'].includes(b.getAttribute('aria-label')));
button?.click(); return !!button;
diff --git a/apps/deepviewer-desktop/scripts/native-app-icon.mjs b/apps/deepviewer-desktop/scripts/native-app-icon.mjs
new file mode 100644
index 0000000..7519a52
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/native-app-icon.mjs
@@ -0,0 +1,36 @@
+import { createHash } from 'node:crypto'
+import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+
+// Preserve DeepViewer's original artwork byte-for-byte. Native packaging and
+// development use the shipped ICNS, without requiring Icon Composer or Xcode.
+export async function compileNativeAppIcon(appRoot) {
+ const sourceIcns = await readFile(join(appRoot, 'assets', 'DeepViewer.icns'))
+ const sourcePng = await readFile(join(appRoot, 'assets', 'deepviewer-icon-macos26-1024.png'))
+ const fingerprint = createHash('sha256')
+ .update(sourceIcns).update(sourcePng).update(await readFile(new URL(import.meta.url)))
+ .digest('hex')
+ const output = join(appRoot, '.desktop', 'native-icon')
+ const stamp = join(output, 'source.sha256')
+ const result = {
+ icns: join(output, 'DeepViewer.icns'),
+ dockThemes: join(output, 'DeepViewerDockThemes'),
+ name: 'DeepViewer',
+ fingerprint,
+ }
+ const light = join(result.dockThemes, 'light.png')
+ const dark = join(result.dockThemes, 'dark.png')
+ const complete = await Promise.all([result.icns, light, dark].map(path => access(path)))
+ .then(() => true, () => false)
+ if (complete && await readFile(stamp, 'utf8').catch(() => '') === fingerprint) return result
+ // Clear stale layered catalogs so a previous Scoder build cannot leak into
+ // the restored DeepViewer bundle.
+ await rm(output, { recursive: true, force: true })
+ await mkdir(result.dockThemes, { recursive: true })
+ await writeFile(result.icns, sourceIcns)
+ // DeepViewer's original Dock icon is the same in both app appearances.
+ await writeFile(light, sourcePng)
+ await writeFile(dark, sourcePng)
+ await writeFile(stamp, fingerprint)
+ return result
+}
diff --git a/apps/deepviewer-desktop/scripts/native-build-metadata.mjs b/apps/deepviewer-desktop/scripts/native-build-metadata.mjs
new file mode 100644
index 0000000..8100d77
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/native-build-metadata.mjs
@@ -0,0 +1,20 @@
+/** Remove build-only source-location comments before materializing public assets. */
+import { globSync, readFileSync, statSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+export function sanitizeNativeBuildMetadata(directory, { projectRoot }) {
+ const privateRoots = [homedir(), resolve(projectRoot)]
+ let changedFiles = 0, removedComments = 0
+ for (const name of globSync('**/*.{js,cjs,mjs}', { cwd: directory, dot: true })) {
+ const path = join(directory, name)
+ if (!statSync(path).isFile()) continue
+ const before = readFileSync(path, 'utf8')
+ const after = before.replace(/^[\t ]*\/\/#(?:end)?region[^\r\n]*$/gmu, line => {
+ if (!privateRoots.some(root => line.includes(root))) return line
+ removedComments += 1
+ return ''
+ })
+ if (after !== before) { writeFileSync(path, after); changedFiles += 1 }
+ }
+ return { changedFiles, removedComments }
+}
diff --git a/apps/deepviewer-desktop/scripts/native-package-closure.mjs b/apps/deepviewer-desktop/scripts/native-package-closure.mjs
new file mode 100644
index 0000000..cf9597c
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/native-package-closure.mjs
@@ -0,0 +1,93 @@
+/** Materialize installed production dependency graphs. No development hoist or external symlink is shipped. */
+import { cpSync, existsSync, globSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, relative, resolve, sep } from 'node:path'
+import { createHash } from 'node:crypto'
+export function locatePackage(name, from) {
+ for (const directory of createRequire(join(from, 'package.json')).resolve.paths(name) ?? []) {
+ const path = join(directory, name, 'package.json')
+ if (existsSync(path)) return realpathSync(dirname(path))
+ }
+ throw new Error(`Production dependency missing: ${name}`)
+}
+function supports(manifest, platform, arch) {
+ const accepts = (values, value) => values === undefined || (!values.includes(`!${value}`) && (!values.some(item => !item.startsWith('!')) || values.includes(value)))
+ return accepts(manifest.os, platform) && accepts(manifest.cpu, arch)
+}
+export function productionGraph(roots, target = { platform: 'darwin', arch: 'arm64' }) {
+ const records = new Map(), rootIds = []
+ const visit = directory => {
+ const id = realpathSync(directory); if (records.has(id)) return id
+ const manifest = JSON.parse(readFileSync(join(id, 'package.json'), 'utf8'))
+ if (!supports(manifest, target.platform, target.arch)) throw new Error(`Wrong target dependency: ${manifest.name}`)
+ const record = { id, manifest, dependencies: [] }; records.set(id, record)
+ const optional = { ...Object.fromEntries(Object.entries(manifest.peerDependenciesMeta ?? {}).filter(([,meta]) => meta.optional).map(([name]) => [name, true])), ...manifest.optionalDependencies }
+ for (const name of new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {})])) {
+ let path
+ try { path = locatePackage(name, id) } catch (error) { if (name in optional) continue; throw error }
+ const dependency = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'))
+ if (!supports(dependency, target.platform, target.arch) && name in optional) continue
+ record.dependencies.push({ name, id: visit(path) })
+ }
+ return id
+ }
+ for (const root of roots) rootIds.push(visit(root))
+ return { records, rootIds, target }
+}
+function selectedFiles(record) {
+ if (!Array.isArray(record.manifest.files)) return undefined // npm-installed payload already passed its package file policy.
+ const roots = globSync(record.manifest.files.filter(pattern => !pattern.startsWith('!')), { cwd: record.id })
+ // npm also includes declared entry points even when `files` omits them. The
+ // installed @img/colour package exercises this rule (index.cjs vs color.cjs).
+ const entryPoints = []
+ const visit = value => {
+ if (typeof value === 'string') entryPoints.push(value.replace(/^\.\//, ''))
+ else if (value && typeof value === 'object') for (const child of Object.values(value)) visit(child)
+ }
+ for (const field of ['main', 'module', 'browser', 'bin', 'exports']) visit(record.manifest[field])
+ for (const path of entryPoints) roots.push(...globSync([path, `${path}.js`, `${path}.cjs`, `${path}.json`], { cwd: record.id }))
+ return new Set(['package.json', ...roots, ...readdirSync(record.id).filter(name => /^(license|licence|copying|notice|readme)(\.|$)/iu.test(name))])
+}
+/** Copy dependency-local version conflicts into nested node_modules; compatible records share the root. */
+export function materializeClosure(graph, output, exclude = () => undefined, upstreamLicense) {
+ rmSync(output, { recursive: true, force: true }); mkdirSync(join(output, 'node_modules'), { recursive: true })
+ const chosen = new Map(), copied = new Set(), inventory = []
+ for (const id of [...graph.rootIds, ...graph.records.keys()]) { const record = graph.records.get(id); if (!chosen.has(record.manifest.name)) chosen.set(record.manifest.name, id) }
+ function copy(id, destination, ancestors = new Map()) {
+ const key = destination; if (copied.has(key)) return; copied.add(key)
+ const record = graph.records.get(id), selected = selectedFiles(record)
+ const include = name => selected === undefined || selected.has(name) || [...selected].some(root => name.startsWith(root + '/') || root.startsWith(name + '/'))
+ mkdirSync(destination, { recursive: true })
+ cpSync(record.id, destination, { recursive: true, dereference: true, filter: source => {
+ const path = relative(record.id, source).split(sep).join('/'); if (!path) return true
+ if (path.split('/').includes('node_modules') || !include(path)) return false
+ const resolved = realpathSync(source), child = relative(record.id, resolved)
+ if (child === '..' || child.startsWith('../')) throw new Error(`Package contains escaping link: ${record.manifest.name}/${path}`)
+ return exclude(`node_modules/${record.manifest.name}/${path}`, graph.target, 'darwin-arm64') === undefined
+ } })
+ const manifest = { ...record.manifest }
+ delete manifest.devDependencies; delete manifest.scripts; delete manifest.private
+ for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) if (manifest[field]) manifest[field] = Object.fromEntries(Object.entries(manifest[field]).map(([name, range]) => [name, typeof range === 'string' && range.startsWith('workspace:') ? graph.records.get(record.dependencies.find(item => item.name === name)?.id)?.manifest.version ?? range : range]))
+ writeFileSync(join(destination, 'package.json'), JSON.stringify(manifest, null, 2)+'\n')
+ if (manifest.name.startsWith('@deepseek-ai/') && upstreamLicense && !existsSync(join(destination, 'LICENSE'))) writeFileSync(join(destination, 'LICENSE'), upstreamLicense)
+ inventory.push({ name: manifest.name, version: manifest.version, license: manifest.license ?? 'SEE PACKAGE', path: relative(output, destination).split(sep).join('/') })
+ const current = new Map(ancestors); current.set(manifest.name, id)
+ for (const dependency of record.dependencies) {
+ if (chosen.get(dependency.name) === dependency.id || current.get(dependency.name) === dependency.id) continue
+ copy(dependency.id, join(destination, 'node_modules', dependency.name), current)
+ }
+ }
+ for (const [name, id] of chosen) copy(id, join(output, 'node_modules', name))
+ writeFileSync(join(output, 'THIRD-PARTY-PACKAGES.json'), JSON.stringify(inventory, null, 2)+'\n')
+ return inventory
+}
+export function auditRuntime(root) {
+ let bytes = 0, files = 0
+ for (const path of globSync('**/*', { cwd: root, dot: true })) {
+ const absolute = join(root, path), info = lstatSync(absolute)
+ if (info.isSymbolicLink()) throw new Error(`Unmaterialized runtime link: ${path}`)
+ if (info.isFile()) { files++; bytes += info.size; if (/\.map$/.test(path) || /(?:^|\/)dsh-better-sidebar(?:\/|$)/.test(path)) throw new Error(`Retired/diagnostic payload: ${path}`) }
+ }
+ const report = { files, bytes, sha256: createHash('sha256').update(readFileSync(join(root, 'THIRD-PARTY-PACKAGES.json'))).digest('hex') }
+ return report
+}
diff --git a/apps/deepviewer-desktop/scripts/native-package-metadata.mjs b/apps/deepviewer-desktop/scripts/native-package-metadata.mjs
new file mode 100644
index 0000000..1048b39
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/native-package-metadata.mjs
@@ -0,0 +1,65 @@
+/** Normalize sealed dependency manifests with the exact Electron builder transformer. */
+import { lstat, readdir, readFile, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+function resolveTransformer() {
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+ const builderRequire = createRequire(join(projectRoot, 'upstream/deepseek-harness/apps/desktop/package.json'))
+ return builderRequire('app-builder-lib/out/fileTransformer.js').createTransformer
+}
+
+/**
+ * Normalize before writeDesktopRuntime seals the final bytes. Builder configuration
+ * must match the subsequent build; the default preserves its metadata defaults.
+ * The injected factory is useful for verifying the real packaging dependency.
+ */
+export async function normalizeNativePackageMetadata(appPath, {
+ runtimeRoot = join(appPath, 'dsh'), configuration = {}, createTransformer = resolveTransformer(),
+} = {}) {
+ const app = resolve(appPath), runtime = resolve(runtimeRoot), child = relative(app, runtime)
+ if (child === '' || isAbsolute(child) || child === '..' || child.startsWith(`..${sep}`)) {
+ throw new Error('native package metadata: runtime must be contained in the staged app')
+ }
+ const rootMetadata = await lstat(runtime)
+ if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
+ throw new Error('native package metadata: runtime must be a materialized directory')
+ }
+ const transform = createTransformer(app, configuration, undefined, null)
+ const manifests = []
+ const visit = async directory => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name)
+ if (entry.isDirectory()) await visit(path)
+ else if (!entry.isFile()) throw new Error('native package metadata: runtime contains an unsupported filesystem entry')
+ else if (entry.name === 'package.json' && path.includes(`${sep}node_modules${sep}`)) manifests.push(path)
+ }
+ }
+ await visit(runtime)
+ manifests.sort()
+ let normalizedManifests = 0
+ for (const path of manifests) {
+ const display = relative(runtime, path).split(sep).join('/')
+ try {
+ const manifest = JSON.parse(await readFile(path, 'utf8'))
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('invalid object')
+ } catch {
+ throw new Error(`native package metadata: invalid dependency manifest ${display}`)
+ }
+ const transformed = await transform(path)
+ if (transformed !== null && typeof transformed !== 'string' && !Buffer.isBuffer(transformed)) {
+ throw new Error(`native package metadata: transformer failed for ${display}`)
+ }
+ if (transformed !== null) {
+ await writeFile(path, transformed)
+ normalizedManifests += 1
+ }
+ // An undefined result may be a swallowed read/parse failure in the builder.
+ // Require its explicit no-change result rather than silently sealing a miss.
+ if (await transform(path) !== null) {
+ throw new Error(`native package metadata: transformer is not idempotent for ${display}`)
+ }
+ }
+ return { packageManifests: manifests.length, normalizedManifests }
+}
diff --git a/apps/deepviewer-desktop/scripts/notarize.mjs b/apps/deepviewer-desktop/scripts/notarize.mjs
index d605965..6cae1c1 100644
--- a/apps/deepviewer-desktop/scripts/notarize.mjs
+++ b/apps/deepviewer-desktop/scripts/notarize.mjs
@@ -16,7 +16,7 @@ if (architectureOption !== undefined && architectureOption !== 'arm64') {
}
const profileOption = process.argv.find(argument => argument.startsWith('--keychain-profile='))
?.slice('--keychain-profile='.length)
-const keychainProfile = profileOption ?? process.env.DEEPVIEWER_NOTARY_PROFILE
+const keychainProfile = profileOption ?? process.env.DEEPVIEWER_NOTARY_PROFILE ?? process.env.DEEPVIEWER_NOTARY_PROFILE
if (typeof keychainProfile !== 'string' || keychainProfile.trim() === '') {
throw new Error('set DEEPVIEWER_NOTARY_PROFILE or --keychain-profile to a notarytool Keychain profile name')
}
diff --git a/apps/deepviewer-desktop/scripts/package-native.mjs b/apps/deepviewer-desktop/scripts/package-native.mjs
new file mode 100644
index 0000000..32ed041
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/package-native.mjs
@@ -0,0 +1,90 @@
+/** Local arm64 packaging and separately authorized signed release preparation. Never publishes implicitly. */
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { createHash } from 'node:crypto'
+import { collectMachOFiles, resolveDeveloperIdApplication, runCapture } from './macos-signing.mjs'
+import { auditNativePackagedApp } from './release-audit.mjs'
+import { createRequire } from 'node:module'
+import { dirname, join, relative, resolve } from 'node:path'
+import { execFileSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { dump } from 'js-yaml'
+import { prepareNativeRuntime } from './prepare-native-runtime.mjs'
+export function updateFeed(channel) {
+ if (!['latest','preview'].includes(channel)) throw new Error('Unsupported update channel')
+ return {provider:'generic',url:`https://github.com/Duoasa/DeepViewer/releases/download/updates-${channel==='latest'?'stable':'preview'}`,channel,updaterCacheDirName:`deepviewer-updater-${channel}`}
+}
+export async function packageNative(args=process.argv.slice(2)) {
+ const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'),root=resolve(appRoot,'../..'),upstream=join(root,'upstream/deepseek-harness')
+ if (process.platform!=='darwin' || process.arch!=='arm64' || args.some(value=>value.startsWith('--arch=') && value!=='--arch=arm64')) throw new Error('Packaging requires macOS arm64')
+ const signed=args.includes('--release'),channel=args.includes('--channel=preview') || args.includes('--preview')?'preview':'latest'
+ if (signed && (!process.env.DEEPVIEWER_SIGN_IDENTITY || !process.env.DEEPVIEWER_TEAM_ID || !process.env.DEEPVIEWER_NOTARY_PROFILE || !process.env.CSC_KEYCHAIN)) throw new Error('Signed releases require DEEPVIEWER_SIGN_IDENTITY, DEEPVIEWER_TEAM_ID, DEEPVIEWER_NOTARY_PROFILE and CSC_KEYCHAIN')
+ const output=resolve(args.find(value=>value.startsWith('--out='))?.slice(6) ?? join(appRoot,'out',signed?channel:'local-preview'))
+ if (output===root || output===appRoot || output==='/') throw new Error('Packaging output must be a dedicated artifact directory')
+ // Public assets always start with fresh generated runtimes and outputs. Locked download archives may be reused.
+ for (const directory of ['.desktop/runtime','.desktop/native-runtime','.desktop/native-cli','.desktop/package-stage','.desktop/build','lib','renderer']) rmSync(join(appRoot,directory),{recursive:true,force:true})
+ rmSync(output,{recursive:true,force:true})
+ execFileSync(process.execPath,[join(appRoot,'scripts/bump-build-number.mjs')],{cwd:appRoot,stdio:'inherit'})
+ execFileSync(process.execPath,[join(appRoot,'scripts/build-native-desktop.mjs'),'--runtime'],{cwd:appRoot,stdio:'inherit',env:{...process.env,DSH_DESKTOP_PRIMARY_RUNTIME_DIR:undefined}})
+ // Materialize signing inputs outside synchronized source folders.
+ const stage=await prepareNativeRuntime(mkdtempSync(join(tmpdir(),'deepviewer-package-stage-')))
+ if (args.includes('--prepare-only')) return stage
+ return packagePreparedNative(stage,{output,signed,channel})
+}
+/** Complete this build's freshly prepared stage after a signing/notary interruption. */
+export async function packagePreparedNative(stage,{output,signed,channel='latest'}) {
+ const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'),root=resolve(appRoot,'../..'),upstream=join(root,'upstream/deepseek-harness')
+ const current=JSON.parse(readFileSync(join(appRoot,'package.json'),'utf8')),prepared=JSON.parse(readFileSync(join(stage,'audit.json'),'utf8'))
+ if(prepared.product!==current.version || prepared.build!==current.buildNumber || prepared.target.platform!=='darwin' || prepared.target.arch!=='arm64') throw new Error('Prepared stage does not match this release build')
+ const desktopRequire=createRequire(join(upstream,'apps/desktop/package.json')), {build,Platform,Arch}=desktopRequire('electron-builder')
+ const product=JSON.parse(readFileSync(join(stage,'app/package.json'),'utf8')), version=channel==='preview'?product.previewRelease:product.version
+ const identity={signingIdentity:process.env.DEEPVIEWER_SIGN_IDENTITY,teamId:process.env.DEEPVIEWER_TEAM_ID}
+ const certificate=signed?await resolveDeveloperIdApplication({requestedIdentity:`Developer ID Application: ${identity.signingIdentity}`,keychain:process.env.CSC_KEYCHAIN}):undefined
+ const {register}=await import(createRequire(join(upstream,'package.json')).resolve('tsx/esm/api'));register()
+ const {writeDesktopRuntime}=await import(join(upstream,'apps/desktop/src/runtime-tree.ts'))
+ const descriptor=JSON.parse(readFileSync(join(stage,'app/dsh/desktop-runtime.json'),'utf8'))
+ if (signed) {
+ const {verifyMacOSRuntimeCode}=await import(join(upstream,'apps/desktop/scripts/verify-macos-signature.mjs'))
+ // A bare person/team qualifier can also match Apple Distribution. Sign with
+ // the resolved Developer ID certificate hash, then verify its public authority.
+ for (const runtime of [join(stage,'app/dsh'),join(stage,'resources/runtime/primary-runtime')]) {
+ const files=await collectMachOFiles(runtime);let next=0
+ const workers=Array.from({length:Math.min(4,files.length)},async()=>{
+ for(;;){const file=files[next++];if(!file)return;const path=relative(runtime,file).split('\\').join('/')
+ const jit=path==='dependencies/node/bin/node' || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-arm64\/bin\/libreoffice-kit$/u.test(path)
+ await runCapture('/usr/bin/codesign',['--force','--sign',certificate.hash,'--keychain',process.env.CSC_KEYCHAIN,'--identifier',`com.deepviewer.desktop.runtime.${createHash('sha256').update(path).digest('hex')}`,'--timestamp','--options','runtime',...(jit?['--entitlements',join(upstream,'apps/desktop/scripts/jit-entitlements.plist')]:[]),file])
+ verifyMacOSRuntimeCode(file,identity)
+ }
+ })
+ const results=await Promise.allSettled(workers),errors=results.filter(result=>result.status==='rejected').map(result=>result.reason)
+ if(errors.length)throw new AggregateError(errors,'DeepViewer runtime signing failed')
+ console.info(`DeepViewer release runtime: signed and verified ${files.length} Mach-O files`)
+ }
+ writeDesktopRuntime(join(stage,'app/dsh'),descriptor.release,descriptor.sharedPackages.map(item=>item.name),{platform:'darwin',arch:'arm64'})
+ }
+ product.version=version;product.deepviewerUpdateChannel=channel;product.deepviewerLocalPreview=!signed;writeFileSync(join(stage,'app/package.json'),JSON.stringify(product,null,2)+'\n');writeFileSync(join(stage,'app/lib/package.json'),JSON.stringify(product)+'\n')
+ const feed=updateFeed(channel)
+ const artifacts=await build({projectDir:join(stage,'app'),targets:Platform.MAC.createTarget(['zip','dmg'],Arch.arm64),publish:'never',config:{
+ appId:signed?'com.deepviewer.desktop':'com.deepviewer.desktop.preview',productName:signed?'DeepViewer':'DeepViewer Preview',buildVersion:String(product.buildNumber),electronVersion:product.devDependencies?.electron ?? '44.0.0',
+ artifactName:`DeepViewer-${version}-arm64${signed?'':'-unsigned'}.\${ext}`,directories:{output},asar:true,
+ electronFuses:{runAsNode:true},
+ // File closure already materialized and audited; the builder must not resolve from the source workspace.
+ beforeBuild:()=>false,files:['lib/**/*','renderer/**/*','package.json','THIRD-PARTY-SHELL.json',{from:join(stage,'app/node_modules'),to:'node_modules',filter:['**/*']},{from:join(stage,'app/dsh'),to:'dsh',filter:['**/*']},{from:join(stage,'app/dsh/node_modules'),to:'dsh/node_modules',filter:['**/*']}],
+ asarUnpack:['**/*.{node,dylib,dll,so,exe}','**/*.so.*','**/spawn-helper','**/@vscode/ripgrep-*/bin/rg','**/node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/**/*'],extraResources:[{from:join(stage,'resources'),to:'.',filter:['**/*']}],
+ beforePack:async context=>{const {officePackageDirectories}=await import(join(upstream,'scripts/libreoffice-packages.mjs'));const packages=await officePackageDirectories(join(stage,'app/dsh'),{platform:'darwin',arch:'arm64'});context.packager.config.asarUnpack.push(...packages.map(directory=>`**/${directory.slice(join(stage,'app/dsh').length+1)}/**/*`))},
+ dmg:{sign:signed},
+ mac:{icon:join(appRoot,'.desktop/native-icon/DeepViewer.icns'),category:'public.app-category.developer-tools',identity:signed?certificate.hash:null,
+ // The default builder signer converts the hash back to a potentially ambiguous
+ // display name. Retain the exact certificate through its standard sign helper.
+ sign:signed?options=>desktopRequire('app-builder-lib/out/codeSign/macCodeSign.js').sign({...options,identity:certificate.hash,identityValidation:true}):undefined,
+ forceCodeSigning:signed,hardenedRuntime:signed,entitlements:join(upstream,'apps/desktop/scripts/macos-entitlements.plist'),entitlementsInherit:join(upstream,'apps/desktop/scripts/macos-entitlements.plist'),signIgnore:['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)','/Contents/Resources/runtime/primary-runtime(?:/|$)','\\.pak$'],notarize:false,extendInfo:{CFBundleLocalizations:['en','zh_CN'],NSMicrophoneUsageDescription:'DeepViewer 使用麦克风进行语音输入。'}},
+ publish:signed?[feed]:null,
+ afterPack:async context=>{const app=join(context.appOutDir,`${context.packager.appInfo.productFilename}.app`);const resources=join(app,'Contents/Resources');execFileSync('/usr/bin/xattr',['-cr',app]);await auditNativePackagedApp({appPath:app,projectRoot:root});if(signed)writeFileSync(join(resources,'app-update.yml'),dump(feed));const {verifyRuntimeArchive}=await import(join(upstream,'apps/desktop/scripts/verify-runtime-archive.ts'));await verifyRuntimeArchive(join(resources,'app.asar'),JSON.parse(readFileSync(join(stage,'app/dsh/desktop-runtime.json'),'utf8')))},
+ afterSign:signed?async context=>{const app=join(context.appOutDir,`${context.packager.appInfo.productFilename}.app`);await desktopRequire('@electron/notarize').notarize({tool:'notarytool',appPath:app,keychainProfile:process.env.DEEPVIEWER_NOTARY_PROFILE});const {verifyMacOSNotarizedApplication}=await import(join(upstream,'apps/desktop/scripts/verify-macos-signature.mjs'));verifyMacOSNotarizedApplication(app,identity)}:undefined,
+ artifactBuildCompleted:signed?async artifact=>{if(!artifact.file.endsWith('.dmg'))return;execFileSync('/usr/bin/xcrun',['notarytool','submit',artifact.file,'--keychain-profile',process.env.DEEPVIEWER_NOTARY_PROFILE,'--wait'],{stdio:'inherit'});execFileSync('/usr/bin/xcrun',['stapler','staple',artifact.file],{stdio:'inherit'});const {verifyMacOSDiskImage}=await import(join(upstream,'apps/desktop/scripts/verify-macos-signature.mjs'));verifyMacOSDiskImage(artifact.file,identity)}:undefined,
+ }})
+ await auditNativePackagedApp({appPath:join(output,'mac-arm64',`${signed?'DeepViewer':'DeepViewer Preview'}.app`),projectRoot:root})
+ if(signed && !artifacts.some(file=>file.endsWith('.zip.blockmap'))) throw new Error('Signed release is missing a ZIP blockmap; do not publish')
+ console.info(JSON.stringify({result:'prepared',version,channel,signed,artifacts,published:false}));return artifacts
+}
+if(process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url)) await packageNative()
diff --git a/apps/deepviewer-desktop/scripts/package.mjs b/apps/deepviewer-desktop/scripts/package.mjs
index 5c1b51a..198400d 100644
--- a/apps/deepviewer-desktop/scripts/package.mjs
+++ b/apps/deepviewer-desktop/scripts/package.mjs
@@ -5,8 +5,9 @@ import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { packager } from '@electron/packager'
-import { verifyRuntimeVersion } from './release-version.mjs'
+import { verifyRuntimeVersion, readRuntimeManifest } from './release-version.mjs'
import { auditPackagedApp, normalizeCopiedRuntimeSymlinks } from './release-audit.mjs'
+import { compileNativeAppIcon } from './native-app-icon.mjs'
import {
createOsxSignOptions,
resolveDeveloperIdApplication,
@@ -18,18 +19,20 @@ import {
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const projectRoot = resolve(appRoot, '..', '..')
const appManifest = JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8'))
+const electronVersion = appManifest.devDependencies.electron
const outputOption = process.argv.find(argument => argument.startsWith('--out='))?.slice('--out='.length)
const outputRoot = outputOption ? resolve(outputOption) : process.argv.includes('--freeze') ? resolve(projectRoot, 'out', appManifest.version) : resolve(projectRoot, 'out')
const releaseStagingRoot = resolve(outputRoot, '.release-staging')
const previewStagingRoot = resolve(outputRoot, '.preview-staging')
-const appIcon = resolve(appRoot, 'assets', 'DeepViewer.icns')
-const rendererAssetPattern = /^[A-Za-z0-9._-]+\.(?:css|js|ttf)$/u
+const nativeIcon = await compileNativeAppIcon(appRoot)
+const appIcon = nativeIcon.icns
+const rendererAssetPattern = /^[A-Za-z0-9._-]+\.(?:css|js|ttf|png)$/u
const fixedApplicationFiles = [
'.desktop/build/main.js',
'.desktop/build/preload.cjs',
'.desktop/renderer/index.html',
- 'assets/DeepViewer.icns',
'assets/deepviewer-icon-macos26-1024.png',
+ 'assets/deepviewer-icon-dark-1024.png',
'assets/licenses/Figtree-OFL.txt',
'assets/licenses/ip-address-LICENSE.txt',
'assets/licenses/ipaddr.js-LICENSE.txt',
@@ -45,18 +48,18 @@ if (!Number.isSafeInteger(appBuildNumber) || appBuildNumber < 1) {
throw new Error(`invalid DeepViewer build number: ${String(appBuildNumber)}`)
}
const appBuildVersion = String(appBuildNumber)
-const expectedHarnessCommit = 'fb2c4b9e698e30edb738bca4cf0618587db7d203'
-const expectedHarnessVersion = '0.1.5-rc.2'
+const expectedHarnessCommit = '477b4f420553e8a52c2fbccc464d7561b239c443'
+const expectedHarnessVersion = '0.1.7-rc.2'
const expectedRuntimePlugins = [
{
name: 'dsh-plugin-subscriptions',
version: '0.3.1',
license: 'MIT',
- adapter: 'deepviewer-remaining-usage-dsh015-v2',
- dshPeerVersion: '0.1.5-rc.2',
+ adapter: 'deepviewer-remaining-usage-dsh017-v1',
+ dshPeerVersion: '0.1.7-rc.2',
},
{ name: 'dsh-better-sidebar', version: '0.19.1', license: 'MIT' },
- { name: '@deepviewer/dsh-plugin-reasoning', version: '0.1.0', license: 'MIT' },
+ { name: '@deepviewer/dsh-plugin-model-capabilities', version: '1.0.1', license: 'MIT' },
]
const shouldSign = process.argv.includes('--sign')
const isPreview = process.argv.includes('--preview')
@@ -72,18 +75,17 @@ if (isPreview && architectureOption !== undefined && architectureOption !== 'arm
const architectures = isPreview
? ['arm64']
: architectureOption === undefined ? ['arm64'] : [architectureOption]
-const signingKeychain = process.env.DEEPVIEWER_CODESIGN_KEYCHAIN
+const signingKeychain = process.env.DEEPVIEWER_CODESIGN_KEYCHAIN ?? process.env.DEEPVIEWER_CODESIGN_KEYCHAIN
const signingIdentity = shouldSign
? await resolveDeveloperIdApplication({
- requestedIdentity: process.env.DEEPVIEWER_CODESIGN_IDENTITY,
+ requestedIdentity: process.env.DEEPVIEWER_CODESIGN_IDENTITY ?? process.env.DEEPVIEWER_CODESIGN_IDENTITY,
keychain: signingKeychain,
})
: undefined
await mkdir(outputRoot, { recursive: true })
-if (!existsSync(appIcon)) throw new Error(`missing macOS app icon: ${appIcon}`)
function cachedElectronZipDirectory(arch) {
- const filename = `electron-v43.4.0-darwin-${arch}.zip`
+ const filename = `electron-v${electronVersion}-darwin-${arch}.zip`
const cacheRoot = join(homedir(), 'Library', 'Caches', 'electron')
if (!existsSync(cacheRoot)) return undefined
for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) {
@@ -145,7 +147,7 @@ for (const arch of architectures) {
`${JSON.stringify(isPreview ? { ...appManifest, productName: packagedName } : appManifest, null, 2)}\n`,
)
await verifyRuntimeVersion(runtimeRoot, appManifest)
- const runtimeManifest = JSON.parse(await readFile(join(runtimeRoot, 'deepviewer-runtime.json'), 'utf8'))
+ const runtimeManifest = await readRuntimeManifest(runtimeRoot)
if (
runtimeManifest.platform !== 'darwin'
|| runtimeManifest.arch !== arch
@@ -171,7 +173,7 @@ for (const arch of architectures) {
overwrite: true,
platform: 'darwin',
arch,
- electronVersion: '43.4.0',
+ electronVersion,
...(electronZipDir === undefined ? {} : { electronZipDir }),
name: packagedName,
executableName: packagedName,
@@ -180,7 +182,7 @@ for (const arch of architectures) {
appVersion,
buildVersion: appBuildVersion,
asar: true,
- extraResource: [runtimeRoot],
+ extraResource: [runtimeRoot, nativeIcon.dockThemes],
afterCopyExtraResources: [async ({ buildPath }) => {
const temporaryAppPath = resolve(buildPath, `${packagedName}.app`)
const copiedRuntimeRoot = resolve(temporaryAppPath, 'Contents', 'Resources', 'harness')
diff --git a/apps/deepviewer-desktop/scripts/prepare-native-runtime.mjs b/apps/deepviewer-desktop/scripts/prepare-native-runtime.mjs
new file mode 100644
index 0000000..9e739ba
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/prepare-native-runtime.mjs
@@ -0,0 +1,56 @@
+import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, relative, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { auditRuntime, locatePackage, materializeClosure, productionGraph } from './native-package-closure.mjs'
+import { auditNativePayload, pruneForeignRuntimePackages } from './audit-native-payload.mjs'
+import { sanitizeNativeBuildMetadata } from './native-build-metadata.mjs'
+import { normalizeNativePackageMetadata } from './native-package-metadata.mjs'
+const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'), root=resolve(appRoot,'../..'), upstream=join(root,'upstream/deepseek-harness')
+export async function prepareNativeRuntime(outputStage = join(appRoot, '.desktop/package-stage')) {
+ const {register}=await import(createRequire(join(upstream,'package.json')).resolve('tsx/esm/api'));register()
+ const {desktopRuntimeFileExclusion}=await import(join(upstream,'apps/desktop/scripts/runtime-file-policy.ts'))
+ const {writeDesktopRuntime,verifyDesktopRuntime}=await import(join(upstream,'apps/desktop/src/runtime-tree.ts'))
+ const stage=resolve(outputStage);rmSync(stage,{recursive:true,force:true});mkdirSync(stage,{recursive:true})
+ const runtime=join(stage,'app/dsh'), devRuntime=join(appRoot,'.desktop/native-runtime')
+ const names=['@deepseek-ai/dsh','@deepseek-ai/dsh-desktop-host','@deepviewer/adapter','@deepviewer/dsh-plugin-model-capabilities','dsh-plugin-subscriptions']
+ const graph=productionGraph(names.map(name=>locatePackage(name,devRuntime)))
+ const inventory=materializeClosure(graph,runtime,desktopRuntimeFileExclusion,readFileSync(join(upstream,'LICENSE'),'utf8'))
+ const dev=JSON.parse(readFileSync(join(devRuntime,'desktop-runtime.json'),'utf8'))
+ writeFileSync(join(runtime,'package.json'),readFileSync(join(devRuntime,'package.json')))
+ writeDesktopRuntime(runtime,dev.release,[...new Set(inventory.filter(item=>item.name.startsWith('@deepseek-ai/') || names.includes(item.name)).map(item=>item.name))],graph.target)
+ await verifyDesktopRuntime(runtime,dev.release.version,graph.target)
+ const shellRoots=['electron-updater','semver','ws','@deepseek-ai/cordis','@deepseek-ai/dsh-api-gateway']
+ const shell=join(stage,'app'),shellGraph=productionGraph(shellRoots.map(name=>locatePackage(name,join(upstream,'apps/desktop'))))
+ // Materialize separately so the shell never depends on a developer's node_modules tree.
+ const shellTemp=join(stage,'shell');materializeClosure(shellGraph,shellTemp,desktopRuntimeFileExclusion,readFileSync(join(upstream,'LICENSE'),'utf8'))
+ cpSync(join(shellTemp,'node_modules'),join(shell,'node_modules'),{recursive:true});cpSync(join(shellTemp,'THIRD-PARTY-PACKAGES.json'),join(shell,'THIRD-PARTY-SHELL.json'));rmSync(shellTemp,{recursive:true,force:true})
+ cpSync(join(appRoot,'lib'),join(shell,'lib'),{recursive:true,filter:path=>!path.endsWith('.map')})
+ cpSync(join(appRoot,'renderer'),join(shell,'renderer'),{recursive:true,filter:path=>!path.endsWith('.map')})
+ cpSync(join(appRoot,'package.json'),join(shell,'package.json'))
+ const product=JSON.parse(readFileSync(join(shell,'package.json'),'utf8'));delete product.devDependencies;delete product.scripts;product.dependencies=Object.fromEntries(shellRoots.map(name=>[name,JSON.parse(readFileSync(join(shell,'node_modules',name,'package.json'),'utf8')).version]));writeFileSync(join(shell,'package.json'),JSON.stringify(product,null,2)+'\n');writeFileSync(join(shell,'lib/package.json'),JSON.stringify(product)+'\n')
+ if (!existsSync(join(appRoot,'.desktop/runtime/primary-runtime/runtime.json'))) throw new Error('Prepare DeepViewer-owned primary runtime before packaging; official app fixtures are forbidden')
+ // pnpm's published payload is bin/dist plus its manifest and notices. Cloud
+ // synchronization may leave unrelated build artifacts and duplicate folders.
+ const pnpmPayload = path => !path || ['bin','dist','package.json','LICENSE','README.md','CHANGELOG.md'].includes(path.split('/')[0]) && !path.endsWith('.map')
+ const primarySource = join(appRoot,'.desktop/runtime')
+ cpSync(primarySource,join(stage,'resources/runtime'),{recursive:true,filter:path=>{
+ const child=relative(primarySource,path).split('\\').join('/')
+ const prefix='primary-runtime/dependencies/pnpm/'
+ return !child.startsWith(prefix) || pnpmPayload(child.slice(prefix.length))
+ }})
+ const pnpmSource = join(root,'node_modules/pnpm')
+ cpSync(pnpmSource,join(stage,'resources/runtime/pnpm'),{recursive:true,filter:path=>pnpmPayload(relative(pnpmSource,path).split('\\').join('/'))})
+ cpSync(join(upstream,'apps/desktop/scripts/node-bin'),join(stage,'resources/runtime/bin'),{recursive:true})
+ cpSync(join(appRoot,'.desktop/native-icon/DeepViewerDockThemes'),join(stage,'resources/DeepViewerDockThemes'),{recursive:true})
+ cpSync(join(root,'apps/deepviewer-adapter/assets/icon-light.png'),join(stage,'resources/icon.png'))
+ const buildMetadata=sanitizeNativeBuildMetadata(stage,{projectRoot:root})
+ // Seal the exact bytes electron-builder will retain in dependency manifests.
+ const packageMetadata=await normalizeNativePackageMetadata(shell)
+ writeDesktopRuntime(runtime,dev.release,[...new Set(inventory.filter(item=>item.name.startsWith('@deepseek-ai/') || names.includes(item.name)).map(item=>item.name))],graph.target)
+ await verifyDesktopRuntime(runtime,dev.release.version,graph.target)
+ const removedForeignPackages=pruneForeignRuntimePackages(join(stage,'resources/runtime')),architecture=auditNativePayload(stage)
+ const report={schemaVersion:1,product:product.version,build:product.buildNumber,kernel:dev.release.version,protocol:dev.release.hostProtocolVersion,target:graph.target,dependencyPackages:inventory.length,buildMetadata,packageMetadata,runtime:auditRuntime(runtime),architecture,removedForeignPackages}
+ writeFileSync(join(stage,'audit.json'),JSON.stringify(report,null,2)+'\n');console.info(JSON.stringify({...report,architecture:{machOBinaries:architecture.machOBinaries},audit:join(stage,'audit.json')}));return stage
+}
+if (process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url)) await prepareNativeRuntime()
diff --git a/apps/deepviewer-desktop/scripts/prepare-subscriptions-client.mjs b/apps/deepviewer-desktop/scripts/prepare-subscriptions-client.mjs
index be7ce6f..9c807be 100644
--- a/apps/deepviewer-desktop/scripts/prepare-subscriptions-client.mjs
+++ b/apps/deepviewer-desktop/scripts/prepare-subscriptions-client.mjs
@@ -25,12 +25,17 @@ export function prepareSubscriptionsClient(target, upstream, source) {
content = content.replace("import { ImageGallery } from '@deepseek-ai/dsh-client-ui-attachment'", "import { ImageGallery } from './gallery/MessageImage.tsx'")
.replace("import type { ImageAttachmentRef, ImageLoader, MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'", "import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'\nimport type { ImageLoader, MessageImageLabels } from './gallery/MessageImage.tsx'")
}
+ content = content.replaceAll('IconSparkle16', 'IconSparkleRegular')
+ .replaceAll(' ({ attachment }))}')
+ .replaceAll('block.call?.argsRaw : block.argsRaw', "block.call?.argsRaw : block.phase === 'start' ? block.argsRaw : undefined")
+ .replace("import type { ClientContext } from '@deepseek-ai/dsh-client-ui-session/client'", "import type { Context as ClientContext } from '@deepseek-ai/cordis'")
+ .replace("import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-session/client'", "import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client'")
writeFileSync(resolve(client, name), adaptSubscriptionsClientSource(name, content))
}
cpSync(imageGenerateStylesPath, resolve(client, 'ImageGenerateToolview.module.css'))
// This is a private source copy of the pinned upstream component, not a new public export.
// Keep the upstream license alongside it; CSS is bundled by DSH's own client preset.
- for (const name of ['MessageImage.tsx', 'MessageImage.module.css', 'ImageLightbox.tsx', 'ImageLightbox.module.css']) {
+ for (const name of ['MessageImage.tsx', 'MessageImage.module.css']) {
cpSync(resolve(upstream, 'packages/client/ui-attachment/src', name), resolve(gallery, basename(name)))
}
cpSync(resolve(upstream, 'LICENSE'), resolve(gallery, 'LICENSE'))
diff --git a/apps/deepviewer-desktop/scripts/publish-native-update.mjs b/apps/deepviewer-desktop/scripts/publish-native-update.mjs
new file mode 100644
index 0000000..a676e97
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/publish-native-update.mjs
@@ -0,0 +1,58 @@
+/** Explicit publication entry. Upload immutable assets first and the mutable channel feed last. */
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { basename, dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { load } from 'js-yaml'
+import { createRequire } from 'node:module'
+import { updateFeed } from './package-native.mjs'
+const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'),upstream=resolve(appRoot,'../../upstream/deepseek-harness'),require=createRequire(join(upstream,'apps/desktop/package.json')), {gt,valid}=require('semver')
+export function validateFeed(directory,channel) {
+ const path=join(directory,`${channel}-mac.yml`),feed=load(readFileSync(path,'utf8'))
+ if(!valid(feed.version) || (channel==='latest' && feed.version.includes('-')))throw new Error('Invalid channel version')
+ const assets=[]
+ for(const file of feed.files ?? []) {
+ if(typeof file.url!=='string' || basename(file.url)!==file.url || !file.url.endsWith('.zip') || file.url.includes('unsigned'))continue
+ const asset=join(directory,file.url);if(!existsSync(asset) || statSync(asset).size!==file.size || createHash('sha512').update(readFileSync(asset)).digest('base64')!==file.sha512)throw new Error('Update ZIP integrity mismatch')
+ const blockmap=asset+'.blockmap';if(!existsSync(blockmap))throw new Error('Missing ZIP blockmap')
+ assets.push(asset,blockmap)
+ }
+ if(assets.length!==2 || basename(assets[0])!==`DeepViewer-${feed.version}-arm64.zip` || feed.path!==basename(assets[0]))throw new Error('Channel feed must reference exactly the qualified arm64 ZIP')
+ return {path,feed,assets}
+}
+export function validateQualification(receipt, feed, channel) {
+ const zip=feed.files.find(file=>file.url===feed.path)
+ if(receipt?.schemaVersion!==1 || receipt.channel!==channel || receipt.version!==feed.version || receipt.zipSha512!==zip?.sha512)throw new Error('Installed-update qualification does not match this channel and ZIP')
+ for(const scenario of ['consecutive','skipped','fullDownload','differentialFallback','busyTaskDeferral','signedInstall','userDataPreserved','relaunch'])if(receipt.scenarios?.[scenario]!==true)throw new Error(`Installed-update qualification missing: ${scenario}`)
+}
+if(process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
+ if(!process.argv.includes('--publish'))throw new Error('Use --publish only after release authorization and signed-update qualification')
+ const channel=process.argv.includes('--channel=preview')?'preview':'latest',directory=resolve(process.argv.find(arg=>arg.startsWith('--from='))?.slice(7) ?? join(appRoot,'out',channel))
+ const {path,feed,assets}=validateFeed(directory,channel),config=updateFeed(channel),tag=channel==='latest'?'updates-stable':'updates-preview'
+ validateQualification(JSON.parse(readFileSync(join(directory,'qualification.json'),'utf8')),feed,channel)
+ // This endpoint is fixed per channel: old blockmaps remain addressable across skipped versions.
+ const previous=await fetch(`${config.url}/${basename(path)}`)
+ if(previous.ok) {const current=load(await previous.text());if(!gt(feed.version,current.version))throw new Error('Public release must increase its semantic version')}
+ else if(previous.status!==404)throw new Error(`Cannot verify existing feed: HTTP ${previous.status}`)
+ const app=join(directory,'mac-arm64','DeepViewer.app');if(!existsSync(app))throw new Error('Signed application is required for publication verification')
+ const {verifyMacOSNotarizedApplication}=await import(join(upstream,'apps/desktop/scripts/verify-macos-signature.mjs'))
+ if(!process.env.DEEPVIEWER_SIGN_IDENTITY || !process.env.DEEPVIEWER_TEAM_ID)throw new Error('Release identity required')
+ verifyMacOSNotarizedApplication(app,{signingIdentity:process.env.DEEPVIEWER_SIGN_IDENTITY,teamId:process.env.DEEPVIEWER_TEAM_ID})
+ // Verify the ZIP users will receive, rather than trusting a separate signed app beside it.
+ const unpacked=mkdtempSync(join(tmpdir(),'deepviewer-update-audit-'))
+ try {
+ execFileSync('/usr/bin/ditto',['-x','-k',assets[0],unpacked])
+ const bundles=readdirSync(unpacked).filter(name=>name.endsWith('.app'));if(bundles.length!==1)throw new Error('Update ZIP must contain one application')
+ const extracted=join(unpacked,bundles[0]),plist=join(extracted,'Contents/Info.plist')
+ const property=key=>execFileSync('/usr/bin/plutil',['-extract',key,'raw','-o','-',plist],{encoding:'utf8'}).trim()
+ if(property('CFBundleShortVersionString')!==feed.version || property('CFBundleIdentifier')!=='com.deepviewer.desktop')throw new Error('Update ZIP product/version mismatch')
+ verifyMacOSNotarizedApplication(extracted,{signingIdentity:process.env.DEEPVIEWER_SIGN_IDENTITY,teamId:process.env.DEEPVIEWER_TEAM_ID})
+ } finally {rmSync(unpacked,{recursive:true,force:true})}
+ // The feed release must already be provisioned intentionally. This script never creates a public release silently.
+ execFileSync('gh',['release','view',tag,'--repo','Duoasa/DeepViewer'],{stdio:'inherit'})
+ execFileSync('gh',['release','upload',tag,...assets,'--repo','Duoasa/DeepViewer'],{stdio:'inherit'})
+ execFileSync('gh',['release','upload',tag,path,'--clobber','--repo','Duoasa/DeepViewer'],{stdio:'inherit'})
+ console.info(JSON.stringify({published:true,channel,version:feed.version,feedLast:true,keptHistoricalBlockmaps:true}))
+}
diff --git a/apps/deepviewer-desktop/scripts/release-audit.mjs b/apps/deepviewer-desktop/scripts/release-audit.mjs
index 01e1a97..8302563 100644
--- a/apps/deepviewer-desktop/scripts/release-audit.mjs
+++ b/apps/deepviewer-desktop/scripts/release-audit.mjs
@@ -1,7 +1,7 @@
-import { readdir, readFile, readlink, realpath, rm, stat, symlink } from 'node:fs/promises'
-import { homedir } from 'node:os'
+import { lstat, open, readdir, readFile, readlink, realpath, rm, stat, symlink } from 'node:fs/promises'
+import { homedir, userInfo } from 'node:os'
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
-import { extractFile, listPackage } from '@electron/asar'
+import { extractFile, listPackage, statFile } from '@electron/asar'
const MAX_TEXT_BYTES = 8 * 1024 * 1024
const TEXT_EXTENSIONS = new Set([
@@ -142,8 +142,8 @@ function isAllowedAsarPath(path) {
'.desktop/renderer/assets',
'.desktop/renderer/index.html',
'assets',
- 'assets/DeepViewer.icns',
'assets/deepviewer-icon-macos26-1024.png',
+ 'assets/deepviewer-icon-dark-1024.png',
'assets/licenses',
'assets/licenses/Figtree-OFL.txt',
'assets/licenses/ip-address-LICENSE.txt',
@@ -153,7 +153,7 @@ function isAllowedAsarPath(path) {
'package.json',
])
return allowedExactPaths.has(path)
- || /^\.desktop\/renderer\/assets\/[A-Za-z0-9._-]+\.(?:css|js|ttf)$/u.test(path)
+ || /^\.desktop\/renderer\/assets\/[A-Za-z0-9._-]+\.(?:css|js|ttf|png)$/u.test(path)
}
function inspectAsar(archivePath, forbiddenRoots, environmentValues, findings) {
@@ -194,3 +194,176 @@ export async function auditPackagedApp({ appPath, projectRoot, expectedAppName =
`Package privacy audit passed: ${relative(projectRoot, resolvedAppPath)} (${asarEntries} allowlisted ASAR entries, no personal paths or credential values)\n`,
)
}
+
+const NATIVE_ASAR_ROOTS = new Set(['lib', 'renderer', 'node_modules', 'dsh', 'package.json', 'THIRD-PARTY-SHELL.json'])
+const NATIVE_TEXT_EXTENSIONS = new Set([...TEXT_EXTENSIONS,
+ '.cfg', '.conf', '.csv', '.h', '.c', '.cpp', '.ini', '.jsx', '.pem', '.plist',
+ '.properties', '.py', '.pyi', '.rb', '.rs', '.sql', '.strings', '.svg', '.tsx', '.zsh',
+])
+const PRIVATE_DIRECTORIES = new Set([
+ '.git', '.hg', '.svn', '.ssh', '.aws', '.codex', '.agents', 'harness-home',
+ 'migration-backups', 'data-migrations', 'local storage', 'session storage', 'indexeddb',
+])
+const PRIVATE_FILES = new Set([
+ '.credentials.yaml', '.credentials.yml', '.credentials.json', '.netrc', '.pypirc',
+ 'cookies', 'cookies-journal', 'cookies-wal', 'cookies-shm', 'login data', 'web data', 'singletonlock', 'singletonsocket', 'singletoncookie',
+ 'deepviewer.log',
+])
+
+function hasNativeSensitivePath(path, { isDirectory = false } = {}) {
+ if (hasSensitivePath(path)) return true
+ const normalized = normalizedPath(path).toLowerCase(), parts = normalized.split('/')
+ const name = parts.at(-1) ?? ''
+ // Undici installs its public cookie implementation in this exact directory.
+ // Only a directory is allowed; a Cookies database at the same path is private.
+ const undiciCookieSourceDirectory = isDirectory && name === 'cookies'
+ && /(?:^|\/)node_modules\/undici\/lib\/web\/cookies$/u.test(normalized)
+ if (parts.some(part => PRIVATE_DIRECTORIES.has(part))
+ || (PRIVATE_FILES.has(name) && !undiciCookieSourceDirectory)) return true
+ if (/^\.deepviewer-(?:layout|profile|desktop-migration|web-state[^/]*|model[^/]*scans)\.json$/u.test(name)) return true
+ if (/^(?:deepviewer-)?model(?:-capability)?-scans\.json$/u.test(name)) return true
+ if (/\.(?:p12|pfx|keychain|keychain-db)$/u.test(name) || /^authkey_[^/]+\.p8$/u.test(name)) return true
+ // The kernel package can contain public session/profile implementation files;
+ // only actual home-shaped roots are private data directories.
+ return /^(?:dsh\/)?(?:sessions|profiles|storages)(?:\/|$)/u.test(normalized)
+ || /^(?:runtime\/(?:primary-runtime\/)?)?(?:sessions|profiles|storages)(?:\/|$)/u.test(normalized)
+ || /^(?:dsh\/|runtime\/(?:primary-runtime\/)?)?settings\.ya?ml$/u.test(normalized)
+}
+
+function nativeSensitiveEnvironmentValues() {
+ return sensitiveEnvironmentValues().filter(({ value }) => {
+ const trimmed = value.trim()
+ return trimmed.length >= 12
+ && !/^(?:undefined|null|false|true|fixture[-_ ]only|test[-_ ](?:key|token|secret)|example|placeholder)$/iu.test(trimmed)
+ && new Set(trimmed).size >= 6
+ })
+}
+
+function nativeTextContent(path, buffer) {
+ if (buffer[0] === 0xff && buffer[1] === 0xfe) return buffer.subarray(2).toString('utf16le')
+ if (buffer[0] === 0xfe && buffer[1] === 0xff) {
+ const bytes = Buffer.from(buffer.subarray(2))
+ if (bytes.length % 2 === 0) return bytes.swap16().toString('utf16le')
+ }
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8192))
+ const textExtension = extname(path) !== '' && NATIVE_TEXT_EXTENSIONS.has(extname(path).toLowerCase())
+ if (!textExtension && sample.includes(0)) return undefined
+ // Sniff extensionless LICENSE/readme files and unknown text formats, while
+ // avoiding scanning native binary payloads as source text.
+ if (!textExtension && sample.some(byte => byte < 0x20 && ![9, 10, 13].includes(byte))) return undefined
+ return buffer.toString('utf8')
+}
+
+/** Audit the final native carrier, including its unpacked code and external runtime. */
+export async function auditNativePackagedApp({ appPath, projectRoot }) {
+ const resourceInput = join(resolve(appPath), 'Contents', 'Resources')
+ let resources
+ try {
+ const metadata = await lstat(resourceInput)
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new Error('invalid resource root')
+ resources = await realpath(resourceInput)
+ } catch {
+ throw new Error('native release privacy audit failed:\n- Resources: invalid packaged resource root')
+ }
+ const archive = join(resources, 'app.asar')
+ const privateRoots = [...new Set([resolve(projectRoot), homedir()])].filter(Boolean)
+ const environmentValues = nativeSensitiveEnvironmentValues()
+ const username = userInfo().username
+ // Generic CI/account names are meaningful only as part of their home path;
+ // scanning the word "root" or "runner" would reject ordinary dependency code.
+ const privateUsername = /^[A-Za-z0-9_.-]{6,}$/u.test(username)
+ && !['runner', 'developer', 'administrator', 'username'].includes(username.toLowerCase()) ? username : undefined
+ const usernamePattern = privateUsername
+ ? new RegExp(`(? {
+ for (const token of [...privateRoots, ...(privateUsername ? [privateUsername] : []), ...environmentValues.map(item => item.value)]) {
+ value = value.replaceAll(token, '[private]')
+ }
+ return value
+ }
+ const finding = (path, reason) => findings.add(`${redact(path)}: ${reason}`)
+ const inspect = (path, buffer) => {
+ const content = nativeTextContent(path, buffer)
+ if (content === undefined) return
+ if (privateRoots.some(root => content.includes(root))) finding(path, 'contains a developer-machine path')
+ if (usernamePattern?.test(content)) {
+ finding(path, 'contains the developer account name')
+ }
+ if (environmentValues.some(({ value }) => content.includes(value)
+ || content.includes(JSON.stringify(value).slice(1, -1)))) finding(path, 'contains a sensitive environment value')
+ // Cryptography dependencies legitimately name PEM delimiters in source;
+ // require an actual multiline encoded key block rather than a label alone.
+ if (/-----BEGIN ((?:[A-Z]+ )?PRIVATE KEY)-----\r?\n(?:[A-Za-z0-9+/=]{16,}\r?\n)+-----END \1-----/u.test(content)) {
+ finding(path, 'contains private key material')
+ }
+ }
+ const inspectPath = (path, options) => {
+ if (hasNativeSensitivePath(path, options)) finding(path, 'sensitive private file or data directory')
+ if (privateRoots.some(root => path.includes(root))) finding(path, 'contains a developer-machine path')
+ if (usernamePattern?.test(path)) finding(path, 'contains the developer account name')
+ if (environmentValues.some(({ value }) => path.includes(value))) finding(path, 'contains a sensitive environment value')
+ }
+ try {
+ const entries = listPackage(archive, { isPack: false })
+ asarEntries = entries.length
+ for (const entry of entries) {
+ const path = normalizedPath(entry)
+ if (!NATIVE_ASAR_ROOTS.has(path.split('/')[0])) finding(`ASAR/${path}`, 'outside the native application allowlist')
+ const metadata = statFile(archive, path, false)
+ inspectPath(path, { isDirectory: 'files' in metadata })
+ if ('files' in metadata) continue
+ if ('link' in metadata) {
+ const target = metadata.link
+ if (isAbsolute(target)) finding(`ASAR/${path}`, 'absolute symbolic link target')
+ if (!isContainedPath('/__native_archive__', resolve('/__native_archive__', target))) {
+ finding(`ASAR/${path}`, 'symbolic link escapes the archive')
+ } else {
+ try { statFile(archive, path, true) } catch { finding(`ASAR/${path}`, 'broken or circular symbolic link') }
+ }
+ continue
+ }
+ // Unpacked payloads are scanned from disk below, rather than following
+ // ASAR metadata to an unchecked filesystem path.
+ if (!metadata.unpacked) inspect(`ASAR/${path}`, extractFile(archive, path, false))
+ }
+ } catch {
+ finding('ASAR', 'cannot inspect the native application archive')
+ }
+ const visit = async directory => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name), display = relative(resources, path).split(sep).join('/')
+ inspectPath(display.replace(/^app\.asar\.unpacked\//u, ''), { isDirectory: entry.isDirectory() })
+ if (entry.isSymbolicLink()) {
+ const target = await readlink(path), resolvedTarget = resolve(dirname(path), target)
+ if (isAbsolute(target)) finding(display, 'absolute symbolic link target')
+ if (!isContainedPath(resources, resolvedTarget)) finding(display, 'symbolic link escapes the packaged resources')
+ else {
+ try {
+ if (!isContainedPath(resources, await realpath(path))) finding(display, 'symbolic link resolves outside the packaged resources')
+ } catch { finding(display, 'broken symbolic link') }
+ }
+ } else if (entry.isDirectory()) await visit(path)
+ else if (entry.isFile()) {
+ resourceFiles += 1
+ if (display === 'app.asar') continue
+ const file = await open(path, 'r')
+ try {
+ const sample = Buffer.alloc(8192), { bytesRead } = await file.read(sample, 0, sample.length, 0)
+ if (nativeTextContent(display, sample.subarray(0, bytesRead)) !== undefined) inspect(display, await file.readFile())
+ } finally { await file.close() }
+ } else finding(display, 'unsupported special file')
+ }
+ }
+ try {
+ if ((await lstat(resources)).isSymbolicLink()) finding('Resources', 'symbolic link resource root')
+ else await visit(resources)
+ } catch {
+ finding('Resources', 'cannot inspect packaged resources')
+ }
+ if (findings.size) throw new Error(`native release privacy audit failed:\n${[...findings].map(value => `- ${value}`).join('\n')}`)
+ const result = { asarEntries, resourceFiles }
+ process.stdout.write(`Native package privacy audit passed (${asarEntries} ASAR entries, ${resourceFiles} resource files)\n`)
+ return result
+}
diff --git a/apps/deepviewer-desktop/scripts/release-version.mjs b/apps/deepviewer-desktop/scripts/release-version.mjs
index c00e383..2172298 100644
--- a/apps/deepviewer-desktop/scripts/release-version.mjs
+++ b/apps/deepviewer-desktop/scripts/release-version.mjs
@@ -1,8 +1,20 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
+export async function readRuntimeManifest(runtimeRoot) {
+ let text
+ try { text = await readFile(join(runtimeRoot, 'deepviewer-runtime.json'), 'utf8') }
+ catch (error) {
+ if (error.code !== 'ENOENT') throw error
+ text = await readFile(join(runtimeRoot, 'deepviewer-runtime.json'), 'utf8')
+ }
+ const runtime = JSON.parse(text)
+ return { ...runtime, deepviewerVersion: runtime.deepviewerVersion ?? runtime.deepviewerVersion,
+ deepviewerBuildNumber: runtime.deepviewerBuildNumber ?? runtime.deepviewerBuildNumber }
+}
+
export async function verifyRuntimeVersion(runtimeRoot, expected) {
- const runtime = JSON.parse(await readFile(join(runtimeRoot, 'deepviewer-runtime.json'), 'utf8'))
+ const runtime = await readRuntimeManifest(runtimeRoot)
const pkg = JSON.parse(await readFile(join(runtimeRoot, 'package.json'), 'utf8'))
const about = await readFile(join(runtimeRoot, 'node_modules/@deepseek-ai/dsh-client-ui-settings-general/lib/client.js'), 'utf8')
const version = about.match(/\bDEEPVIEWER_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]
diff --git a/apps/deepviewer-desktop/scripts/remove-bottom-workbench.mjs b/apps/deepviewer-desktop/scripts/remove-bottom-workbench.mjs
new file mode 100644
index 0000000..74dce09
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/remove-bottom-workbench.mjs
@@ -0,0 +1,204 @@
+import ts from 'typescript'
+
+function replaceOnce(source, before, after) {
+ if (source.split(before).length !== 2) throw new Error(`Bottom-workbench adapter drift: ${before.slice(0, 90)}`)
+ return source.replace(before, after)
+}
+function parse(source) {
+ const file = ts.createSourceFile('sidebar.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
+ if (file.parseDiagnostics.length) throw new Error('Invalid sidebar JavaScript after workbench removal')
+ return file
+}
+function editFunction(source, name, replacement) {
+ const file = parse(source)
+ const matches = []
+ const visit = node => {
+ if (ts.isFunctionDeclaration(node) && node.name?.text === name) matches.push(node)
+ ts.forEachChild(node, visit)
+ }
+ visit(file)
+ if (matches.length !== 1) throw new Error(`Expected one sidebar function ${name}, found ${matches.length}`)
+ const node = matches[0]
+ return source.slice(0, node.getFullStart()) + '\n' + replacement + source.slice(node.end)
+}
+// Remove unreachable factory-local function declarations, including the entire
+// workbench renderer, split reducers, drag handlers and layout measurements.
+// References outside function declarations are roots; this conservative pass
+// leaves all side-effectful variable initializers and shared view code intact.
+function pruneFunctions(source) {
+ const file = parse(source)
+ let factory
+ const find = node => {
+ if (ts.isPropertyAssignment(node) && node.name.getText(file) === 'factory' && ts.isArrowFunction(node.initializer)) factory = node.initializer.body
+ ts.forEachChild(node, find)
+ }
+ find(file)
+ if (!factory || !ts.isBlock(factory)) throw new Error('Sidebar module factory missing')
+ const functions = new Map(factory.statements.filter(ts.isFunctionDeclaration).map(node => [node.name.text, node]))
+ const refs = node => {
+ const names = new Set()
+ const visit = child => {
+ if (ts.isIdentifier(child) && functions.has(child.text)
+ && !(ts.isPropertyAccessExpression(child.parent) && child.parent.name === child)
+ && !(ts.isPropertyAssignment(child.parent) && child.parent.name === child)) names.add(child.text)
+ ts.forEachChild(child, visit)
+ }
+ visit(node)
+ return names
+ }
+ const live = new Set()
+ const queue = factory.statements.filter(node => !ts.isFunctionDeclaration(node)).flatMap(node => [...refs(node)])
+ while (queue.length) {
+ const name = queue.pop()
+ if (live.has(name)) continue
+ live.add(name)
+ queue.push(...refs(functions.get(name).body))
+ }
+ for (const node of [...functions.values()].filter(node => !live.has(node.name.text)).sort((a, b) => b.pos - a.pos)) {
+ source = source.slice(0, node.getFullStart()) + source.slice(node.end)
+ }
+ return source
+}
+
+export function removeBottomWorkbench(source) {
+ source = replaceOnce(source, 'ctx.effect(() => registerBottomToggle(ctx, sidebarStore), "dsh-better-sidebar: bottom-workbench toggle");', '')
+ source = editFunction(source, 'makeDefaultState', `function makeDefaultState() {
+ return { nextTerminal: 1, nextBrowser: 1, expanded: [], revealed: [], agentWaits: {} };
+ }`)
+ source = editFunction(source, 'maxCounterId', 'function maxCounterId() { return 0; }')
+ source = editFunction(source, 'sanitizeState', `function sanitizeState(record) {
+ if (!record || typeof record !== 'object') return;
+ return {
+ ...makeDefaultState(),
+ nextTerminal: Number.isSafeInteger(record.nextTerminal) && record.nextTerminal > 0 ? record.nextTerminal : 1,
+ nextBrowser: Number.isSafeInteger(record.nextBrowser) && record.nextBrowser > 0 ? record.nextBrowser : 1,
+ expanded: Array.isArray(record.expanded) ? record.expanded.filter(path => typeof path === 'string') : [],
+ };
+ }`)
+ source = editFunction(source, 'tabOpenIn', 'function tabOpenIn() { return false; }')
+ // Every open now uses the native right surface, including legacy target hints.
+ source = replaceOnce(source, 'surface !== void 0 && seed.target !== "bottom"', 'surface !== void 0')
+ const fallback = source.indexOf('\n\t\t\t\tconst activeSessionId = store.getSnapshot().sessionId;', source.indexOf('const openTab = (seed, scope) =>'))
+ const close = source.indexOf('\n\t\t\tconst closeTab$1 =', fallback)
+ if (fallback < 0 || close < 0) throw new Error('Sidebar open fallback missing')
+ source = source.slice(0, fallback) + '\n\t\t\t};' + source.slice(close)
+ const closeFallback = source.indexOf('\n\t\t\t\tlet closed;', source.indexOf('const closeTab$1 ='))
+ const snapshot = source.indexOf('\n\t\t\t/** The snapshot', closeFallback)
+ if (closeFallback < 0 || snapshot < 0) throw new Error('Sidebar close fallback missing')
+ source = source.slice(0, closeFallback) + '\n\t\t\t};' + source.slice(snapshot)
+ const updateStart = source.indexOf('const updateTab = (tabId, patch) =>')
+ const openFile = source.indexOf('\n\t\t\t/** Open a file', updateStart)
+ if (updateStart < 0 || openFile < 0) throw new Error('Sidebar tab operations missing')
+ source = source.slice(0, updateStart) + `const updateTab = (tabId, patch) => { surface?.update(tabId, patch); };
+ const activateTab$1 = tabId => { surface?.activate(tabId); };
+` + source.slice(openFile)
+ source = replaceOnce(source, 'const nativeRecords = createNativeTabRecords();', `const nativeRecords = createNativeTabRecords();
+ sidebarStore.tabOpen = (sessionId, tabId) => nativeRecords.values().some(view => view.scope.sessionId === sessionId && (view.tab.id === tabId || view.tab.meta?.agentTabId === tabId));
+ sidebarStore.countTerminals = () => nativeRecords.values().filter(view => view.scope.sessionId === sidebarStore.getSnapshot().sessionId && view.tab.type === 'terminal' && !view.tab.meta?.agentTabId).length;`)
+ source = replaceOnce(source, 'get: (id) => views.get(id),', 'get: (id) => views.get(id),\n values: () => [...views.values()],')
+ source = replaceOnce(source, 'version: SIDEBAR_SERVICE_VERSION,', 'version: SIDEBAR_SERVICE_VERSION,\n countTerminals: () => store.countTerminals?.() ?? 0,')
+ source = editFunction(source, 'uiTerminalCount', '')
+ source = source.replaceAll('uiTerminalCount(state)', '(ctx.get("betterSidebar")?.countTerminals() ?? 0)')
+ source = replaceOnce(source, 'const tabs = allLeaves(snapshot.state.bottomSplits).flatMap((leaf) => leaf.tabs);', 'const tabs = nativeRecords.values().map(view => view.tab);')
+ source = replaceOnce(source, 'store,\n\t\t\t\t\t\ttabId: tab.id', 'store,\n\t\t\t\t\t\ttabId: tab.meta?.agentTabId ?? tab.id')
+ // Host waits and agent requests remain alive independently of any panel.
+ source = editFunction(source, 'reconcileAgentTerminals', `function reconcileAgentTerminals(state, list) {
+ return mirrorAgentWaits(state, list);
+ }`)
+ source = replaceOnce(source, 'if (!Array.isArray(list)) return;', `if (!Array.isArray(list)) return;
+ if (store.getPrefs().tabsEnabled.terminal !== false) {
+ const service = ctx.get('betterSidebar');
+ for (const item of list) if (!seenAgentTerminals.has(item.uuid)) {
+ seenAgentTerminals.add(item.uuid);
+ service?.openTab({ type: 'terminal', title: item.title, meta: { agentTabId: 'agent:' + item.uuid } }, { sessionId });
+ }
+ }`)
+ source = replaceOnce(source, 'const { ctx, store, sessionList, sessionId } = feeds;', 'const { ctx, store, sessionList, sessionId } = feeds;\nconst seenAgentTerminals = (0, react.useMemo)(() => new Set(), [sessionId]);')
+ source = editFunction(source, 'Sidebar', `function DeepViewerSidebarRuntime({ ctx, store }) {
+ const sessionList = (0, react.useSyncExternalStore)(callback => ctx.sessions.list.subscribe(callback), () => ctx.sessions.list.getSnapshot());
+ const sessionId = deepviewerMainSession(sessionList);
+ (0, react.useEffect)(() => { store.setSession(sessionId); }, [sessionId, store]);
+ useHostFeeds({ ctx, store, sessionList, sessionId });
+ return null;
+ }`)
+ const shellStart = source.indexOf('//#region src/client/Sidebar.tsx');
+ const runtimeStart = source.indexOf('function DeepViewerSidebarRuntime(', shellStart);
+ if (shellStart < 0 || runtimeStart < 0) throw new Error('Sidebar shell region missing');
+ source = source.slice(0, shellStart) + source.slice(runtimeStart);
+ const mountStart = source.indexOf('\n\t\t\t\tctx.effect(() => {\n\t\t\t\t\tlet disposed = false;')
+ const mountEndMarker = '}, "dsh-better-sidebar: sidebar mount");'
+ const mountEnd = source.indexOf(mountEndMarker, mountStart)
+ if (mountStart < 0 || mountEnd < 0) throw new Error('Sidebar mount anchor missing')
+ source = source.slice(0, mountStart) + `
+ ctx.effect(() => {
+ let disposed = false, generation = 0, root, host;
+ const unmount = () => { root?.unmount(); root = undefined; host?.remove(); host = undefined; };
+ const sync = async () => {
+ const revision = ++generation;
+ const decision = await Promise.race([loadBootDecision(api), new Promise(resolve => window.setTimeout(() => resolve(null), 2000))]);
+ if (disposed || revision !== generation) return;
+ if (decision) { sidebarStore.setPrefs(decision.prefs); sidebarStore.setSuspended(decision.suspended); }
+ if (decision?.suspended) { unmount(); return; }
+ if (root) return;
+ host = document.createElement('div');
+ host.setAttribute('data-deepviewer-sidebar-runtime', '');
+ document.body.appendChild(host);
+ root = (0, react_dom_client.createRoot)(host);
+ root.render((0, react.createElement)(DeepViewerSidebarRuntime, { ctx, store: sidebarStore }));
+ };
+ void sync();
+ const off = ctx.get('remote')?.$on?.('settings/document-updated', () => { void sync(); });
+ return () => { disposed = true; off?.(); unmount(); };
+ }, 'deepviewer: native sidebar runtime');` + source.slice(mountEnd + mountEndMarker.length)
+ source = source.replace(/^\s*(?:bottomPanelAutoTerminal|collapseBottomPanel|expandBottomPanel|settingsBottomTerminalTitle|settingsBottomTerminalDesc):[^\n]*\n/gmu, '')
+ source = editFunction(source, 'pathTabsOf', `function pathTabsOf(store) {
+ return (store.nativeTabs?.() ?? []).filter(tab => tab.path !== undefined);
+ }`)
+ source = source.replaceAll('pathTabsOf(store.getSnapshot())', 'pathTabsOf(store)')
+ source = replaceOnce(source, 'sidebarStore.tabOpen =', 'sidebarStore.nativeTabs = () => nativeRecords.values().filter(view => view.scope.sessionId === sidebarStore.getSnapshot().sessionId).map(view => view.tab);\n sidebarStore.tabOpen =')
+ const sideStart = source.indexOf('const openFileSide = (absolute) => {')
+ const sideEnd = source.indexOf('\n\t\t\t/** The context menu', sideStart)
+ if (sideStart < 0 || sideEnd < 0) throw new Error('Editor side-open anchor missing')
+ source = source.slice(0, sideStart) + `const openFileSide = absolute => {
+ ctx.get('sidebarRight')?.split?.();
+ openSidebarFile(ctx, store, scope.sessionId, absolute);
+ };` + source.slice(sideEnd)
+ source = replaceOnce(source, 'store.reduce((state) => patchTab(state, tab.id, {\n\t\t\t\t\tpath: nextUrl,\n\t\t\t\t\ttitle: host\n\t\t\t\t}));',
+ 'ctx.get("betterSidebar")?.updateTab(tab.id, { path: nextUrl, title: host });')
+ source = pruneFunctions(source)
+ // Remove the deleted surface's CSS rules and exported class names as well.
+ source = source.replace(/const css\$6 = ("[^\n]*");/u, (_, literal) => {
+ const css = JSON.parse(literal).replace(/([^{}]+)\{([^{}]*)\}/gu, (rule, selectors, body) => {
+ const kept = selectors.split(',').filter(selector => !/nArs4W_bottom|data-dsh-panel-host/u.test(selector));
+ return kept.length ? kept.join(',') + '{' + body + '}' : '';
+ });
+ return `const css$6 = ${JSON.stringify(css)};`;
+ });
+ source = source.replace(/^\s*"bottom[A-Za-z]+": "nArs4W_[^"]+",?\n/gmu, '');
+
+ // Only keep layout rules needed by right-side tab views and menus. The old
+ // stylesheet before the settings marker solely reserved workbench geometry.
+ source = source.replace(/const css = ("[^\n]*");/u, (_, literal) => {
+ let css = JSON.parse(literal)
+ const start = css.indexOf('/* DSH 0.1.x gives external settings')
+ if (start < 0) throw new Error('Sidebar shared layout stylesheet drift')
+ css = css.slice(start).replace(/@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\n\}\n/u, '')
+ return `const css = ${JSON.stringify(css)};`
+ })
+ parse(source)
+ return source
+}
+
+/** Shared CSS is duplicated into lazy chunks by the upstream bundler. */
+export function stripWorkbenchStyles(source) {
+ source = source.replace(/const css(?:\$\d+)? = ("[^\n]*");/gu, (declaration, literal) => {
+ if (!literal.includes('data-dsh-panel-host') && !literal.includes('nArs4W_bottom')) return declaration
+ const css = JSON.parse(literal)
+ const trimmed = css.replace(/([^{}]+)\{([^{}]*)\}/gu, (_rule, selectors, body) => {
+ const kept = selectors.split(',').filter(selector => !/nArs4W_bottom|data-dsh-panel-host/u.test(selector))
+ return kept.length ? kept.join(',') + '{' + body + '}' : ''
+ })
+ return declaration.replace(literal, JSON.stringify(trimmed))
+ })
+ return source.replace(/^\s*"bottom[A-Za-z]+": "nArs4W_[^"]+",?\n/gmu, '')
+}
diff --git a/apps/deepviewer-desktop/scripts/smoke-better-sidebar.mjs b/apps/deepviewer-desktop/scripts/smoke-better-sidebar.mjs
index 131f2f9..aec2c93 100644
--- a/apps/deepviewer-desktop/scripts/smoke-better-sidebar.mjs
+++ b/apps/deepviewer-desktop/scripts/smoke-better-sidebar.mjs
@@ -7,19 +7,61 @@ import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { Script } from 'node:vm'
-import { stageBetterSidebar } from './stage-better-sidebar.mjs'
+import { stageBetterSidebar, validateBetterSidebar } from './stage-better-sidebar.mjs'
const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const upstream = join(project, 'upstream/deepseek-harness')
-const plugin = stageBetterSidebar(upstream)
+const runtimeRoot = process.argv.find(arg => arg.startsWith('--runtime-root='))?.slice('--runtime-root='.length)
+const electronHost = process.argv.includes('--electron-host')
+const electronRequire = createRequire(join(project, 'package.json'))
+if ((process.argv.includes('--host-only') || process.argv.includes('--mount-only')) && process.env.DEEPVIEWER_FULL_BROWSER_SMOKE === '1') throw new Error('Noninteractive smoke cannot enable browser interactions')
+const plugin = runtimeRoot ? join(resolve(runtimeRoot), 'node_modules/dsh-better-sidebar') : stageBetterSidebar(upstream)
+validateBetterSidebar(plugin)
const scratch = mkdtempSync(join(tmpdir(), 'deepviewer-sidebar-smoke-'))
const outsideScratch = mkdtempSync(join(tmpdir(), 'deepviewer-sidebar-outside-'))
const home = join(scratch, 'home')
mkdirSync(join(home, 'profiles/node_modules'), { recursive: true })
symlinkSync(plugin, join(home, 'profiles/node_modules/dsh-better-sidebar'), 'dir')
-const child = spawn(process.execPath, ['--expose-internals', join(upstream, 'apps/cli/lib/bin.js'), 'web', '--patch', join(plugin, 'cordis.patch.yml'), '--port', '0', '--no-open'], {
- cwd: scratch, env: { PATH: process.env.PATH, HOME: scratch, SHELL: '/bin/sh', DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
-})
+// Exercise the desktop's real profile preparation, including the disabled
+// baseline used by fallback launches, rather than CLI-inserting the plugin.
+const ts = electronRequire('typescript')
+const locator = new Script(ts.transpileModule(readFileSync(join(project, 'apps/deepviewer-desktop/src/main/resource-locator.ts'), 'utf8'), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+}).outputText + '\nexports').runInNewContext({ exports: {}, require: electronRequire, process })
+const enabledPatch = locator.prepareBetterSidebarProfile(home)
+const disabledPatch = join(home, 'sidebar-disabled.patch.yml')
+writeFileSync(disabledPatch, '- id: better-sidebar\n disabled: true\n')
+const patches = [disabledPatch, enabledPatch]
+// A temporary, authenticated host probe checks tool registration without a
+// model request, account or UI interaction. It is never staged into the app.
+const probePath = join(scratch, 'sidebar-settings-probe.mjs')
+writeFileSync(probePath, `export const inject = ['webServer', 'connection', 'tools'];
+export function apply(ctx) {
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/deepviewer-smoke/sidebar-tools', handler(req, res) {
+ if (ctx.connection.requestRejection(req) !== undefined) { res.writeHead(403); res.end(); return; }
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ sidebarOpen: ctx.tools.get('sidebar_open') !== undefined }));
+ }}));
+}`)
+const probePatch = join(scratch, 'sidebar-settings-probe.patch.yml')
+writeFileSync(probePatch, `- insert:\n - id: deepviewer-sidebar-smoke-probe\n name: ${JSON.stringify(probePath)}\n`)
+patches.push(probePatch)
+if (runtimeRoot) {
+ for (const name of ['dsh-plugin-subscriptions', '@deepviewer/dsh-plugin-model-capabilities']) {
+ const installed = join(resolve(runtimeRoot), 'node_modules', name)
+ const link = join(home, 'profiles/node_modules', name)
+ mkdirSync(dirname(link), { recursive: true })
+ symlinkSync(installed, link, 'dir')
+ patches.push(join(installed, 'cordis.patch.yml'))
+ }
+}
+const entry = runtimeRoot ? join(resolve(runtimeRoot), 'node_modules/@deepseek-ai/dsh/lib/bin.js') : join(upstream, 'apps/cli/lib/bin.js')
+const hostOptions = {
+ cwd: scratch, env: { PATH: process.env.PATH, HOME: scratch, SHELL: '/bin/sh', DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', ...(electronHost ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }, stdio: ['ignore', 'pipe', 'pipe'],
+}
+const startHost = activePatches => spawn(electronHost ? electronRequire('electron') : process.execPath,
+ ['--expose-internals', entry, 'web', ...activePatches.flatMap(patch => ['--patch', patch]), '--port', '0', '--no-open'], hostOptions)
+let child = startHost(patches)
let output = ''
let terminal
child.stdout.on('data', b => { output += b })
@@ -31,12 +73,43 @@ try {
if (child.exitCode !== null || Date.now() > deadline) throw new Error('Sidebar host did not start: ' + output.replace(/token=\S+/g, 'token=[REDACTED]').slice(-4000))
await new Promise(r => setTimeout(r, 100))
}
- const url = new URL(match[1])
+ let url = new URL(match[1])
+ if (electronHost) console.log('ELECTRON_HOST_READY: actual desktop Node-mode host booted successfully.')
const bootstrap = await fetch(url, { redirect: 'manual' })
assert.equal(bootstrap.status, 303)
const cookie = bootstrap.headers.getSetCookie().map(v => v.split(';')[0]).join('; ')
await bootstrap.body?.cancel()
- const headers = { cookie, origin: url.origin, 'content-type': 'application/json' }
+ let headers = { cookie, origin: url.origin, 'content-type': 'application/json' }
+ const restartHost = async activePatches => {
+ const exited = once(child, 'exit')
+ child.kill('SIGTERM')
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000)
+ await exited
+ clearTimeout(timer)
+ output = ''
+ child = startHost(activePatches)
+ child.stdout.on('data', b => { output += b })
+ child.stderr.on('data', b => { output += b })
+ const deadline = Date.now() + 60000
+ let match
+ while (!(match = output.match(/dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=\S+)/))) {
+ if (child.exitCode !== null || Date.now() > deadline) throw new Error('Sidebar restart failed: ' + output.replace(/token=\S+/g, 'token=[REDACTED]').slice(-2000))
+ await new Promise(r => setTimeout(r, 100))
+ }
+ url = new URL(match[1])
+ const response = await fetch(url, { redirect: 'manual' })
+ assert.equal(response.status, 303)
+ headers = { ...headers, cookie: response.headers.getSetCookie().map(v => v.split(';')[0]).join('; '), origin: url.origin }
+ await response.body?.cancel()
+ }
+ if (runtimeRoot) {
+ const response = await fetch(`${url.origin}/subscriptions-auth/status`, { method: 'POST', headers, body: JSON.stringify({ type: 'client-request', rpcId: 'runtime-status', method: 'status', payload: {} }) })
+ assert.equal(response.status, 200)
+ const status = await response.json()
+ assert.equal(status.result.ok, true)
+ for (const provider of Object.values(status.result.value.providers)) assert.equal(provider.loggedIn, false)
+ console.log('PACKED_RUNTIME_PLUGINS_OK: subscriptions, model capabilities and sidebar booted from the contained Runtime; all providers signed out.')
+ }
for (const name of ['terminal', 'editor', 'mermaid']) {
const response = await fetch(`${url.origin}/sidebar/bundle/${name}.js`, { headers })
assert.equal(response.status, 200, name)
@@ -46,6 +119,39 @@ try {
assert.equal(settings.status, 200)
const body = await settings.json()
assert.equal(body.ok, true)
+ assert.equal(typeof body.value?.value?.agentOpenTools, 'boolean', 'Sidebar settings must expose resolved preferences')
+ assert.equal('bottomPanelAutoTerminal' in body.value.value, false, 'Removed workbench preference must not be served')
+ assert.equal(typeof body.value.revision, 'number')
+ const toolGate = async () => {
+ const response = await fetch(`${url.origin}/deepviewer-smoke/sidebar-tools`, { headers })
+ assert.equal(response.status, 200)
+ return (await response.json()).sidebarOpen
+ }
+ const updatePrefs = (patch, expectedRevision) => fetch(`${url.origin}/sidebar/api/settings.update`, {
+ method: 'POST', headers, body: JSON.stringify({ patch, expectedRevision }),
+ })
+ assert.equal(body.value.value.agentOpenTools, false)
+ assert.equal(await toolGate(), false)
+ const enabled = await updatePrefs({ agentOpenTools: true }, body.value.revision)
+ const enabledBody = await enabled.json()
+ assert.equal(enabled.status, 200, JSON.stringify(enabledBody))
+ assert.equal(enabledBody.ok, true)
+ assert.equal(enabledBody.value.value.agentOpenTools, true)
+ assert.ok(enabledBody.value.revision > body.value.revision)
+ assert.equal(await toolGate(), true, 'Enabling the preference must register sidebar_open live')
+ assert.match(readFileSync(join(home, 'profiles/web/cordis.patch.yml'), 'utf8'), /agentOpenTools: true/, 'Preference must be persisted to the writable profile')
+ const stale = await updatePrefs({ agentOpenTools: false }, body.value.revision)
+ assert.equal(stale.status, 409)
+ assert.equal((await stale.json()).error.code, 'settings-conflict')
+ const invalid = await updatePrefs({ agentOpenTools: 'invalid' }, enabledBody.value.revision)
+ assert.equal(invalid.status, 400)
+ await invalid.body?.cancel()
+ assert.equal(await toolGate(), true, 'Rejected writes must preserve the active tool')
+ const disabled = await updatePrefs({ agentOpenTools: false }, enabledBody.value.revision)
+ assert.equal(disabled.status, 200)
+ assert.equal((await disabled.json()).value.value.agentOpenTools, false)
+ assert.equal(await toolGate(), false, 'Disabling the preference must unregister sidebar_open live')
+ console.log('SIDEBAR_SETTINGS_OK: read/write, live sidebar_open registration/removal, stale revision and invalid-value rejection.')
const denied = await fetch(`${url.origin}/sidebar/api/settings.get`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
assert.ok([401, 403].includes(denied.status))
await denied.body?.cancel()
@@ -113,13 +219,13 @@ try {
const anonymousPreview = await fetch(url.origin + previewUrl)
assert.ok([401, 403].includes(anonymousPreview.status))
await anonymousPreview.body?.cancel()
+ let rendererOutput = ''
+ if (!process.argv.includes('--host-only')) {
const browserConfig = join(scratch, 'renderer-config.json')
writeFileSync(browserConfig, JSON.stringify({ origin: url.origin, cookie, previewUrl, sessionId, imageUrl }), { mode: 0o600 })
- const electronRequire = createRequire(join(project, 'package.json'))
- const rendererEnv = { ...process.env }
+ const rendererEnv = { ...process.env, DEEPVIEWER_SMOKE_MOUNT_ONLY: process.argv.includes('--mount-only') ? '1' : '0' }
delete rendererEnv.ELECTRON_RUN_AS_NODE
const renderer = spawn(electronRequire('electron'), [join(project, process.env.DEEPVIEWER_FULL_BROWSER_SMOKE === '1' ? 'apps/deepviewer-desktop/scripts/fixtures/full-sidebar-browser-smoke.cjs' : 'apps/deepviewer-desktop/scripts/fixtures/sidebar-renderer-smoke.cjs'), browserConfig], { env: rendererEnv, stdio: ['ignore', 'pipe', 'pipe'] })
- let rendererOutput = ''
renderer.stdout.on('data', data => { rendererOutput += data })
renderer.stderr.on('data', data => { rendererOutput += data })
const rendererTimer = setTimeout(() => renderer.kill('SIGKILL'), 35000)
@@ -127,6 +233,7 @@ try {
clearTimeout(rendererTimer)
assert.equal(rendererCode, 0, rendererOutput.slice(-2500))
assert.match(rendererOutput, /SIDEBAR_RENDERER_MOUNTED/)
+ }
if (fullBrowserSmoke) {
assert.match(rendererOutput, /FULL_DSH_BROWSER_SWITCH_OK/)
console.log('FULL_DSH_BROWSER_SWITCH_OK: actual app tab switching preserves guest, unsaved input, scroll and navigation history.')
@@ -140,8 +247,25 @@ try {
terminal.onExit(() => { clearTimeout(timer); resolve() })
})
assert.match(terminalOutput, /DEEPVIEWER_PTY_OK/)
- console.log('Better Sidebar smoke passed: pinned host, authenticated settings, editor/terminal/mermaid chunks, anonymous/cross-origin denial, native PTY, authenticated HTML preview, isolated renderer mount.' +
- (fullBrowserSmoke ? ' Real DSH browser tab lifecycle checked.' : ' HTML relative script/storage checked.'))
+ // Confirm saved preferences survive a new host, then exercise the desktop's
+ // disabled fallback without modifying an account or opening any UI.
+ const beforeRestart = await fetch(`${url.origin}/sidebar/api/settings.get`, { method: 'POST', headers, body: '{}' }).then(r => r.json())
+ const saveForRestart = await updatePrefs({ agentOpenTools: true }, beforeRestart.value.revision)
+ assert.equal(saveForRestart.status, 200)
+ await saveForRestart.body?.cancel()
+ await restartHost(patches)
+ const restored = await fetch(`${url.origin}/sidebar/api/settings.get`, { method: 'POST', headers, body: '{}' }).then(r => r.json())
+ assert.equal(restored.value.value.agentOpenTools, true)
+ assert.equal(await toolGate(), true)
+ await restartHost(patches.filter(patch => patch !== enabledPatch))
+ assert.equal(await toolGate(), false)
+ const fallbackSettings = await fetch(`${url.origin}/sidebar/api/settings.get`, { method: 'POST', headers, body: '{}' })
+ // The web app's fallback router may answer POST with 405 instead of 404.
+ assert.ok([404, 405].includes(fallbackSettings.status))
+ await fallbackSettings.body?.cancel()
+ console.log('SIDEBAR_RESTART_OK: persisted preference and tool restored; disabled fallback boots without sidebar tools or routes.')
+ console.log('Better Sidebar smoke passed: pinned host, authenticated settings, editor/terminal/mermaid chunks, anonymous/cross-origin denial, native PTY and authenticated HTML preview.' +
+ (process.argv.includes('--host-only') ? ' Host-only; no renderer or UI interaction.' : process.argv.includes('--mount-only') ? ' Hidden renderer startup and HTML script/storage checked; no UI interaction.' : fullBrowserSmoke ? ' Real DSH browser tab lifecycle checked.' : ' Isolated renderer and HTML relative script/storage checked.'))
} finally {
try { terminal?.kill() } catch {}
if (child.exitCode === null) {
diff --git a/apps/deepviewer-desktop/scripts/smoke-model-capabilities-host.mjs b/apps/deepviewer-desktop/scripts/smoke-model-capabilities-host.mjs
new file mode 100644
index 0000000..35a1a60
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-model-capabilities-host.mjs
@@ -0,0 +1,14 @@
+import { spawnSync } from 'node:child_process'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { buildModelCapabilitiesPlugin } from './build-model-capabilities-plugin.mjs'
+const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const upstream = join(project, 'upstream/deepseek-harness')
+const plugin = buildModelCapabilitiesPlugin(upstream)
+const result = spawnSync(process.execPath, [join(project, 'apps/dsh-plugin-model-capabilities/scripts/smoke-host.mjs')], {
+ stdio: 'inherit',
+ env: { ...process.env, DSH_ROOT: upstream, MODEL_CAPABILITIES_PLUGIN_ROOT: plugin, SMOKE_LEGACY_SCANS: '1',
+ SMOKE_NATIVE_MODULES: '1', SMOKE_CLI_ROOT: join(project,'apps/deepviewer-desktop/.desktop/native-cli'),
+ SMOKE_EXTRA_PLUGIN_ROOTS: JSON.stringify(['dsh-plugin-subscriptions'].map(name => join(upstream, 'node_modules', name))) },
+})
+if (result.status !== 0) throw new Error('Native model capabilities Host smoke failed')
diff --git a/apps/deepviewer-desktop/scripts/smoke-model-capabilities-plugin.mjs b/apps/deepviewer-desktop/scripts/smoke-model-capabilities-plugin.mjs
new file mode 100644
index 0000000..541bc89
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-model-capabilities-plugin.mjs
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createRequire } from 'node:module'
+import vm from 'node:vm'
+import { modelEdit, validateEfforts } from '../../dsh-plugin-model-capabilities/src/client/model-config.ts'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const upstream = resolve(root, 'upstream/deepseek-harness')
+const require = createRequire(resolve(upstream, 'packages/client/ui-renderer/package.json'))
+const view = { ns: 'llm-pi-ai', revision: 1, secrets: [], user: { providers: { smoke: { models: [{ id: 'one', contextWindow: 4096 }, { id: 'two' }] } } } }
+const original = structuredClone(view)
+const op = modelEdit(view, 'smoke', 'one', 'custom', { off: '', low: 'low', high: 'high' })
+assert.deepEqual(op.value[0], { id: 'one', contextWindow: 4096, reasoningEfforts: { off: null, low: 'low', high: 'high' } })
+assert.deepEqual(view, original)
+assert.deepEqual(op.value[1], original.user.providers.smoke.models[1])
+const edited = structuredClone(view); edited.user.providers.smoke.models = op.value
+assert.deepEqual(modelEdit(edited, 'smoke', 'one', 'inherit', {}).value, original.user.providers.smoke.models)
+assert.equal(validateEfforts('disabled', {}), false)
+assert.throws(() => validateEfforts('custom', {}))
+assert.throws(() => validateEfforts('custom', { high: '' }))
+assert.throws(() => modelEdit({ ...view, secrets: [{ path: ['providers', 'smoke', 'models', '0', 'secret'] }] }, 'smoke', 'one', 'custom', { high: 'high' }))
+assert.throws(() => modelEdit(view, 'smoke', 'missing', 'inherit', {}))
+let bundle
+vm.runInNewContext(readFileSync(resolve(upstream, 'node_modules/@deepviewer/dsh-plugin-model-capabilities/lib/client.js'), 'utf8'), { window: { __ModuleLoader__: { load: value => { bundle = value } } } })
+assert.equal(bundle.id, '@deepviewer/dsh-plugin-model-capabilities')
+const plugin = bundle.factory(require)
+let registration, accepted, request, fail = false
+const mirror = { acceptView: value => { accepted = value } }
+plugin.apply({
+ effect: action => action(),
+ locale: { register: () => () => {}, bind: () => key => key },
+ configForms: { describe: () => mirror },
+ remote: { settings: { describe: async () => ({ ok: true, value: { namespaces: [edited] } }) } },
+ get: name => { assert.equal(name, 'connection'); return { rpc: { call: async (...args) => { request = args; return fail ? { ok: false, error: { message: 'revision conflict' } } : { ok: true, value: { status: 'new' } } } } } },
+ slots: { inject: (slot, action) => { assert.equal(slot, 'settings.models.provider-card'); action() }, register: value => { registration = value } },
+})
+assert.equal(registration.key, 'llm-pi-ai')
+const props = registration.inject()
+await props.write(op, 1, 'one')
+assert.deepEqual(structuredClone(request), ['/dsh-model-capabilities', 'edit', { route: 'smoke', id: 'one', models: op.value, revision: 1 }])
+assert.equal(accepted, edited)
+fail = true
+await assert.rejects(props.write(op, 1, 'one'), /revision conflict/)
+console.log('PASS: model edits, restoration, validation, secret guard, native client registration, revision forwarding and conflict propagation')
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-adapter-contracts.mjs b/apps/deepviewer-desktop/scripts/smoke-native-adapter-contracts.mjs
new file mode 100644
index 0000000..343e1ec
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-adapter-contracts.mjs
@@ -0,0 +1,10 @@
+/** Run pinned adapter contract fixtures without a second package-manager install. */
+import { execFileSync } from 'node:child_process'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const upstream = resolve(dirname(fileURLToPath(import.meta.url)), '../../../upstream/deepseek-harness')
+execFileSync(process.execPath, [join(upstream, 'node_modules/vitest/vitest.mjs'), 'run',
+ 'packages/client/connection/tests/deepviewer-rpc-owner.host.spec.ts',
+ 'packages/client/ui-tool/tests/deepviewer-file-protection-tone.client.spec.tsx',
+ 'packages/client/hmr/tests/deepviewer-client-artifact-metadata.client.spec.ts',
+], { cwd: upstream, stdio: 'inherit' })
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-context-placement.mjs b/apps/deepviewer-desktop/scripts/smoke-native-context-placement.mjs
new file mode 100644
index 0000000..ab67a4f
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-context-placement.mjs
@@ -0,0 +1,20 @@
+/** Reuse the native synthetic composer bench, changing only DeepViewer placement assertions. */
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'upstream/deepseek-harness')
+const source = resolve(root, 'packages/client/ui-conversation/tests/input-bar.client.spec.tsx')
+const fixture = resolve(root, 'packages/client/ui-conversation/tests/deepviewer-layout-smoke.client.spec.tsx')
+let text = readFileSync(source, 'utf8')
+function replace(before, after) {
+ if (!text.includes(before)) throw new Error('Native composer smoke fixture changed')
+ text = text.replace(before, after)
+}
+replace("it('places context usage below the composer and hides it until the activity closes'", "it('places DeepViewer context after model and before activity, retaining activity lifecycle'")
+replace('contextWindow: 128_000 },\n activityEntry:', 'contextWindow: 128_000 }, modelEntry: deepviewer model choice ,\n activityEntry:')
+replace('expect(microphone.compareDocumentPosition(meter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()', "expect(view.getByRole('button', { name: 'deepviewer model choice' }).nextElementSibling?.contains(meter)).toBe(true)\n expect(meter.compareDocumentPosition(microphone) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()")
+try {
+ writeFileSync(fixture, text)
+ execFileSync('pnpm', ['exec', 'vitest', 'run', fixture, '-t', 'places DeepViewer context'], { cwd: root, stdio: 'inherit' })
+} finally { rmSync(fixture, { force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-contract-source.mjs b/apps/deepviewer-desktop/scripts/smoke-native-contract-source.mjs
new file mode 100644
index 0000000..0e55dd6
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-contract-source.mjs
@@ -0,0 +1,15 @@
+/** Apply every pinned contract twice to a fresh upstream checkout, without touching the working kernel. */
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { prepareUpstream, upstreamRoot, harnessCommit } from '../../deepviewer-adapter/scripts/prepare-upstream.mjs'
+import { contracts } from '../../deepviewer-adapter/compatibility/contracts.mjs'
+const fixture = mkdtempSync(join(tmpdir(), 'deepviewer-contracts-')), checkout = join(fixture, 'kernel')
+try {
+ execFileSync('git', ['clone', '--shared', '--no-checkout', upstreamRoot, checkout], { stdio: 'ignore' })
+ execFileSync('git', ['checkout', '--detach', harnessCommit], { cwd: checkout, stdio: 'ignore' })
+ if (contracts.some(contract => /packages\/core\/agent-loop\//u.test(contract.file))) throw new Error('DeepViewer must not patch the Agent loop')
+ prepareUpstream(checkout); prepareUpstream(checkout)
+ console.info(JSON.stringify({ result: 'PASS', commit: harnessCommit, contracts: contracts.length, cleanSource: true, idempotent: true, agentLoopUnchanged: true }))
+} finally { rmSync(fixture, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-feedback-policy.mjs b/apps/deepviewer-desktop/scripts/smoke-native-feedback-policy.mjs
new file mode 100644
index 0000000..01b46c9
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-feedback-policy.mjs
@@ -0,0 +1,17 @@
+/** Synthetic profile only: DeepViewer feedback policy survives legacy overrides. */
+import assert from 'node:assert/strict'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { readProfilePatches, composeEntries } from '../../../upstream/deepseek-harness/packages/boot/app-boot/lib/index.js'
+const home = mkdtempSync(join(tmpdir(), 'deepviewer-feedback-policy-'))
+const ids = ['ui-message-feedback', 'message-feedback', 'command-feedback', 'session-log-deepseek', 'session-telemetry-otel']
+try {
+ const rows = [...ids, 'session-log-export', 'llm'].map(id => ({ id, name: `fixture-${id}` }))
+ const context = { home, overlays: ids.map(id => ({ id, disabled: false })), telemetryDisabledEnv: undefined }
+ const profile = { layers: [{ patches: [{ insert: rows }] }], patches: ids.map(id => ({ id, disabled: false })) }
+ const resolved = composeEntries([readProfilePatches('DeepViewer smoke', context, profile)])
+ for (const id of ids) assert.equal(resolved.find(row => row.id === id)?.disabled, true, id)
+ for (const id of ['session-log-export', 'llm']) assert.notEqual(resolved.find(row => row.id === id)?.disabled, true, id)
+ console.info(JSON.stringify({ result: 'PASS', feedbackDisabled: ids, legacyOverridesBlocked: true, exportAndInferencePreserved: true }))
+} finally { rmSync(home, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-header-placement.mjs b/apps/deepviewer-desktop/scripts/smoke-native-header-placement.mjs
new file mode 100644
index 0000000..7cfea50
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-header-placement.mjs
@@ -0,0 +1,34 @@
+/** Native synthetic session bench checks DeepViewer navigation-row ownership. */
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'upstream/deepseek-harness')
+const source = resolve(root, 'packages/client/ui-conversation/tests/skeleton.client.spec.tsx')
+const fixture = resolve(root, 'packages/client/ui-conversation/tests/deepviewer-header-smoke.client.spec.tsx')
+const tests = `
+it('DeepViewer statistics share the view navigation row outside the tablist', () => {
+ const b = mount(sessionSnapshotOf())
+ const tabs = b.view.getByRole('tablist')
+ const stats = b.view.container.querySelector('[data-deepviewer-session-statistics]')
+ expect(stats).not.toBeNull()
+ expect(stats?.parentElement).toBe(tabs.parentElement)
+ expect(tabs.contains(stats)).toBe(false)
+ expect(stats?.parentElement?.hasAttribute('data-deepviewer-navigation-row')).toBe(true)
+ fireEvent.click(b.view.getByRole('tab', {name: 'Trajectory'}))
+ expect(b.view.getByRole('tab', {name: 'Trajectory'}).getAttribute('aria-selected')).toBe('true')
+})
+it('DeepViewer statistics survive a single view without fabricating tabs', () => {
+ const b = mount(sessionSnapshotOf(), undefined, undefined, {viewTabs: [{id:'chat', label:'Chat'}]})
+ expect(b.view.queryByRole('tablist')).toBeNull()
+ expect(b.view.container.querySelector('[data-deepviewer-session-statistics]')).not.toBeNull()
+})
+it('DeepViewer statistics do not occupy the blank welcome header', () => {
+ const b = mount(sessionSnapshotOf({blank:true}))
+ expect(b.view.container.querySelector('[data-deepviewer-navigation-row]')).toBeNull()
+})
+`
+try {
+ writeFileSync(fixture, readFileSync(source, 'utf8') + tests)
+ execFileSync('pnpm', ['exec', 'vitest', 'run', fixture, '-t', 'DeepViewer statistics'], { cwd: root, stdio: 'inherit' })
+} finally { rmSync(fixture, { force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-host.mjs b/apps/deepviewer-desktop/scripts/smoke-native-host.mjs
new file mode 100644
index 0000000..83f1695
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-host.mjs
@@ -0,0 +1,130 @@
+import { load, dump } from 'js-yaml'
+import assert from 'node:assert/strict'
+import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createRequire } from 'node:module'
+import { createServer } from 'node:http'
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'), root = resolve(appRoot, '../..'), upstream = join(root, 'upstream/deepseek-harness')
+const { register } = await import(createRequire(join(upstream, 'package.json')).resolve('tsx/esm/api')); register()
+const { prepareDesktopProfile } = await import(join(root, 'apps/deepviewer-adapter/migrations/desktop-profile.ts'))
+const { DesktopHostProcess } = await import(join(upstream, 'apps/desktop/src/host-process.ts'))
+const { DesktopProjectManager } = await import(join(upstream, 'apps/desktop/src/project-manager.ts'))
+const { resolveDesktopPaths } = await import(join(upstream, 'apps/desktop/src/paths.ts'))
+const fixture = mkdtempSync(join(tmpdir(), 'deepviewer-070-host-')), home = join(fixture, 'home'), runtime = process.env.DEEPVIEWER_RUNTIME_OVERRIDE ?? join(appRoot, '.desktop/native-runtime')
+const stdout = process.stdout.write.bind(process.stdout)
+process.stdout.write = (chunk, ...args) => stdout(chunk.toString().replace(/([?&]token=)[^&\s]+/gu, '$1[REDACTED]'), ...args)
+let host
+const modelServer = createServer((request, response) => {
+ request.resume()
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ response.end('data: ' + JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', model: 'fixture', choices: [{ index: 0, delta: { role: 'assistant', content: 'Fixture complete.' }, finish_reason: 'stop' }] }) + '\n\ndata: [DONE]\n\n')
+})
+await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve))
+try {
+ process.env.DSH_HOME = home
+ const integrations = ['@deepviewer/adapter', '@deepviewer/dsh-plugin-model-capabilities', 'dsh-plugin-subscriptions'].map(name => { const directory = join(runtime, 'node_modules', name); return { name, directory, version: JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')).version } })
+ prepareDesktopProfile(home, runtime, integrations)
+ const paths = resolveDesktopPaths(home)
+ writeFileSync(join(home, '.credentials.yaml'), JSON.stringify({ version: 1, refs: { SMOKE_KEY: 'fixture-only' } }), { mode: 0o600 })
+ writeFileSync(join(home, 'settings.yaml'), dump({ 'llm-pi-ai': { providers: { fixture: { api: 'openai-completions', baseURL: `http://127.0.0.1:${modelServer.address().port}/v1`, apiKeyEnv: 'SMOKE_KEY', models: [{ id: 'fixture', name: 'Fixture model', contextWindow: 128000, maxTokens: 1024 }] } } } }), { mode: 0o600 })
+ await new DesktopProjectManager(paths, { dsh: runtime }).applyRelease()
+ const patch = load(readFileSync(join(paths.profile, 'cordis.patch.yml'), 'utf8')); writeFileSync(join(paths.profile, 'cordis.patch.yml'), dump([...(patch ?? []), { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }]))
+ const environment = { ...process.env, DSH_HOME: home, DEEPVIEWER_HOST_PORT: '0', DSH_TELEMETRY_MODE: 'DISABLED', HTTP_PROXY: 'http://127.0.0.1:1', HTTPS_PROXY: 'http://127.0.0.1:1' }
+ for (const key of Object.keys(environment)) if (/KEY|TOKEN|SECRET|PASSWORD/u.test(key)) delete environment[key]
+ const createHost = () => new DesktopHostProcess(process.execPath, runtime, paths.profile, undefined, environment, undefined, process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? join(appRoot, '.desktop/runtime/primary-runtime'), { pnpm: join(appRoot, 'node_modules/pnpm/bin/pnpm.mjs'), nodeBin: join(appRoot, 'scripts/node-bin') })
+ host = createHost()
+ const ready = await host.start()
+ assert.equal(new URL(ready.url).hostname, '127.0.0.1')
+ assert.ok(ready.injections?.length)
+ const unauthenticated = await fetch(new URL('/api', ready.url), { redirect: 'manual' })
+ assert.equal(unauthenticated.status, 401)
+ const authenticated = await fetch(ready.url, { redirect: 'manual' })
+ const cookies = authenticated.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
+ const document = await fetch(new URL('/', ready.url), { headers: { cookie: cookies } })
+ assert.equal(document.status, 200)
+ const html = await document.text(); assert.ok(html.includes('__DSH_BOOT__'))
+ for (const integration of integrations) assert.ok(html.includes(integration.name), `Missing active integration: ${integration.name}`); assert.ok(!html.includes('dsh-better-sidebar'))
+ let origin = new URL(ready.url).origin, cookie = cookies
+ async function rpc(method, args, channel = '/api/', expected = true) {
+ const response = await fetch(origin + channel + method, { method: 'POST', headers: { 'content-type': 'application/json', Origin: origin, Cookie: cookie }, body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method, payload: channel === '/api/' ? { args } : args }), signal: AbortSignal.timeout(15000) })
+ const body = await response.text(); assert.equal(response.ok, true, `HTTP ${response.status}: ${method} ${body.slice(0,600)}`)
+ const result = JSON.parse(body); assert.equal(result.result?.ok, expected, JSON.stringify(result)); return expected ? result.result.value : result.result.error
+ }
+ await rpc('status', {}, '/subscriptions-auth/')
+ await rpc('status', { route: 'fixture' }, '/dsh-model-capabilities/')
+ const anonymousMap = await fetch(origin + '/deepviewer-synapse/request', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
+ assert.equal(anonymousMap.status, 401)
+ const bundles = await rpc('pluginManager/listBundles', {})
+ for (const integration of integrations) assert.ok(!bundles.some(bundle => bundle.name === integration.name), `Built-in exposed as a managed bundle: ${integration.name}`)
+ const preparedManifest = JSON.parse(readFileSync(join(paths.profile,'package.json'),'utf8'))
+ for (const integration of integrations) { assert.ok(!preparedManifest.dsh.profile.bundles.includes(integration.name)); assert.ok(!Object.hasOwn(preparedManifest.dependencies ?? {},integration.name)) }
+ const workspace = join(fixture, 'workspace'); mkdirSync(workspace); writeFileSync(join(workspace, 'file.txt'), 'original')
+ const work = await rpc('session/create', { request: { sessionId: 'smoke-work', cwd: workspace } })
+ const chat = await rpc('session/create', { request: { sessionId: 'smoke-chat', agentPreset: 'deepviewer-chat' } })
+ await rpc('session/create', { request: { sessionId: 'bad-chat', agentPreset: 'deepviewer-chat', cwd: workspace } }, '/api/', false)
+ await rpc('session/rename', { request: { sessionId: work.sessionId, title: 'DeepViewer Work cold fixture' } })
+ await rpc('session/rename', { request: { sessionId: chat.sessionId, title: 'DeepViewer Chat cold fixture' } })
+ for (const session of [work, chat]) {
+ await rpc('session/selectModel', { request: { sessionId: session.sessionId, provider: 'fixture', model: 'fixture' } })
+ await rpc('session/prompt', { request: { requestId: crypto.randomUUID(), sessionId: session.sessionId, mode: 'queue', content: [{ type: 'text', text: 'deepviewer-cold-search-fixture' }] } })
+ }
+ for (let i = 0; await host.updateTasks('inspect'); i++) { assert.ok(i < 100, 'Fixture model did not settle'); await new Promise(resolve => setTimeout(resolve,100)) }
+ const file = await rpc('editor.read', { sessionId: work.sessionId, path: 'file.txt' }, '/deepviewer-workspace/')
+ await rpc('editor.save', { sessionId: work.sessionId, path: 'file.txt', text: 'saved', version: file.version }, '/deepviewer-workspace/')
+ assert.equal(readFileSync(join(workspace, 'file.txt'), 'utf8'), 'saved')
+ await rpc('editor.read', { sessionId: chat.sessionId, path: join(workspace, 'file.txt') }, '/deepviewer-workspace/', false)
+ await rpc('editor.read', { sessionId: work.sessionId, path: join(fixture, 'outside') }, '/deepviewer-workspace/', false)
+ await host.stop(true)
+ host = createHost()
+ const restarted = await host.start(); origin = new URL(restarted.url).origin
+ const reauth = await fetch(restarted.url, { redirect: 'manual' }); cookie = reauth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
+ const cold = await rpc('session/list', { _request: {} })
+ const coldWork = cold.items.find(item => item.sessionId === work.sessionId), coldChat = cold.items.find(item => item.sessionId === chat.sessionId)
+ assert.ok(coldWork); assert.ok(coldChat); assert.equal(coldChat.cwd, undefined); assert.equal(coldChat.projections.values.agentPreset, 'deepviewer-chat')
+ assert.deepEqual((await rpc('session/search', { request: { query: 'deepviewer-cold-search-fixture', mode: 'chat' } })).items.map(item => item.sessionId), [chat.sessionId])
+ assert.deepEqual((await rpc('session/search', { request: { query: 'deepviewer-cold-search-fixture', mode: 'work' } })).items.map(item => item.sessionId), [work.sessionId])
+ const projections = await rpc('session/projections', { request: { sessionId: chat.sessionId } })
+ const history = await rpc('session/page', { request: { address: { kind: 'session', sessionId: chat.sessionId }, throughSeq: projections.asOfSeq } })
+ assert.ok(JSON.stringify(history).includes('deepviewer-cold-search-fixture'))
+ const mapList = await rpc('request', { path: '/synapse/api/workspaces' }, '/deepviewer-synapse/')
+ assert.ok(mapList.workspaces.length >= 1)
+ const mapped = []
+ for (const workspace of mapList.workspaces) {
+ const result = await rpc('request', { path: '/synapse/api/workspaces/' + workspace.id }, '/deepviewer-synapse/')
+ mapped.push(...result.workspace.threads)
+ }
+ for (const session of [work, chat]) {
+ const thread = mapped.find(item => item.dshSessionId === session.sessionId)
+ assert.ok(thread, 'Cold session missing from native map')
+ assert.ok(JSON.stringify(thread.messages).includes('deepviewer-cold-search-fixture'))
+ }
+ await rpc('request', { path: '/synapse/api/reset', method: 'POST' }, '/deepviewer-synapse/', false)
+ await rpc('session/rename', { request: { sessionId: chat.sessionId, title: 'Resumed Chat' } })
+ await rpc('editor.read', { sessionId: chat.sessionId, path: join(workspace, 'file.txt') }, '/deepviewer-workspace/', false)
+ await host.stop(true); host = undefined
+ // Official recovery disables user plugins. Application-owned modules stay available.
+ await new DesktopProjectManager(paths, { dsh: runtime }).disableAllPlugins()
+ prepareDesktopProfile(home, runtime, integrations)
+ const recoveredManifest = JSON.parse(readFileSync(join(paths.profile,'package.json'),'utf8'))
+ for (const plugin of integrations) assert.ok(!recoveredManifest.dsh.profile.bundles.includes(plugin.name))
+ host = createHost(); const recovered = await host.start(); origin = new URL(recovered.url).origin
+ const recoveryLogin = await fetch(recovered.url, { redirect: 'manual' }); cookie = recoveryLogin.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
+ await rpc('status', {}, '/subscriptions-auth/')
+ await rpc('status', { route: 'fixture' }, '/dsh-model-capabilities/')
+ const recoveryBundles = await rpc('pluginManager/listBundles', {})
+ for (const integration of integrations) assert.ok(!recoveryBundles.some(bundle => bundle.name === integration.name))
+ await rpc('session/create', { request: { sessionId: 'recovered-core', cwd: workspace } })
+ await host.stop(true); host = undefined
+ // The diagnostic flag wins over a stale explicit enable override and opens pure native core.
+ writeFileSync(join(paths.profile,'cordis.patch.yml'),dump([{id:'model-capabilities',disabled:false}]))
+ environment.DEEPVIEWER_DISABLE_BUILTINS = '1'
+ host = createHost(); const diagnostic = await host.start(); origin = new URL(diagnostic.url).origin
+ const diagnosticLogin = await fetch(diagnostic.url,{redirect:'manual'}); cookie = diagnosticLogin.headers.getSetCookie().map(value=>value.split(';')[0]).join('; ')
+ const diagnosticHtml = await (await fetch(new URL('/',diagnostic.url),{headers:{cookie}})).text()
+ for (const integration of integrations) assert.ok(!diagnosticHtml.includes(integration.name), `Diagnostic flag did not disable native module: ${integration.name}`)
+ await rpc('session/create', { request: { sessionId: 'diagnostic-core', cwd: workspace } })
+ await host.stop(true); host = undefined
+ console.info(JSON.stringify({ result: 'PASS', core: '0.2.0-rc.2', privateHost: true, integrationRPC: true, nativeSynapse: true, nativeModules: true, noManagedBundles: true, workChatIsolation: true, coldHistory: true, editorRPC: true, integrations: integrations.map(plugin => plugin.name), nativeRecovery: true, diagnosticPureCore: true, gracefulShutdown: true }))
+} finally { await host?.stop(); await new Promise(resolve => modelServer.close(resolve)); rmSync(fixture, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-payload.mjs b/apps/deepviewer-desktop/scripts/smoke-native-payload.mjs
new file mode 100644
index 0000000..779cf87
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-payload.mjs
@@ -0,0 +1,43 @@
+/** Exercise the production closure in a temporary ASAR; no app, DMG or user profile is created. */
+import { createRequire } from 'node:module'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'), upstream = resolve(appRoot, '../../upstream/deepseek-harness')
+const require = createRequire(join(appRoot, 'package.json'))
+const { register } = await import(createRequire(join(upstream, 'package.json')).resolve('tsx/esm/api')); register()
+const { smokePreparedRuntime } = await import(join(upstream, 'apps/desktop/scripts/smoke-prepared-runtime.ts'))
+const { officePackageDirectories } = await import(join(upstream, 'scripts/libreoffice-packages.mjs'))
+const stage = resolve(process.argv.slice(2).find(value => value.startsWith('--stage='))?.slice(8) ?? join(appRoot, '.desktop/package-stage'))
+const descriptor = JSON.parse(readFileSync(join(stage, 'app/dsh/desktop-runtime.json'), 'utf8'))
+const fixture = mkdtempSync(join(tmpdir(), 'deepviewer-070-asar-')), archive = join(fixture, 'app.asar')
+// Equivalent native binary and Office unpack policy to package-native.mjs.
+const { createPackageWithOptions } = require('@electron/asar')
+const stdout = process.stdout.write.bind(process.stdout)
+process.stdout.write = (chunk, ...args) => stdout(chunk.toString().replace(/([?&]token=)[^&\s]+/gu, '$1[REDACTED]'), ...args)
+try {
+ const office = await officePackageDirectories(join(stage, 'app/dsh'), { platform: 'darwin', arch: 'arm64' })
+ const unpackDirectories = office.map(path => `dsh/${path.slice(join(stage, 'app/dsh').length + 1)}`)
+ await createPackageWithOptions(join(stage, 'app'), archive, {
+ // asar matches absolute paths; spell out the source prefix so hidden worktree
+ // ancestors do not suppress minimatch's native-file pattern.
+ unpack: `${join(stage, 'app')}/**/{*.node,*.dylib,*.dll,*.so,*.so.*,*.exe,spawn-helper,rg}`,
+ unpackDir: `{${unpackDirectories.join(',')}}`,
+ })
+ // The upstream smoke checks inventory, actual PTY/ripgrep/Koffi/sharp loading,
+ // isolated Host composition and DOCX/XLSX/PPTX conversion, with shipped interpreters.
+ const environment = { ...process.env }, isolated = { ...environment }
+ for (const key of Object.keys(isolated)) if (/KEY|TOKEN|SECRET|PASSWORD/iu.test(key)) delete isolated[key]
+ // DSH_HOME alone does not isolate the Office engine's user-font discovery/cache.
+ // Keep all verification-owned user locations inside the disposable fixture.
+ for (const key of ['HOME','XDG_CACHE_HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','TMPDIR']) {
+ const directory = join(fixture, key.toLowerCase())
+ mkdirSync(directory, { recursive: true, mode: 0o700 }); isolated[key] = directory
+ }
+ delete isolated.FONTCONFIG_PATH; delete isolated.FONTCONFIG_FILE
+ process.env = { ...isolated, DSH_TELEMETRY_MODE: 'DISABLED', DEEPVIEWER_HOST_PORT: '0' }
+ try { await smokePreparedRuntime(join(archive, 'dsh'), require('electron'), join(stage, 'resources/runtime'), descriptor) }
+ finally { process.env = environment }
+ console.info(JSON.stringify({ result: 'PASS', productionASAR: true, inventory: true, nativePayloads: true, officeConversion: true, userProfile: false }))
+} finally { rmSync(fixture, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-renderer.mjs b/apps/deepviewer-desktop/scripts/smoke-native-renderer.mjs
new file mode 100644
index 0000000..f0ffdb6
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-renderer.mjs
@@ -0,0 +1,49 @@
+import { spawn } from 'node:child_process'
+import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve, dirname } from 'node:path'
+import { createRequire } from 'node:module'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'), fixture = mkdtempSync(join(tmpdir(), 'deepviewer-renderer-'))
+const electron = createRequire(join(appRoot, 'package.json'))('electron')
+writeFileSync(join(fixture, 'package.json'), JSON.stringify({ name: 'deepviewer-renderer-smoke', version: '0.7.0', main: 'main.cjs' }))
+const presentationSmoke = readFileSync(new URL('../../deepviewer-adapter/presentation/smoke-renderer.js.txt', import.meta.url), 'utf8')
+writeFileSync(join(fixture, 'main.cjs'), `
+const { app, BrowserWindow, dialog } = require('electron')
+const fs = require('node:fs'), path = require('node:path')
+app.getAppPath = () => ${JSON.stringify(appRoot)}
+app.getVersion = () => '0.7.0'
+BrowserWindow.prototype.show = function() {}
+BrowserWindow.prototype.showInactive = function() {}
+dialog.showErrorBox = (title, error) => { console.error(title, error); process.exitCode = 1; app.exit(1) }
+let finished = false
+function finish(result, error) { if (finished) return; finished = true; console.info(JSON.stringify({result: error ? 'FAIL' : 'PASS', ...result, error})); if (error) app.exit(1); else app.quit() }
+app.on('web-contents-created', (_, contents) => {
+ contents.on('console-message', (_, details) => { if (details.level === 'error') console.error('renderer:', details.message) })
+ contents.on('did-finish-load', async () => {
+ if (!contents.getURL().startsWith('dsh-app://app/')) return
+ const window = BrowserWindow.fromWebContents(contents)
+ const minimum = window?.getMinimumSize()
+ if (!minimum || minimum[0] !== 900 || minimum[1] !== 640) return finish({}, 'DeepViewer minimum window size is not 900x640')
+ for (let i = 0; i < 360 && !finished; i++) {
+ try { const state = await contents.executeJavaScript('({boot:!!document.querySelector("[data-dsh-boot]"), deepviewer:document.body.innerText.includes("DeepViewer"), workChat:document.body.innerText.includes("Work") && document.body.innerText.includes("Chat"), hero:!!document.querySelector("[data-deepviewer-hero]"), failed:document.querySelector("[data-dsh-boot]")?.innerText.includes("Failed"), text:document.body.innerText.slice(0,1500)})')
+ if (state.failed) return finish({}, state.text)
+ if (!state.boot && state.deepviewer && state.workChat) { window.setSize(1400,800); await new Promise(resolve => setTimeout(resolve,100)); const presentation = await contents.executeJavaScript(${JSON.stringify(presentationSmoke)}).catch(error => { throw new Error('Presentation smoke: ' + error.message) }); window.setSize(320, 300); const size = window.getSize(); if (size[0] < 900 || size[1] < 640) return finish({}, 'Window shrank below the safe minimum'); return finish({nativeRenderer:true, minimumWindowSize:[900,640], brand:true, workChat:true, hero:state.hero, ...presentation}) }
+ } catch (error) { if (contents.isDestroyed()) return; if (String(error).includes('Presentation smoke:')) return finish({},String(error)) }
+ await new Promise(resolve => setTimeout(resolve,250))
+ }
+ })
+})
+setTimeout(() => finish({},'Native renderer did not become ready within 90 seconds'),95000).unref()
+import(${JSON.stringify(pathToFileURL(join(appRoot, 'lib/main.js')).href)}).catch(error => finish({},String(error)))
+`)
+const environment = { ...process.env, DEEPVIEWER_DEV_USER_DATA: join(fixture, 'user-data'), DEEPVIEWER_PROFILE: 'development', DSH_TELEMETRY_MODE: 'DISABLED' }
+for (const key of Object.keys(environment)) if (/KEY|TOKEN|SECRET|PASSWORD/u.test(key)) delete environment[key]
+delete environment.ELECTRON_RUN_AS_NODE
+try {
+ const child = spawn(electron, [fixture], { cwd: appRoot, env: environment, stdio: ['ignore', 'pipe', 'pipe'] })
+ const sanitize = chunk => chunk.toString().replace(/([?&]token=)[^&\s]+/gu, '$1[REDACTED]')
+ let output = ''
+ child.stdout.on('data', chunk => { output += sanitize(chunk); process.stdout.write(sanitize(chunk)) }); child.stderr.on('data', chunk => process.stderr.write(sanitize(chunk)))
+ const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve) }); if (code !== 0 || !output.includes('"result":"PASS"')) throw new Error(`Renderer smoke did not pass (exit ${code})`)
+} finally { rmSync(fixture, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-synapse-canvas.mjs b/apps/deepviewer-desktop/scripts/smoke-native-synapse-canvas.mjs
new file mode 100644
index 0000000..8603036
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-synapse-canvas.mjs
@@ -0,0 +1,171 @@
+import { createServer } from 'node:http';
+import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
+import { join, resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { spawn } from 'node:child_process';
+const root=resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const fixture=mkdtempSync('/private/tmp/deepviewer-canvas-');
+const assets=join(root,'apps/deepviewer-adapter/assets/synapse');
+const html=``;
+const server=createServer((req,res)=>{res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'none'");if(req.url==='/')return res.end(html);const name=new URL(req.url,'http://localhost').pathname.replace('/map/','');if(!['index.html','styles.css','app.js'].includes(name)){res.statusCode=404;return res.end();}res.setHeader('Content-Type',name.endsWith('html')?'text/html':name.endsWith('css')?'text/css':'text/javascript');res.end(readFileSync(join(assets,name)));});
+await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const url=`http://127.0.0.1:${server.address().port}`;
+const probe=`(async()=>{const wait=async f=>{for(let i=0;i<200;i++){if(f())return;await new Promise(r=>setTimeout(r,20));}throw new Error('Timed out '+f.toString());};const frame=()=>document.querySelector('iframe'),doc=()=>frame().contentDocument;await wait(()=>doc()?.querySelectorAll('.thread-card').length===3);if([...doc().querySelectorAll('.graph-continue-button,.graph-fold-button,.graph-branch-button,.node-handle')].some(el=>el.getBoundingClientRect().width<32||el.getBoundingClientRect().height<32))throw new Error('Graph controls have undersized hit targets');if(doc().querySelector('.sidebar,.view-switch,form,textarea'))throw new Error('Independent shell/input remains');if(doc().documentElement.lang!=='zh-CN')throw new Error('Locale missing');if(doc().querySelector('.thread-card[data-card-id="t-two:turn:7"] .thread-title').textContent!=='附件消息'||!doc().querySelector('.thread-card[data-card-id="t-two:turn:7"] .thread-answer').textContent.includes('Attachment answer')||doc().querySelector('.thread-card[data-card-id="t-two:turn:5"] .thread-answer').textContent.includes('Attachment answer'))throw new Error('Attachment-only input lost its own turn or Chinese label');if(frame().contentWindow.getComputedStyle(doc().querySelector('.thread-answer')).fontSize!=='18px')throw new Error('Chat font size ignored');presentation.locale='en-US';sendMap('synapse:presentation',presentation);await wait(()=>doc().querySelector('[data-action="show-canvas"]').textContent==='Map');if(doc().querySelector('.thread-card[data-card-id="t-two:turn:7"] .thread-title').textContent!=='Attachment message'||doc().body.textContent.includes('fixture-name.png'))throw new Error('Attachment label not localized or contains a private path');if(frame().contentWindow.getComputedStyle(doc().querySelector('.thread-answer')).color!=='rgb(238, 238, 238)')throw new Error('Theme token ignored');sendMap('synapse:live-reply',{sessionId:'one',running:true,userSeq:99,text:'Wrong future reply'});await new Promise(resolve=>setTimeout(resolve,60));if(doc().querySelector('.thread-card[data-thread="t-one"] .thread-answer').textContent.includes('Wrong future reply'))throw new Error('Future reply attached to old turn');sendMap('synapse:live-reply',{sessionId:'one',running:true,userSeq:1,text:'Live partial answer'});await wait(()=>doc().querySelector('.thread-card[data-thread="t-one"] .thread-answer').textContent.includes('Live partial answer'));sendMap('synapse:live-reply',{sessionId:'one',running:false,userSeq:1,text:''});await wait(()=>doc().querySelector('.thread-card[data-thread="t-one"] .thread-answer').textContent.includes('First answer'));await new Promise(resolve=>setTimeout(resolve,80));doc().querySelector('[data-action="zoom-in"]').click();const beforeSwitchDoc=doc(),beforeSwitchCamera=doc().querySelector('.canvas-content').style.transform;const beforeSwitchCard=doc().querySelector('.thread-card[data-thread="t-two"]');doc().querySelector('.thread-card[data-thread="t-two"] .thread-answer').click();await wait(()=>doc()?.querySelector('.card-inspector[data-inspector-card="t-two:turn:5"]'));if(doc()!==beforeSwitchDoc||doc().querySelector('.thread-card[data-thread="t-two"]')!==beforeSwitchCard||currentId!=='one'||!doc().querySelector('.thread-card[data-card-id="t-two:turn:5"].selected')||doc().querySelector('.canvas-content').style.transform!==beforeSwitchCamera)throw new Error('Browsing must keep the native composer owner and canvas DOM stable');sendMap('synapse:current-session',{session:{id:'one',title:'One'}});await new Promise(r=>setTimeout(r,60));if(doc().querySelector('.thread-card[data-thread="t-two"]')!==beforeSwitchCard||!doc().querySelector('.card-inspector[data-inspector-card="t-two:turn:5"]'))throw new Error('Native pulse stole local inspection');doc().querySelector('.thread-card[data-card-id="t-two:turn:7"] [data-action="show-thread"]').click();if(doc().querySelector('.message-user[data-message-seq="7"] .message-body').textContent!=='Attachment message'||doc().querySelector('.detail-head h1').textContent!=='Attachment message'||doc().body.textContent.includes('fixture-name.png'))throw new Error('Attachment details localization failed');doc().querySelector('[data-action="show-canvas"]').click();const beforeReturnDoc=doc();doc().querySelector('.thread-card[data-thread="t-one"] .thread-answer').click();await wait(()=>doc()?.querySelector('.card-inspector[data-inspector-card="t-one:turn:1"]'));if(doc()!==beforeReturnDoc)throw new Error('Local inspection remounted frame');doc().querySelectorAll('[data-action="toggle-compare"]')[0].click();doc().querySelectorAll('[data-action="toggle-compare"]')[1].click();doc().querySelector('[data-action="show-compare"]').click();if(doc().querySelectorAll('.compare-columns article').length!==2)throw new Error('Compare unavailable');doc().querySelector('[data-action="show-thread"]').click();if(!doc().querySelector('.detail-scroll')||doc().querySelector('form,textarea'))throw new Error('Details did not use native composer');doc().querySelector('[data-action="open-branch"]').click();await wait(()=>composeCalls.length===1);if(!composeCalls[0].fork||composeCalls[0].sessionId!=='one'||composeCalls[0].atSeq!==2||sendCalls.length!==0)throw new Error('Branch must prepare native draft at the reply sequence');doc().querySelector('[data-action="show-canvas"]').click();doc().querySelector('[data-action="open-continue"]').click();await wait(()=>composeCalls.length===2);if(composeCalls[1].fork||composeCalls[1].text!==undefined||sendCalls.length)throw new Error('Followup must prepare native composer');doc().querySelector('[data-action="zoom-in"]').click();const zoom=doc().querySelector('.canvas-controls span').textContent;doc().querySelector('[data-action="show-thread"]').click();const previousDoc=doc();frame().src='/map/index.html?reload=1';await wait(()=>doc()!==previousDoc&&doc()?.querySelector('.detail-scroll'));doc().querySelector('[data-action="show-canvas"]').click();if(doc().querySelector('.canvas-controls span').textContent!==zoom)throw new Error('Camera/zoom did not survive tab remount');const saved=JSON.parse(frame().contentWindow.sessionStorage.getItem('deepviewer:synapse:view:v1:one'));if(JSON.stringify(saved).includes('First question')||JSON.stringify(saved).includes('First answer'))throw new Error('Private content persisted in UI state');return {attachmentOnlyTurn:true,attachmentLocale:true,liveReplyTurnBinding:true,graphHitTargets:true,standaloneShellRemoved:true,nativeDraftOnly:true,compare:true,locale:true,font:true,tokens:true,viewRestored:true,crossSessionBrowseStable:true};})()`;
+writeFileSync(join(fixture,'package.json'),JSON.stringify({main:'main.cjs'}));
+const geometryProbe="(()=>{const d=document.querySelector('iframe').contentDocument;const toolbar=d.querySelector('.map-toolbar'),tabs=d.querySelector('.canvas-tabs'),controls=d.querySelector('.canvas-controls');const a=tabs.getBoundingClientRect(),b=controls.getBoundingClientRect(),r=toolbar.getBoundingClientRect();if(a.right>b.left&&a.bottom>b.top&&b.bottom>a.top)throw new Error('Toolbar overlaps');if([...toolbar.querySelectorAll('button')].some(el=>el.getBoundingClientRect().right>r.right+1))throw new Error('Narrow toolbar overflows');if(Math.abs(d.querySelector('.synapse-shell').getBoundingClientRect().height-document.querySelector('iframe').clientHeight)>1)throw new Error('Map did not fill iframe');return {narrowToolbar:true,iframeHeight:true};})()";
+const pointerFixture = async win => {
+ const evaluate = fn => win.webContents.executeJavaScript(`(${fn.toString()})()`);
+ const waitFor = async fn => { for (let i=0;i<200;i++) { if(await evaluate(fn))return; await new Promise(r=>setTimeout(r,20)); } throw new Error('Pointer fixture timed out: '+fn); };
+ const click = async (selector, duringPress) => {
+ const point = await win.webContents.executeJavaScript(`(()=>{const f=document.querySelector('iframe'),d=f.contentDocument,b=d.querySelector(${JSON.stringify(selector)});if(!b)throw new Error('Missing real click target');const r=b.getBoundingClientRect(),o=f.getBoundingClientRect(),x=r.left+r.width/2,y=r.top+r.height/2;if(!b.contains(d.elementFromPoint(x,y)))throw new Error('Real click target is covered: '+${JSON.stringify(selector)});return {x:Math.round(x+o.left),y:Math.round(y+o.top)};})()`);
+ win.webContents.sendInputEvent({type:'mouseMove',...point});
+ await new Promise(r=>setTimeout(r,20));
+ // Chromium can discard the first input while the subframe's initial hit
+ // test data is arriving; retry only a press that never reached the DOM.
+ let delivered=false;
+ for(let i=0;i<3;i++){
+ const count=await evaluate(()=>mouseDownCount);
+ win.webContents.sendInputEvent({type:'mouseDown',...point,button:'left',clickCount:1});
+ await new Promise(r=>setTimeout(r,35));
+ if(await evaluate(()=>mouseDownCount)>count){delivered=true;break;}
+ win.webContents.sendInputEvent({type:'mouseUp',...point,button:'left',clickCount:1});
+ await new Promise(r=>setTimeout(r,100));
+ // A release can reach the subframe even when its initial press did not.
+ // Exclude that priming attempt; assert the delivered press/up/click chain.
+ await evaluate(()=>{pointerTrace.length=0;});
+ }
+ if(!delivered)throw new Error('Trusted mouseDown did not reach iframe');
+ if(duringPress)await duringPress();
+ win.webContents.sendInputEvent({type:'mouseUp',...point,button:'left',clickCount:1});
+ await new Promise(r=>setTimeout(r,60));
+ };
+ await waitFor(()=>document.querySelector('iframe').contentDocument?.querySelectorAll('.thread-card').length===3);
+ win.webContents.focus();
+ // Prime Chromium's initial subframe hit-test routing away from any action.
+ win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1});
+ win.webContents.sendInputEvent({type:'mouseDown',x:1,y:1,button:'left',clickCount:1});
+ win.webContents.sendInputEvent({type:'mouseUp',x:1,y:1,button:'left',clickCount:1});
+ await new Promise(r=>setTimeout(r,350));
+ await evaluate(()=>{const d=document.querySelector('iframe').contentDocument;window.pointerTrace=[];window.mouseDownCount=0;d.addEventListener('pointerdown',()=>mouseDownCount++,true);for(const type of ['pointerdown','pointerup','click'])d.addEventListener(type,event=>{const action=event.target.closest('[data-action]')?.dataset.action;if(action==='show-card-actions')pointerTrace.push({type:event.type,trusted:event.isTrusted});},true);window.pressedPlus=d.querySelector('.graph-continue-button');});
+ await click('.thread-card[data-thread="t-one"] .graph-continue-button',async()=>{
+ await evaluate(()=>{presentation.fontSize=19;sendMap('synapse:presentation',presentation);sendMap('synapse:current-session',{session:{id:'one',title:'One'}});});
+ await new Promise(r=>setTimeout(r,35));
+ if(!await evaluate(()=>pressedPlus.isConnected))throw new Error('Subscription replaced pressed button before click: '+JSON.stringify(await evaluate(()=>pointerTrace)));
+ });
+ await waitFor(()=>!!document.querySelector('iframe').contentDocument?.querySelector('.card-action-menu'));
+ const chain=await evaluate(()=>pointerTrace);
+ if(chain.map(e=>e.type).join(',')!=='pointerdown,pointerup,click'||chain.some(e=>!e.trusted))throw new Error('Missing real pointer click chain: '+JSON.stringify(chain));
+ if(!await evaluate(()=>composeCalls.length===0))throw new Error('Plus must show choices before preparing native draft');
+ await evaluate(()=>{delayCompose=250;});
+ await click('.card-action-menu [data-action="open-continue"]');
+ await evaluate(()=>{const d=document.querySelector('iframe').contentDocument;const b=d.querySelector('.card-action-menu [data-action="open-continue"]');if(!b?.disabled)throw new Error('Pending compose permits duplicate click');b.click();});
+ await waitFor(()=>composeCalls.length===1&&!document.querySelector('iframe').contentDocument.querySelector('.card-action-menu'));
+ if(!await evaluate(()=>composeCalls[0].sessionId==='one'&&!composeCalls[0].fork&&composeCalls[0].text===undefined&&sendCalls.length===0))throw new Error('Continue did not focus native draft without clearing it');
+ await evaluate(()=>{delayCompose=0;presentation.fontSize=18;sendMap('synapse:presentation',presentation);});
+ await new Promise(r=>setTimeout(r,40));
+ await click('.thread-card[data-thread="t-one"] .graph-continue-button');
+ await click('.card-action-menu [data-action="open-branch"]');
+ await waitFor(()=>composeCalls.length===2);
+ if(!await evaluate(()=>composeCalls[1].sessionId==='one'&&composeCalls[1].fork&&composeCalls[1].atSeq===2&&composeCalls[1].text===undefined&&sendCalls.length===0))throw new Error('Branch did not route actual reply sequence to native draft');
+ // A non-current card can be inspected without touching the native owner.
+ await evaluate(()=>{const d=document.querySelector('iframe').contentDocument;d.querySelector('.thread-card[data-thread="t-two"] .thread-answer').click();window.browseDoc=d;window.browseCard=d.querySelector('.thread-card[data-thread="t-two"]');sendMap('synapse:current-session',{session:{id:'one',title:'One'}});sendMap('synapse:layout',{composerHeight:120});});
+ await waitFor(()=>layoutReports.some(item=>item.inspectorWidth===460));
+ if(!await evaluate(()=>{const d=document.querySelector('iframe').contentDocument;return d===browseDoc&&d.querySelector('.thread-card[data-thread="t-two"]')===browseCard&&d.querySelector('.card-inspector[data-inspector-card="t-two:turn:5"]')&¤tId==='one'&&d.documentElement.style.getPropertyValue('--map-composer-height')==='120px';}))throw new Error('Local card inspection or compositor layout failed');
+ await evaluate(()=>{const d=document.querySelector('iframe').contentDocument;const a=d.querySelector('.canvas-viewport').getBoundingClientRect(),b=d.querySelector('.card-inspector').getBoundingClientRect(),stage=d.querySelector('.canvas-view').getBoundingClientRect();if(a.right>b.left+1||Math.abs(b.bottom-stage.bottom)>1||Math.abs(stage.bottom-a.bottom-120)>1)throw new Error('Detail rail/composer geometry overlaps');d.querySelector('.card-inspector-close').click();});
+ await waitFor(()=>!document.querySelector('iframe').contentDocument.querySelector('.card-inspector'));
+ await evaluate(()=>{composeCalls.length=0;sendMap('synapse:layout',{composerHeight:0});sendMap('synapse:presentation',presentation);});
+ return {realPointerPlus:true,pressedSubscriptionSafe:true,explicitComposeChoices:true,pendingDraftProtected:true,inspectorComposerLayout:true};
+};
+const currentTreeFixture = async win => {
+ const evaluate = fn => win.webContents.executeJavaScript(`(${fn.toString()})()`);
+ const waitFor = async fn => { for (let i=0;i<200;i++) { if(await evaluate(fn))return; await new Promise(r=>setTimeout(r,20)); } throw new Error('Current tree fixture timed out: '+fn); };
+ await evaluate(() => {
+ const d=document.querySelector('iframe').contentDocument;
+ d.querySelector('.thread-card[data-card-id="t-two:turn:5"] .thread-answer').click();
+ const current=d.querySelectorAll('.thread-card.current');
+ const inspected=d.querySelector('.thread-card[data-card-id="t-two:turn:5"]');
+ if(current.length!==1||current[0].dataset.cardId!=='t-one:turn:1'||!inspected.classList.contains('selected')||inspected.classList.contains('current'))throw new Error('Browsing stole the current native tree highlight');
+ const w=document.querySelector('iframe').contentWindow;
+ if(w.getComputedStyle(inspected).borderColor===w.getComputedStyle(current[0]).borderColor)throw new Error('Inspection still uses the native-current blue border');
+ const saved=JSON.parse(w.sessionStorage.getItem('deepviewer:synapse:view:v1:one'));
+ saved.activeId='t-one';saved.selectedCardId='t-one:turn:1';saved.inspectorCardId=null;saved.mode='canvas';
+ w.sessionStorage.setItem('deepviewer:synapse:view:v1:two',JSON.stringify(saved));
+ window.currentId='two';window.beforeCurrentFrame=d;
+ document.querySelector('iframe').src='/map/index.html?native=two-stale-parent';
+ });
+ await waitFor(() => { const d=document.querySelector('iframe').contentDocument;return d!==beforeCurrentFrame&&d?.querySelector('.thread-card.current[data-card-id="t-two:turn:7"]'); });
+ await evaluate(() => {
+ const d=document.querySelector('iframe').contentDocument;
+ if(d.querySelector('.thread-card[data-card-id="t-one:turn:1"]')?.classList.contains('current'))throw new Error('Restored parent browse selection became native-current');
+ const edges=[...d.querySelectorAll('.active-connector')].map(e=>e.dataset.from+'>'+e.dataset.to).sort();
+ if(JSON.stringify(edges)!==JSON.stringify(['t-one:turn:1>t-two:turn:5','t-two:turn:5>t-two:turn:7']))throw new Error('Native child route lost the ancestor entry edge: '+JSON.stringify(edges));
+ d.querySelector('.thread-card[data-card-id="t-one:turn:1"] .thread-answer').click();
+ if(d.querySelector('.thread-card.current')?.dataset.cardId!=='t-two:turn:7')throw new Error('Inspecting the trunk changed the active child');
+ window.currentTreeCamera=d.querySelector('.canvas-content').style.transform;
+ threads[0].messages.push({kind:'user',text:'Later trunk turn',sourceSeq:9},{kind:'assistant',text:'Trunk later answer',sourceSeq:10});
+ threads[1].messages.push({kind:'user',text:'Pending child question',sourceSeq:11});
+ threads.push({id:'t-sibling',dshSessionId:'sibling',title:'Sibling',parentId:'t-one',sourceSeedLength:3,messages:[{kind:'user',text:'Unrelated branch',sourceSeq:5},{kind:'assistant',text:'Unrelated reply',sourceSeq:6}]});
+ sendMap('synapse:live-reply',{sessionId:'two',running:true,userSeq:11,text:'Live child reply'});
+ });
+ await waitFor(() => { const d=document.querySelector('iframe').contentDocument; return d?.querySelector('.connectors path[data-to="t-two:turn:11"]'); });
+ await evaluate(() => {
+ const d=document.querySelector('iframe').contentDocument;
+ if(d.querySelector('.canvas-content').style.transform!==currentTreeCamera||!d.querySelector('.card-inspector[data-inspector-card="t-one:turn:1"]'))throw new Error('New current turn moved the camera or replaced inspected details');
+ const edges=[...d.querySelectorAll('.active-connector')].map(e=>e.dataset.from+'>'+e.dataset.to).sort();
+ if(JSON.stringify(edges)!==JSON.stringify(['t-one:turn:1>t-two:turn:5','t-two:turn:5>t-two:turn:7','t-two:turn:7>t-two:turn:11']))throw new Error('Current tree includes future trunk/sibling or omits pending child: '+JSON.stringify(edges));
+ d.querySelector('.card-inspector-close').click();
+ });
+ await waitFor(() => !document.querySelector('iframe').contentDocument.querySelector('.card-inspector'));
+ await evaluate(() => {document.querySelector('iframe').contentDocument.querySelector('[data-action="focus-active"]').click();});
+ await waitFor(() => document.querySelector('iframe').contentDocument.querySelector('.thread-card.current[data-card-id="t-two:turn:11"]'));
+ await evaluate(() => {
+ const d=document.querySelector('iframe').contentDocument,current=d.querySelector('.thread-card.current');
+ if(d.querySelectorAll('.thread-card.current').length!==1||current.getAttribute('aria-current')!=='true')throw new Error('Pending child is not the unique accessible current card');
+ const a=current.getBoundingClientRect(),b=d.querySelector('.canvas-viewport').getBoundingClientRect();
+ if(Math.abs((a.left+a.right)/2-(b.left+b.right)/2)>1||Math.abs((a.top+a.bottom)/2-(b.top+b.bottom)/2)>1)throw new Error('Locate targets inspected trunk instead of the current child');
+ threads.push({id:'t-blank',dshSessionId:'blank',title:'Blank branch',parentId:'t-one',sourceSeedLength:3,messages:[]});
+ const saved=JSON.parse(document.querySelector('iframe').contentWindow.sessionStorage.getItem('deepviewer:synapse:view:v1:two'));
+ saved.activeId='t-one';saved.selectedCardId='t-one:turn:1';saved.inspectorCardId=null;
+ document.querySelector('iframe').contentWindow.sessionStorage.setItem('deepviewer:synapse:view:v1:blank',JSON.stringify(saved));
+ window.currentId='blank';window.beforeBlankFrame=d;
+ document.querySelector('iframe').src='/map/index.html?native=blank-child';
+ });
+ await waitFor(() => {const d=document.querySelector('iframe').contentDocument;return d!==beforeBlankFrame&&d?.querySelector('.connectors path.active-connector[data-to="t-blank:turn:empty"]');});
+ await evaluate(() => {document.querySelector('iframe').contentDocument.querySelector('[data-action="focus-active"]').click();});
+ await waitFor(() => document.querySelector('iframe').contentDocument.querySelector('.thread-card.current[data-card-id="t-blank:turn:empty"]'));
+ await evaluate(() => {
+ window.delayProjection=160;
+ threads.at(-1).messages.push({kind:'user',text:'First blank child message',sourceSeq:13});
+ sendMap('synapse:live-reply',{sessionId:'blank',running:true,userSeq:13,text:''});
+ });
+ await waitFor(() => projectionDetailPending>0);
+ await evaluate(() => {
+ const f=document.querySelector('iframe'),d=f.contentDocument;
+ window.pressedEmptyCard=d.querySelector('.thread-card.current');
+ pressedEmptyCard.dispatchEvent(new f.contentWindow.PointerEvent('pointerdown',{bubbles:true,button:0,clientX:5,clientY:5}));
+ });
+ await new Promise(resolve=>setTimeout(resolve,220));
+ await evaluate(() => {
+ if(!pressedEmptyCard.isConnected)throw new Error('Projection response replaced the pressed empty card');
+ const f=document.querySelector('iframe');window.delayProjection=0;
+ f.contentDocument.dispatchEvent(new f.contentWindow.PointerEvent('pointerup',{bubbles:true,button:0}));
+ });
+ await waitFor(() => document.querySelector('iframe').contentDocument.querySelector('.thread-card.current[data-card-id="t-blank:turn:13"]')).catch(async error => {throw new Error(String(error)+' '+JSON.stringify(await evaluate(() => {const d=document.querySelector('iframe').contentDocument;return {owner:currentId,cards:[...d.querySelectorAll('.thread-card')].map(c=>({id:c.dataset.cardId,current:c.classList.contains('current')})),paths:[...d.querySelectorAll('.connectors path')].map(p=>({to:p.dataset.to,current:p.classList.contains('active-connector')})),camera:d.querySelector('.canvas-content').style.transform};})));});
+ await evaluate(() => {const d=document.querySelector('iframe').contentDocument;if(d.querySelector('.thread-card[data-card-id="t-blank:turn:empty"]')||d.querySelector('.thread-card.current')?.dataset.cardId!=='t-blank:turn:13')throw new Error('First user turn did not replace the current empty branch marker');});
+ await evaluate(() => {
+ threads.at(-1).messages.push({kind:'assistant',text:'Final child reply',sourceSeq:14});
+ sendMap('synapse:live-reply',{sessionId:'blank',running:false,userSeq:13,text:''});
+ });
+ await waitFor(() => document.querySelector('iframe').contentDocument.querySelector('.thread-card.current[data-card-id="t-blank:turn:13"] .thread-answer')?.textContent.includes('Final child reply'));
+ return {nativeCurrentTree:true,neutralInspection:true,staleParentCacheSafe:true,ancestorRoute:true,pendingTurnCurrent:true,currentLocate:true,emptyBranchCurrent:true,firstBranchTurnCurrent:true,deferredProjectionCurrent:true,finalReplySameSummary:true};
+};
+writeFileSync(join(fixture,'main.cjs'),`const {app,BrowserWindow}=require('electron');app.setPath('userData',${JSON.stringify(join(fixture,'user-data'))});app.whenReady().then(async()=>{const win=new BrowserWindow({width:1000,height:650,show:false,webPreferences:{nodeIntegration:false,contextIsolation:true}});win.webContents.on('console-message',(...args)=>console.log(args[1]?.message??args[2]));try{await win.loadURL(${JSON.stringify(url)});const pointer=await (${pointerFixture.toString()})(win);const result=await win.webContents.executeJavaScript(${JSON.stringify(probe+".catch(error=>({error:String(error),stack:error.stack}))")});if(result.error)throw new Error(JSON.stringify(result));const tree=await (${currentTreeFixture.toString()})(win);win.setSize(430,500);await new Promise(r=>setTimeout(r,100));const geometry=await win.webContents.executeJavaScript(${JSON.stringify(geometryProbe)});console.log(JSON.stringify({result:'PASS',...pointer,...result,...tree,...geometry}));app.quit();}catch(error){console.error(error);app.exit(1);}});setTimeout(()=>app.exit(2),20000).unref();`);
+const electron=createRequire(join(root,'apps/deepviewer-desktop/package.json'))('electron');
+const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;for(const key of Object.keys(env))if(/KEY|TOKEN|SECRET|PASSWORD/u.test(key))delete env[key];
+try{const child=spawn(electron,[fixture],{cwd:fixture,env,stdio:['ignore','pipe','pipe']});child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr);const code=await new Promise(resolve=>child.on('exit',resolve));if(code!==0)process.exitCode=code;}finally{server.close();rmSync(fixture,{recursive:true,force:true});}
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-synapse-client.mjs b/apps/deepviewer-desktop/scripts/smoke-native-synapse-client.mjs
new file mode 100644
index 0000000..1209a5e
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-synapse-client.mjs
@@ -0,0 +1,11 @@
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const upstream = resolve(root, 'upstream/deepseek-harness')
+const fixture = resolve(upstream, 'packages/client/ui-conversation/tests/deepviewer-synapse.client.spec.tsx')
+try {
+ writeFileSync(fixture, readFileSync(resolve(root, 'apps/deepviewer-adapter/tests/synapse-client.spec.tsx.txt')))
+ execFileSync('pnpm', ['exec','vitest','run',fixture], {cwd:upstream,stdio:'inherit'})
+} finally { rmSync(fixture,{force:true}) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-synapse-seams.mjs b/apps/deepviewer-desktop/scripts/smoke-native-synapse-seams.mjs
new file mode 100644
index 0000000..7ef369f
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-synapse-seams.mjs
@@ -0,0 +1,15 @@
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const upstream = resolve(root, 'upstream/deepseek-harness')
+const conversation = resolve(upstream, 'packages/client/ui-conversation/tests/deepviewer-synapse-native.client.spec.tsx')
+const chat = resolve(upstream, 'packages/client/ui-chat/tests/deepviewer-synapse-focus.client.spec.tsx')
+try {
+ writeFileSync(conversation, readFileSync(resolve(root, 'apps/deepviewer-adapter/tests/synapse-native.spec.tsx.txt')))
+ // Reuse the upstream measurement/Session fixtures; run only DeepViewer's new
+ // cases rather than duplicating or broadening the upstream test suite.
+ writeFileSync(chat, readFileSync(resolve(upstream, 'packages/client/ui-chat/tests/chat-view.client.spec.tsx'), 'utf8') + '\n' + readFileSync(resolve(root, 'apps/deepviewer-adapter/tests/synapse-chat-focus.spec.tsx.txt'), 'utf8'))
+ execFileSync('pnpm', ['exec', 'vitest', 'run', conversation, chat, '-t', 'DeepViewer map native'], { cwd: upstream, stdio: 'inherit' })
+} finally { rmSync(conversation, { force: true }); rmSync(chat, { force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-ui-regression.mjs b/apps/deepviewer-desktop/scripts/smoke-native-ui-regression.mjs
new file mode 100644
index 0000000..d294a95
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-ui-regression.mjs
@@ -0,0 +1,41 @@
+/** Preserve upstream tests and run DeepViewer copies with approved presentation expectations. */
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
+const upstream = resolve(root, 'upstream/deepseek-harness')
+const generated = []
+function replace(source, before, after, count) {
+ const actual = source.split(before).length - 1
+ if (actual !== count) throw new Error(`DeepViewer expectation anchor changed (${actual}, expected ${count}): ${before}`)
+ return source.replaceAll(before, after)
+}
+try {
+ const skeletonSource = resolve(upstream, 'packages/client/ui-conversation/tests/skeleton.client.spec.tsx')
+ const skeleton = resolve(dirname(skeletonSource), 'deepviewer-native-regression-skeleton.client.spec.tsx')
+ let source = readFileSync(skeletonSource, 'utf8')
+ // Approved DeepViewer hero copy; every chrome, composer and navigation assertion remains.
+ source = replace(source, 'Into the Unknown', 'What shall we build?', 1)
+ source = replace(source, '探索未至之境', '让我们做点什么', 3)
+ writeFileSync(skeleton, source); generated.push(skeleton)
+ const chatSource = resolve(upstream, 'packages/client/ui-chat/tests/chat-view.client.spec.tsx')
+ const chat = resolve(dirname(chatSource), 'deepviewer-native-regression-chat.client.spec.tsx')
+ source = readFileSync(chatSource, 'utf8')
+ // Approved DeepViewer running/thinking names retain all clock, disclosure, focus and live-region assertions.
+ const runningCount = source.split('深度求索中').length - 1
+ if (runningCount < 1) throw new Error('Native running copy expectation missing')
+ source = replace(source, 'name: /^思考/', 'name: /^深度求索中/', 2)
+ // Approved removal of the decorative whale is tested as absence, while transcript-bottom ownership,
+ // status/live text, absence of SVG animation and removal when idle are preserved.
+ source = replace(source, ` const icon = status?.querySelector('svg')?.parentElement
+ expect(icon?.getAttribute('aria-hidden')).toBe('true')
+ expect(icon?.firstElementChild?.tagName).toBe('SPAN')
+ expect(icon?.querySelectorAll('path')).toHaveLength(1)
+ expect(icon?.getAttribute('style')).toBeNull()`, ` expect(status?.querySelector('svg')).toBeNull()
+ expect(status?.querySelector('path')).toBeNull()`, 1)
+ writeFileSync(chat, source); generated.push(chat)
+ // Unmodified upstream apply-wiring checks retain target-neutral roster introspection coverage.
+ const wiring = resolve(upstream, 'packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx')
+ execFileSync(process.execPath, [resolve(upstream, 'node_modules/vitest/vitest.mjs'), 'run', chat, skeleton, wiring], { cwd: upstream, stdio: 'inherit' })
+} finally { for (const file of generated) rmSync(file, { force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-updates.mjs b/apps/deepviewer-desktop/scripts/smoke-native-updates.mjs
new file mode 100644
index 0000000..6261781
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-updates.mjs
@@ -0,0 +1,55 @@
+/** Real HTTP Range/blockmap reconstruction through the pinned updater; never invokes installation. */
+import { createServer } from 'node:http'
+import { createHash, randomBytes } from 'node:crypto'
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve, dirname, basename } from 'node:path'
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+import assert from 'node:assert/strict'
+import { dump } from 'js-yaml'
+const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'), desktopRequire=createRequire(resolve(appRoot,'../../upstream/deepseek-harness/apps/desktop/package.json')),buildRequire=createRequire(desktopRequire.resolve('app-builder-lib/package.json')),updaterRequire=createRequire(desktopRequire.resolve('electron-updater/package.json'))
+const {buildBlockMap}=desktopRequire('app-builder-lib/out/targets/blockmap/blockmap.js'),{NodeHttpExecutor}=buildRequire('builder-util'),{CancellationToken}=updaterRequire('builder-util-runtime'),{AppUpdater}=desktopRequire('electron-updater/out/AppUpdater.js'),{GenericProvider}=desktopRequire('electron-updater/out/providers/GenericProvider.js')
+const fixture=mkdtempSync(join(tmpdir(),'deepviewer-updates-')),cache=join(fixture,'cache');mkdirSync(join(cache,'pending'),{recursive:true})
+const versions=['0.7.0','0.7.1','0.7.3'],base=randomBytes(1024*1024),bodies=new Map(),metadata=new Map()
+for(let i=0;i{
+ const path=new URL(req.url,'http://localhost').pathname
+ if(path==='/latest-mac.yml'){const file=metadata.get(current);res.end(dump({version:current,files:[{url:`DeepViewer-${current}-arm64.zip`,sha512:file.sha512,size:file.size}],path:`DeepViewer-${current}-arm64.zip`,sha512:file.sha512,releaseDate:new Date().toISOString()}));return}
+ const match=/^\/DeepViewer-(0\.7\.[0-9])-arm64\.zip(\.blockmap)?$/.exec(path)
+ if(!match){res.writeHead(404);res.end();return}
+ if(mode==='http-failure'){res.writeHead(503);res.end('fixture failure');return}
+ const file=join(fixture,basename(path));if(!existsSync(file)){res.writeHead(404);res.end();return}
+ const bytes=readFileSync(file)
+ if(match[2]){res.end(mode==='bad-blockmap'?Buffer.from('invalid gzip'):bytes);return}
+ if(req.headers.range && mode!=='no-range'){
+ const range=/^bytes=(\d+)-(\d+)$/.exec(req.headers.range);if(!range){res.writeHead(416);res.end();return}
+ const start=Number(range[1]),end=Number(range[2]);rangeBytes+=end-start+1;res.writeHead(206,{'Content-Range':`bytes ${start}-${end}/${bytes.length}`,'Content-Length':end-start+1,'Accept-Ranges':'bytes'});res.end(bytes.subarray(start,end+1))
+ }else{fullBytes+=bytes.length;res.writeHead(200,{'Content-Length':bytes.length});res.end(bytes)}
+})
+await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin=`http://127.0.0.1:${server.address().port}`,executor=new NodeHttpExecutor()
+executor.download=desktopRequire('electron-updater/out/electronHttpExecutor.js').ElectronHttpExecutor.prototype.download
+const request=executor.createRequest.bind(executor);executor.createRequest=(options,callback)=>request({...options,agent:false},callback)
+async function attempt(oldVersion,newVersion,{missingCache=false,badHash=false}={}){
+ current=newVersion;rangeBytes=0;fullBytes=0;rmSync(join(cache,'current.blockmap'),{force:true});rmSync(join(cache,'update.zip'),{force:true})
+ if(!missingCache)copyFileSync(join(fixture,`DeepViewer-${oldVersion}-arm64.zip`),join(cache,'update.zip'))
+ const updater=new AppUpdater(undefined,{version:oldVersion,name:'DeepViewer',isPackaged:true});updater.httpExecutor=executor;updater.logger={info(){},warn(){},error(){}};updater.downloadedUpdateHelper={cacheDir:cache,cacheDirForPendingUpdate:join(cache,'pending')}
+ const provider=new GenericProvider({provider:'generic',url:origin,channel:'latest'},updater,{executor,platform:'darwin',isUseMultipleRangeRequest:false})
+ const latest=await provider.getLatestVersion();assert.equal(latest.version,newVersion)
+ const info=provider.resolveFiles(latest)[0];if(badHash)info.info.sha512=Buffer.alloc(64).toString('base64')
+ const cancellationToken=new CancellationToken(),destination=join(fixture,'reconstructed.zip'),options={updateInfoAndProvider:{info:latest,provider},cancellationToken,requestHeaders:{}}
+ const fallback=await updater.differentialDownloadInstaller(info,options,destination,provider,'update.zip')
+ if(fallback)await executor.download(info.url,destination,{cancellationToken,sha512:info.info.sha512})
+ assert.equal(createHash('sha512').update(readFileSync(destination)).digest('base64'),metadata.get(newVersion).sha512)
+ return {fallback,rangeBytes,fullBytes}
+}
+try{
+ mode='range';const consecutive=await attempt('0.7.0','0.7.1'),skipped=await attempt('0.7.0','0.7.3');assert.equal(consecutive.fallback,false);assert.equal(skipped.fallback,false);assert.ok(consecutive.rangeBytes=base.length)
+ mode='no-range';const noRange=await attempt('0.7.0','0.7.1');assert.equal(noRange.fallback,true)
+ mode='bad-blockmap';const corruptMap=await attempt('0.7.0','0.7.1');assert.equal(corruptMap.fallback,true)
+ mode='range';await assert.rejects(attempt('0.7.0','0.7.1',{badHash:true}),/sha512|checksum|mismatch/i)
+ mode='http-failure';await assert.rejects(attempt('0.7.0','0.7.1'),/503/)
+ console.info(JSON.stringify({result:'PASS',realHttp:true,consecutive,skipped,missingCacheFallback:true,noRangeFallback:true,corruptMapFallback:true,badHashRejected:true,httpFailureRejected:true,installationInvoked:false}))
+}finally{await new Promise(resolve=>server.close(resolve));rmSync(fixture,{recursive:true,force:true})}
diff --git a/apps/deepviewer-desktop/scripts/smoke-native-web-state.mjs b/apps/deepviewer-desktop/scripts/smoke-native-web-state.mjs
new file mode 100644
index 0000000..53bb846
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/smoke-native-web-state.mjs
@@ -0,0 +1,43 @@
+import { spawn } from 'node:child_process'
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve, dirname } from 'node:path'
+import { createRequire } from 'node:module'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+const appRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..'), root=resolve(appRoot,'../..'), fixture=mkdtempSync(join(tmpdir(),'deepviewer-web-state-'))
+const electron=createRequire(join(appRoot,'package.json'))('electron'), tsx=createRequire(join(root,'upstream/deepseek-harness/package.json')).resolve('tsx/esm/api')
+writeFileSync(join(fixture,'package.json'),JSON.stringify({name:'deepviewer-web-state-smoke',main:'main.cjs'}))
+writeFileSync(join(fixture,'main.cjs'), `
+const {app,BrowserWindow,protocol}=require('electron'), {createServer}=require('node:http'), fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict')
+const home=${JSON.stringify(join(fixture,'user-data'))}
+app.setPath('userData',home)
+app.on('window-all-closed',()=>{})
+protocol.registerSchemesAsPrivileged([{scheme:'dsh-app',privileges:{standard:true,secure:true,supportFetchAPI:true}}])
+app.whenReady().then(async()=>{
+ let server,source,target
+ try{
+ protocol.handle('dsh-app',()=>new Response('DeepViewer state fixture ',{headers:{'content-type':'text/html'}}))
+ const {register}=await import(${JSON.stringify(pathToFileURL(tsx).href)});register()
+ const {prepareWebState}=await import(${JSON.stringify(pathToFileURL(join(root,'apps/deepviewer-adapter/migrations/web-state.ts')).href)})
+ server=createServer((req,res)=>res.end('Legacy fixture '));await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+ const origin='http://127.0.0.1:'+server.address().port
+ source=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true}});await source.loadURL(origin)
+ await source.webContents.executeJavaScript('localStorage.setItem("dsh.conversation.fixture",JSON.stringify({draft:"preserved draft",view:null}));localStorage.setItem("api-key","excluded credential")')
+ source.destroy();source=undefined;await new Promise(resolve=>server.close(resolve));server=undefined
+ fs.mkdirSync(path.join(home,'logs'),{recursive:true});fs.writeFileSync(path.join(home,'logs/deepviewer.log'),'INFO runtime ready origin='+origin+'\\n')
+ await prepareWebState(home)
+ target=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,preload:${JSON.stringify(join(root,'apps/deepviewer-adapter/desktop/preload-state.cjs'))}}});await target.loadURL('dsh-app://app/')
+ const values=await target.webContents.executeJavaScript('({draft:localStorage.getItem("dsh.conversation.fixture"),credential:localStorage.getItem("api-key")})')
+ assert.equal(JSON.parse(values.draft).draft,'preserved draft');assert.equal(values.credential,null)
+ await target.webContents.executeJavaScript('localStorage.setItem("dsh.conversation.fixture",JSON.stringify({draft:"new edit",view:null}))');await target.reload()
+ await new Promise(resolve=>target.webContents.once('did-finish-load',resolve))
+ assert.equal(JSON.parse(await target.webContents.executeJavaScript('localStorage.getItem("dsh.conversation.fixture")')).draft,'new edit')
+ const backup=JSON.parse(fs.readFileSync(path.join(home,'.deepviewer-web-state-v1.json'),'utf8'));assert.equal(backup.values['api-key'],undefined)
+ assert.equal(fs.statSync(path.join(home,'.deepviewer-web-state-v1.json')).mode & 0o777,0o600)
+ assert.ok(fs.existsSync(path.join(home,'.deepviewer-web-state-imported-v1.json')))
+ console.info(JSON.stringify({result:'PASS',realElectronOrigins:true,draftPreserved:true,credentialsExcluded:true,newValuesNotOverwritten:true,privateBackup:true}));target.destroy();app.quit()
+ }catch(error){console.error(error);source?.destroy();target?.destroy();server?.close();app.exit(1)}
+})
+setTimeout(()=>app.exit(1),30000).unref()
+`)
+try{const child=spawn(electron,[fixture],{cwd:appRoot,env:{...process.env,ELECTRON_RUN_AS_NODE:undefined},stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',chunk=>{output+=chunk.toString();process.stdout.write(chunk)});child.stderr.pipe(process.stderr);const code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve)});if(code!==0 || !output.includes('"result":"PASS"'))throw new Error('Real-origin frontend migration smoke failed')}finally{rmSync(fixture,{recursive:true,force:true})}
diff --git a/apps/deepviewer-desktop/scripts/smoke-reasoning-host.mjs b/apps/deepviewer-desktop/scripts/smoke-reasoning-host.mjs
index 3131f77..91969e6 100644
--- a/apps/deepviewer-desktop/scripts/smoke-reasoning-host.mjs
+++ b/apps/deepviewer-desktop/scripts/smoke-reasoning-host.mjs
@@ -11,11 +11,12 @@ mkdirSync(home+'/profiles/node_modules/@deepviewer',{recursive:true})
symlinkSync(upstream+'/node_modules/@deepviewer/dsh-plugin-reasoning',home+'/profiles/node_modules/@deepviewer/dsh-plugin-reasoning')
const requests=[]
const fake=createServer(async(req,res)=>{
- if(req.url==='/v1/models'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({data:[{id:'smoke-model'},{id:'blocked-model'}]}));return}
+ if(new URL(req.url,'http://fixture').pathname.endsWith('/models')){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({data:[{id:'smoke-model'},{id:'blocked-model'}]}));return}
let text='';for await(const chunk of req)text+=chunk
+ if(!text){res.writeHead(404);res.end();return}
const body=JSON.parse(text);requests.push(body)
assert.equal(req.headers.authorization,'Bearer fixture-only')
- assert.equal(body.max_tokens,128)
+ assert.equal(body.max_tokens,1)
if(body.model==='blocked-model'){res.writeHead(403);res.end('denied');return}
if(body.messages[0].role==='developer'){res.writeHead(400);res.end('messages: Unexpected role developer. Allowed roles are user or assistant');return}
if(body.reasoning_effort==='__deepviewer_invalid_effort__'){res.writeHead(400);res.end('invalid reasoning_effort');return}
@@ -25,8 +26,9 @@ const fake=createServer(async(req,res)=>{
await new Promise(resolve=>fake.listen(0,'127.0.0.1',resolve))
const fakeBase='http://127.0.0.1:'+fake.address().port+'/v1'
writeFileSync(home+'/.credentials.yaml',JSON.stringify({version:1,refs:{SCAN_FIXTURE_KEY:'fixture-only'}}),{mode:0o600})
+writeFileSync(home+'/fixture-network.mjs', `const original = globalThis.fetch; globalThis.fetch = (input, options) => { const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); if (!['127.0.0.1', 'localhost'].includes(url.hostname)) return Promise.resolve(new Response('{}', {status: 404})); return original(input, options); };` )
writeFileSync(home+'/settings.yaml', JSON.stringify({'llm-pi-ai':{providers:{smoke:{api:'openai-completions',baseURL:fakeBase,apiKeyEnv:'SCAN_FIXTURE_KEY',models:[{id:'smoke-model',name:'Smoke model',contextWindow:4096,maxTokens:256}]}}}}))
-const child=spawn(process.execPath,['--expose-internals',upstream+'/apps/cli/lib/bin.js','web','--patch',upstream+'/node_modules/@deepviewer/dsh-plugin-reasoning/cordis.patch.yml','--port','0','--no-open'],{env:{...process.env,DSH_HOME:home},stdio:['ignore','pipe','pipe']})
+const child=spawn(process.execPath,['--expose-internals','--import',home+'/fixture-network.mjs',upstream+'/apps/cli/lib/bin.js','web','--patch',upstream+'/node_modules/@deepviewer/dsh-plugin-reasoning/cordis.patch.yml','--port','0','--no-open'],{env:{...process.env,HOME:home,DSH_HOME:home,DSH_TELEMETRY_DISABLED:'1'},stdio:['ignore','pipe','pipe']})
let logs='';for(const stream of [child.stdout,child.stderr])stream.on('data',x=>logs+=x)
try {
let url;for(let i=0;i<100;i++){url=logs.match(/http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/)?.[0];if(url)break;if(child.exitCode!==null)break;await new Promise(r=>setTimeout(r,200))}
@@ -34,17 +36,19 @@ try {
const auth=await fetch(url,{redirect:'manual'}),cookie=auth.headers.getSetCookie().map(x=>x.split(';')[0]).join('; '),origin=new URL(url).origin
async function rpc(endpoint,payload={},channel='/api/') {const r=await fetch(origin+channel+endpoint,{method:'POST',headers:{'Content-Type':'application/json',Cookie:cookie,Origin:origin},body:JSON.stringify({type:'client-request',rpcId:crypto.randomUUID(),method:endpoint,payload:channel==='/api/'?{args:payload}:payload})});const text=await r.text();if(!r.ok)throw Error('RPC '+r.status+' '+text.slice(0,400));const answer=JSON.parse(text).result;if(!answer.ok)throw Error(JSON.stringify(answer.error));return answer.value}
console.log('Host started; isolated home:',home)
- const result=await rpc('settings/describe');console.log('describe namespaces:',result.namespaces.length)
+ let result
+ for(let i=0;i<100;i++){result=await rpc('settings/describe');if(result.namespaces.find(x=>x.ns==='llm-pi-ai')?.value?.providers?.smoke)break;await new Promise(r=>setTimeout(r,100))}
+ console.log('describe namespaces:',result.namespaces.length)
const catalog=await rpc('session/modelCatalog'); assert.equal(catalog.groups.find(x=>x.id==='smoke').models[0].reasoning,undefined)
const namespace=result.namespaces.find(x=>x.ns==='llm-pi-ai')
- const models=structuredClone(namespace.user.providers.smoke.models)
+ const models=structuredClone(namespace.value.providers.smoke.models)
models[0].reasoningEfforts={low:'low',high:'high'}
const payload={ns:'llm-pi-ai',ops:[{op:'set',path:['providers','smoke','models'],value:models}],expectedRevision:namespace.revision}
const saved=await rpc('settings/mutate',payload)
const updated=await rpc('session/modelCatalog')
assert.deepEqual(updated.groups.find(x=>x.id==='smoke').models[0].reasoning.efforts.map(x=>x.id),['low','high'])
await assert.rejects(rpc('settings/mutate',payload),/revision|conflict|stale/i)
- await rpc('settings/mutate',{...payload,ops:[{...payload.ops[0],value:namespace.user.providers.smoke.models}],expectedRevision:saved.revision})
+ await rpc('settings/mutate',{...payload,ops:[{...payload.ops[0],value:namespace.value.providers.smoke.models}],expectedRevision:saved.revision})
const restored=await rpc('session/modelCatalog')
assert.equal(restored.groups.find(x=>x.id==='smoke').models[0].reasoning,undefined)
const html=await (await fetch(origin+'/',{headers:{Cookie:cookie}})).text()
@@ -56,11 +60,11 @@ try {
for(let i=0;i<100;i++){report=await scan('status',{route:'smoke'});if(report.status!=='running')break;await new Promise(r=>setTimeout(r,50))}
assert.equal(report.status,'complete')
assert.deepEqual(report.results.find(x=>x.id==='smoke-model').efforts,['low','medium','high'])
- assert.equal(report.results.find(x=>x.id==='blocked-model').availability,'unavailable')
+ assert.equal(report.results.find(x=>x.id==='blocked-model').availability,'permission_denied')
await scan('apply',{route:'smoke',id:report.id,exclude:true})
const savedScan=(await rpc('settings/describe')).namespaces.find(x=>x.ns==='llm-pi-ai')
- assert.equal(savedScan.user.providers.smoke.models[0].compat.supportsDeveloperRole,false)
- assert.deepEqual(savedScan.user.providers.smoke.models[0].input,['text','image'])
+ assert.equal(savedScan.value.providers.smoke.models[0].compat.supportsDeveloperRole,false)
+ assert.deepEqual(savedScan.value.providers.smoke.models[0].input,['text','image'])
const scannedCatalog=await rpc('session/modelCatalog')
assert.deepEqual(scannedCatalog.groups.find(x=>x.id==='smoke').models[0].reasoning.efforts.map(x=>x.id),['low','medium','high'])
// modelCatalog exposes picker fields only; the upload gate uses resolveModelInfo.
@@ -69,14 +73,14 @@ try {
const PiAi=await import(pathToFileURL(upstream+'/packages/llm/llm-pi-ai/lib/index.js'))
const ctx=new Context(), forks=[]
try {
- forks.push(await ctx.plugin(Llm));forks.push(await ctx.plugin(PiAi,{providers:{smoke:savedScan.user.providers.smoke}}))
+ forks.push(await ctx.plugin(Llm));forks.push(await ctx.plugin(PiAi,{providers:{smoke:savedScan.value.providers.smoke}}))
assert.deepEqual((await ctx.llm.resolveModelInfo('smoke','smoke-model')).inputModalities,['text','image'])
} finally {for(const fork of forks.reverse()) fork.dispose()}
await scan('restore',{route:'smoke',id:report.id})
assert.equal((await rpc('session/modelCatalog')).groups.find(x=>x.id==='smoke').models[0].reasoning,undefined)
- assert.equal(requests.length,9)
+ assert.equal(requests.length,8)
const appliedView=(await rpc('settings/describe')).namespaces.find(x=>x.ns==='llm-pi-ai')
- assert.equal(appliedView.user.providers.smoke.models[0].compat?.supportsDeveloperRole,undefined)
+ assert.equal(appliedView.value.providers.smoke.models[0].compat?.supportsDeveloperRole,undefined)
console.log('PASS: authenticated plugin scan RPC, local fake API, capability apply/restore, settings CAS and native model catalog; no external model requests')
}finally{fake.close();if(child.exitCode===null){child.kill('SIGTERM');await new Promise(r=>{child.once('exit',r);setTimeout(()=>{child.kill('SIGKILL');r()},2000)})}}
diff --git a/apps/deepviewer-desktop/scripts/smoke-reasoning-plugin.mjs b/apps/deepviewer-desktop/scripts/smoke-reasoning-plugin.mjs
index 3478429..340d7f7 100644
--- a/apps/deepviewer-desktop/scripts/smoke-reasoning-plugin.mjs
+++ b/apps/deepviewer-desktop/scripts/smoke-reasoning-plugin.mjs
@@ -30,9 +30,9 @@ const mirror = { acceptView: value => { accepted = value } }
plugin.apply({
effect: action => action(),
locale: { register: () => () => {}, bind: () => key => key },
- settingsScope: { describe: () => mirror },
+ configForms: { describe: () => mirror },
remote: { settings: { mutate: async (...args) => { request = args; return fail ? { ok: false, error: { message: 'revision conflict' } } : { ok: true, value: edited } } } },
- connection: { rpc: { call: async () => ({ ok: true, value: { status: 'new' } }) } },
+ get: name => { assert.equal(name, 'connection'); return { rpc: { call: async () => ({ ok: true, value: { status: 'new' } }) } } },
slots: { inject: (slot, action) => { assert.equal(slot, 'settings.models.provider-card'); action() }, register: value => { registration = value } },
})
const props = registration.inject()
diff --git a/apps/deepviewer-desktop/scripts/smoke-system-network.mjs b/apps/deepviewer-desktop/scripts/smoke-system-network.mjs
index aaaa514..f979127 100644
--- a/apps/deepviewer-desktop/scripts/smoke-system-network.mjs
+++ b/apps/deepviewer-desktop/scripts/smoke-system-network.mjs
@@ -44,7 +44,7 @@ async function runElectronSmoke() {
const options = { resolveProxy: url => session.defaultSession.resolveProxy(url), proxyEnv: {}, ask: async () => 'deny', log: message => console.log(message) }
const bridge = await startNetworkBridge(options); bridges.push(bridge)
const provider = new HttpFetchProvider(limits, undefined, bridge.webUrl)
- for (const url of ['https://github.com/deepseek-ai/deepseek-harness', 'https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/fb2c4b9e698e30edb738bca4cf0618587db7d203/README.md']) {
+ for (const url of ['https://github.com/deepseek-ai/deepseek-harness', 'https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/477b4f420553e8a52c2fbccc464d7561b239c443/README.md']) {
const result = await provider.fetch({ url })
assert.equal(result.statusCode, 200); assert.ok(result.body.content.length > 0)
console.log(`PASS public ${new URL(url).hostname} status=200`)
diff --git a/apps/deepviewer-desktop/scripts/smoke-user-data.mjs b/apps/deepviewer-desktop/scripts/smoke-user-data.mjs
index d456f56..159fd2a 100644
--- a/apps/deepviewer-desktop/scripts/smoke-user-data.mjs
+++ b/apps/deepviewer-desktop/scripts/smoke-user-data.mjs
@@ -67,9 +67,9 @@ try {
prepareUserData({ appData: root, userData, development: true })
const home = join(userData, 'harness-home')
assert.deepEqual(await readHistories(home, 'write'), before)
- for (const fixture of cases) assert.ok((await readFile(join(home, 'sessions', fixture.project, fixture.id, 'session.v3.jsonl.zstd'))).length > 0)
+ for (const fixture of cases) assert.ok((await readFile(join(home, 'sessions', fixture.project, fixture.id, 'session.v4.jsonl.zstd'))).length > 0)
assert.equal(prepareUserData({ appData: root, userData, development: true }).migrated, false)
assert.deepEqual(await readHistories(home, 'read'), before)
for (const original of originals) assert.deepEqual(await readFile(original.path), original.bytes)
- console.log(JSON.stringify({ passed: true, workEvents: before.work.count, chatEvents: before.chat.count, modesAndWorkspacePreserved: true, semanticHistoryUnchanged: true, dshFormat: 'v2 -> v3 zstd', sourceUnchanged: true, restartRead: true }))
+ console.log(JSON.stringify({ passed: true, workEvents: before.work.count, chatEvents: before.chat.count, modesAndWorkspacePreserved: true, semanticHistoryUnchanged: true, dshFormat: 'v2 -> v3 -> v4 zstd', sourceUnchanged: true, restartRead: true }))
} finally { await rm(root, { recursive: true, force: true }) }
diff --git a/apps/deepviewer-desktop/scripts/stage-better-sidebar.mjs b/apps/deepviewer-desktop/scripts/stage-better-sidebar.mjs
index 06d1dc8..eb3b757 100644
--- a/apps/deepviewer-desktop/scripts/stage-better-sidebar.mjs
+++ b/apps/deepviewer-desktop/scripts/stage-better-sidebar.mjs
@@ -7,7 +7,7 @@ import { adaptBetterSidebarUI } from './adapt-better-sidebar-ui.mjs'
const project = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
export const betterSidebarName = 'dsh-better-sidebar'
export const betterSidebarVersion = '0.19.1'
-export const betterSidebarAdapter = 'deepviewer-dsh015-sidebar-management-v2'
+export const betterSidebarAdapter = 'deepviewer-dsh017-sidebar-management-v1'
export const betterSidebarFiles = ['package.json', 'LICENSE', 'cordis.patch.yml', 'lib/index.js', 'lib/client.js', 'lib/client-terminal.js', 'lib/client-editor.js', 'lib/client-mermaid.js']
export function validateBetterSidebar(root) {
@@ -28,6 +28,28 @@ function adaptHost(root) {
const path = resolve(root, 'lib/index.js')
let content = readFileSync(path, 'utf8')
const replacements = [
+ ['import z from "schemastery";', 'import z from "@deepseek-ai/schemastery";'],
+ ['const Config = z.object({', 'const HostConfig = z.object({'],
+ ['//#region src/wire.ts', `// RC2 forms project volatile fields from the plugin's own Config.
+const Config = z.object({
+ ...HostConfig.dict,
+ ...Object.fromEntries(Object.entries(PrefsSchema.dict).map(([key, schema]) => [key, schema.volatile()])),
+});
+//#region src/wire.ts`],
+ ['const ns = SIDEBAR_PREFS_NS;\n\t\tconst scope = sctx.settings.register(ns, PrefsSchema);', `const owner = [...ctx.root.loader.entries()].find(entry => entry.fiber === ctx.fiber);
+ if (!owner) throw new Error("Sidebar configuration owner is unavailable");
+ const ns = owner.options.id;
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
+ const scope = {
+ get: () => Object.fromEntries(Object.keys(PrefsSchema.dict).map(key => [key, config[key].get()])),
+ watch: callback => sctx.effect(() => ctx.on("loader/volatile-update", callback)),
+ };
+ sctx.effect(() => () => {
+ settingsFace = undefined;
+ toolsDisposers?.(); toolsDisposers = null;
+ openToolsDisposers?.(); openToolsDisposers = null;
+ agentOpenRegistry.drainAll();
+ });`],
['...buildSidechatApi(ctx, assistantLive)', '...{} /* DeepViewer: side conversation removed by maintainer */'],
['const MEDIA_TYPES = {', 'const MEDIA_TYPES = {\n' + Object.entries({
'.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
@@ -46,10 +68,15 @@ function adaptHost(root) {
if (content.split(before).length !== 2) throw new Error('Better Sidebar auth adapter anchor mismatch')
content = content.replace(before, after)
}
+ content = content.replace(/^\s*bottomPanelAutoTerminal:[^\n]*\n/gmu, '')
writeFileSync(path, content)
const manifestPath = resolve(root, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.deepviewerAdapter = betterSidebarAdapter
+ for (const name of Object.keys(manifest.peerDependencies ?? {})) {
+ if (name.startsWith('@deepseek-ai/')) manifest.peerDependencies[name] = name === '@deepseek-ai/cordis' ? '4.0.4' : '0.1.7-rc.2'
+ }
+ manifest.peerDependencies['@deepseek-ai/schemastery'] = '3.18.4'
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
}
@@ -60,10 +87,12 @@ export function stageBetterSidebar(upstream = resolve(project, 'upstream/deepsee
if (app.dependencies?.[betterSidebarName] !== betterSidebarVersion) throw new Error('Better Sidebar must be version-pinned')
const target = resolve(upstream, 'node_modules', betterSidebarName)
const digest = createHash('sha256')
+ if (existsSync(resolve(source, 'lib/client-registry.js'))) digest.update(readFileSync(resolve(source, 'lib/client-registry.js')))
for (const file of betterSidebarFiles) digest.update(readFileSync(resolve(source, file)))
digest.update(readFileSync(fileURLToPath(import.meta.url)))
digest.update(readFileSync(new URL('./adapt-better-sidebar-ui.mjs', import.meta.url)))
digest.update(readFileSync(new URL('./sidebar-browser-client.js', import.meta.url)))
+ digest.update(readFileSync(new URL('./remove-bottom-workbench.mjs', import.meta.url)))
const stamp = digest.digest('hex')
if (!existsSync(resolve(target, '.deepviewer-stage')) || readFileSync(resolve(target, '.deepviewer-stage'), 'utf8') !== stamp) {
rmSync(target, { recursive: true, force: true })
@@ -82,11 +111,12 @@ export function stageBetterSidebar(upstream = resolve(project, 'upstream/deepsee
}
}
const peers = new Map(workspaces.map(path => [JSON.parse(readFileSync(resolve(path, 'package.json'), 'utf8')).name, path]))
- for (const name of Object.keys(manifest.peerDependencies ?? {}).filter(name => name.startsWith('@deepseek-ai/'))) {
+ for (const name of [...new Set([...Object.keys(manifest.peerDependencies ?? {}).filter(name => name.startsWith('@deepseek-ai/')), '@deepseek-ai/schemastery'])]) {
const path = peers.get(name)
if (!path) throw new Error(`Pinned DSH peer missing: ${name}`)
const peer = JSON.parse(readFileSync(resolve(path, 'package.json'), 'utf8'))
- if (peer.version !== (name === '@deepseek-ai/cordis' ? '4.0.2' : '0.1.5-rc.2')) throw new Error(`Pinned DSH peer mismatch: ${name}`)
+ const version = name === '@deepseek-ai/cordis' ? '4.0.4' : name === '@deepseek-ai/schemastery' ? '3.18.4' : '0.1.7-rc.2'
+ if (peer.version !== version) throw new Error(`Pinned DSH peer mismatch: ${name}`)
const link = resolve(target, 'node_modules', name)
mkdirSync(dirname(link), { recursive: true })
rmSync(link, { force: true, recursive: true })
diff --git a/apps/deepviewer-desktop/scripts/stage-release-plugins.mjs b/apps/deepviewer-desktop/scripts/stage-release-plugins.mjs
index 910cfca..dd085b2 100644
--- a/apps/deepviewer-desktop/scripts/stage-release-plugins.mjs
+++ b/apps/deepviewer-desktop/scripts/stage-release-plugins.mjs
@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { stageSubscriptionsPlugin } from './sync-upstream-overrides.mjs'
+import { stageSubscriptionsPlugin } from './stage-subscriptions.mjs'
import { stageBetterSidebar } from './stage-better-sidebar.mjs'
import { prepareSubscriptionsClient } from './prepare-subscriptions-client.mjs'
import { adaptSubscriptionsPlugin } from './adapt-subscriptions-plugin.mjs'
diff --git a/apps/deepviewer-desktop/scripts/stage-subscriptions.mjs b/apps/deepviewer-desktop/scripts/stage-subscriptions.mjs
new file mode 100644
index 0000000..8e7e8e5
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/stage-subscriptions.mjs
@@ -0,0 +1,124 @@
+import { createHash } from 'node:crypto'
+import {
+ cpSync,
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import {
+ adaptSubscriptionsPlugin,
+ SUBSCRIPTIONS_DSH_PEER_VERSION,
+ SUBSCRIPTIONS_UI_ADAPTER_ID,
+} from './adapt-subscriptions-plugin.mjs'
+import { imageGenerateStylesPath } from './prepare-subscriptions-client.mjs'
+
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const projectRoot = resolve(appRoot, '..', '..')
+const upstreamRoot = resolve(projectRoot, 'upstream', 'deepseek-harness')
+const subscriptionsPluginName = 'dsh-plugin-subscriptions'
+const subscriptionsPluginVersion = '0.3.1'
+const subscriptionsPluginSource = resolve(projectRoot, 'node_modules', subscriptionsPluginName)
+const subscriptionsPluginTarget = resolve(upstreamRoot, 'node_modules', subscriptionsPluginName)
+const subscriptionsPluginStampPath = resolve(upstreamRoot, '.deepviewer-subscriptions-plugin')
+const subscriptionsUiAdapterPath = resolve(appRoot, 'scripts', 'adapt-subscriptions-plugin.mjs')
+const subscriptionsPluginPeers = new Map([
+ ['@deepseek-ai/cordis', resolve(upstreamRoot, 'vendor', 'cordis')],
+ ['@deepseek-ai/dsh-attachment', resolve(upstreamRoot, 'packages', 'attachment', 'attachment')],
+ ['@deepseek-ai/dsh-home-paths', resolve(upstreamRoot, 'packages', 'util', 'home-paths')],
+ ['@deepseek-ai/dsh-llm', resolve(upstreamRoot, 'packages', 'llm', 'llm')],
+ ['@deepseek-ai/dsh-tools', resolve(upstreamRoot, 'packages', 'core', 'tools')],
+ ['@deepseek-ai/schemastery', resolve(upstreamRoot, 'vendor', 'schemastery')],
+])
+function subscriptionsPluginDigest(root) {
+ const digest = createHash('sha256')
+ const visit = directory => {
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
+ if (entry.name === 'node_modules') continue
+ const path = resolve(directory, entry.name)
+ const relativePath = path.slice(root.length + 1)
+ digest.update(relativePath)
+ if (entry.isDirectory()) visit(path)
+ else if (entry.isFile() || entry.isSymbolicLink()) digest.update(readFileSync(path))
+ }
+ }
+ visit(root)
+ return digest.digest('hex')
+}
+
+function validateSubscriptionsPlugin(root, integrated = false) {
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
+ throw new Error(`Missing ${subscriptionsPluginName}; run pnpm install in the DeepViewer workspace`)
+ }
+ const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
+ const clientExport = manifest.exports?.['./client']?.default
+ if (
+ manifest.name !== subscriptionsPluginName
+ || manifest.version !== subscriptionsPluginVersion
+ || manifest.license !== 'MIT'
+ || (manifest.dsh?.bundle?.patch !== './cordis.patch.yml' && !(integrated && manifest.dsh?.bundle === undefined))
+ || manifest.dsh?.client?.platform !== 'web'
+ || typeof manifest.main !== 'string'
+ || typeof clientExport !== 'string'
+ || !existsSync(resolve(root, manifest.main))
+ || !existsSync(resolve(root, clientExport))
+ || !existsSync(resolve(root, 'cordis.patch.yml'))
+ || !existsSync(resolve(root, 'LICENSE'))
+ ) {
+ throw new Error(`Invalid ${subscriptionsPluginName}@${subscriptionsPluginVersion} package`)
+ }
+}
+
+function stageSubscriptionsPluginPeers() {
+ let changed = false
+ for (const [name, target] of subscriptionsPluginPeers) {
+ if (!existsSync(resolve(target, 'package.json'))) {
+ throw new Error(`Missing pinned Harness peer package for ${subscriptionsPluginName}: ${name}`)
+ }
+ const link = resolve(subscriptionsPluginTarget, 'node_modules', ...name.split('/'))
+ try {
+ if (lstatSync(link).isSymbolicLink() && realpathSync(link) === realpathSync(target)) continue
+ } catch {
+ // Any missing, wrong, or dangling generated entry is replaced below.
+ }
+ rmSync(link, { recursive: true, force: true })
+ mkdirSync(resolve(link, '..'), { recursive: true })
+ symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
+ changed = true
+ }
+ return changed
+}
+
+export function stageSubscriptionsPlugin() {
+ validateSubscriptionsPlugin(subscriptionsPluginSource)
+ const sourceDigest = subscriptionsPluginDigest(subscriptionsPluginSource)
+ const desiredStamp = `${sourceDigest}:${SUBSCRIPTIONS_UI_ADAPTER_ID}:${SUBSCRIPTIONS_DSH_PEER_VERSION}:${createHash("sha256").update(readFileSync(subscriptionsUiAdapterPath)).update(readFileSync(resolve(appRoot, "scripts/subscriptions-v4-messages.mjs"))).update(readFileSync(resolve(appRoot, "scripts/prepare-subscriptions-client.mjs"))).update(readFileSync(imageGenerateStylesPath)).digest("hex")}\n`
+ const packageCurrent = (
+ existsSync(subscriptionsPluginTarget)
+ && lstatSync(subscriptionsPluginTarget).isDirectory()
+ && existsSync(subscriptionsPluginStampPath)
+ && readFileSync(subscriptionsPluginStampPath, 'utf8') === desiredStamp
+ )
+ if (!packageCurrent) {
+ rmSync(subscriptionsPluginTarget, { recursive: true, force: true })
+ mkdirSync(resolve(subscriptionsPluginTarget, '..'), { recursive: true })
+ cpSync(subscriptionsPluginSource, subscriptionsPluginTarget, { recursive: true, dereference: true })
+ }
+ validateSubscriptionsPlugin(subscriptionsPluginTarget, true)
+ const adapterChanged = adaptSubscriptionsPlugin(subscriptionsPluginTarget)
+ if (!packageCurrent || adapterChanged) writeFileSync(subscriptionsPluginStampPath, desiredStamp)
+ const peersChanged = stageSubscriptionsPluginPeers()
+ if (packageCurrent && !adapterChanged && !peersChanged) return false
+ process.stdout.write(
+ `Staged ${subscriptionsPluginName}@${subscriptionsPluginVersion} with ${SUBSCRIPTIONS_UI_ADAPTER_ID} for DSH ${SUBSCRIPTIONS_DSH_PEER_VERSION}.\n`,
+ )
+ return true
+}
diff --git a/apps/deepviewer-desktop/scripts/subscriptions-v4-messages.mjs b/apps/deepviewer-desktop/scripts/subscriptions-v4-messages.mjs
new file mode 100644
index 0000000..2a31d48
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/subscriptions-v4-messages.mjs
@@ -0,0 +1,33 @@
+/** Resolve V4 image occurrences before translating into the pinned subscription wire adapters. */
+export async function subscriptionMessages(messages, attachments, signal, offloadedImageText) {
+ const output = []
+ for (const message of messages) {
+ signal?.throwIfAborted()
+ const content = []
+ for (const block of message.content) {
+ if (block.type !== 'image') { content.push(block); continue }
+ if (block.offloaded === true) {
+ content.push({ type: 'text', text: offloadedImageText(block.attachment) })
+ continue
+ }
+ if (!attachments) throw new Error('Subscription image input requires the attachment service')
+ const stored = await attachments.readImage(block.attachment, signal)
+ content.push({ type: 'image', mediaType: stored.ref.mediaType, dataBase64: Buffer.from(stored.data).toString('base64') })
+ }
+ if (message.role !== 'tool') {
+ output.push({ ...message, content })
+ continue
+ }
+ // The plugin's pinned serializers accept legacy result wrappers. This is
+ // request-local only: the authoritative Session remains native V4.
+ output.push({ role: 'user', content: [{ type: 'tool-result', toolCallId: message.toolCallId,
+ content, ...(message.isError === undefined ? {} : { isError: message.isError }) }] })
+ // The pinned adapters flatten tool text; keep result images visible as an
+ // adjacent user image input rather than silently discarding their bytes.
+ const images = content.filter(block => block.type === 'image')
+ if (images.length) output.push({ role: 'user', content: [
+ { type: 'text', text: `Images returned by tool call ${String(message.toolCallId)}:` }, ...images,
+ ] })
+ }
+ return output
+}
diff --git a/apps/deepviewer-desktop/scripts/sync-deepviewer-branding.mjs b/apps/deepviewer-desktop/scripts/sync-deepviewer-branding.mjs
new file mode 100644
index 0000000..4fb6481
--- /dev/null
+++ b/apps/deepviewer-desktop/scripts/sync-deepviewer-branding.mjs
@@ -0,0 +1,132 @@
+import { createHash } from 'node:crypto'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Apply DeepViewer's brand overlay to the already-restored, pinned Harness source.
+const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const upstream = resolve(appRoot, '../../upstream/deepseek-harness')
+const app = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
+const core = JSON.parse(readFileSync(resolve(upstream, 'package.json'), 'utf8'))
+const stamp = resolve(appRoot, '.desktop/deepviewer-branding.sha256')
+const entries = new Map()
+const read = path => readFileSync(resolve(appRoot, path), 'utf8')
+
+// Persist DeepViewer's file-protection presentation over the pinned Harness UI.
+for (const path of [
+ 'src/client/tool/models/tool-call-model.ts',
+ 'src/client/tool/components/ToolRow.tsx',
+ 'src/client/tool/components/ToolRow.module.css',
+ 'src/client/tool/toolviews/file-mutation-row.tsx',
+ 'src/client/tool/toolviews/GenericToolCard.tsx',
+ 'tests/file-protection-tone.client.spec.tsx',
+]) {
+ entries.set(`packages/client/ui-tool/${path}`, read(`upstream-overrides/ui-tool/${path}`))
+}
+
+const skeleton = 'packages/client/ui-conversation/src/client/skeleton'
+for (const name of ['EmptyHero.tsx', 'ConversationContent.tsx', 'DeepViewerMascot.tsx', 'mascot-motion.ts']) {
+ entries.set(`${skeleton}/${name}`, read(`upstream-overrides/ui-conversation/${name}`))
+}
+for (const [file, marker, override] of [
+ ['HeroShell.module.css', 'hide-empty-hero-branding', 'HideEmptyHeroBranding.module.css'],
+ ['ConversationRoot.module.css', 'bottom-align-empty-composer', 'CenteredWelcome.module.css'],
+]) {
+ const source = readFileSync(resolve(upstream, skeleton, file), 'utf8')
+ const start = `/* DeepViewer override:start ${marker} */`
+ const end = `/* DeepViewer override:end ${marker} */`
+ const from = source.indexOf(start)
+ const to = source.indexOf(end)
+ if (from < 0 || to <= from) throw new Error(`Missing welcome overlay boundary: ${file}`)
+ entries.set(`${skeleton}/${file}`, source.slice(0, from + start.length) + '\n'
+ + read(`upstream-overrides/ui-conversation/${override}`).trim() + '\n' + source.slice(to))
+}
+const conversationLocalePath = 'packages/client/ui-conversation/src/client/locales.ts'
+entries.set(conversationLocalePath, readFileSync(resolve(upstream, conversationLocalePath), 'utf8')
+ .replace("'hero.headline': '让我们做点什么'", "'hero.headline': '今天想做点什么?'")
+ .replace("'hero.headline': 'What shall we build?'", "'hero.headline': 'What would you like to do today?'"))
+for (const path of ['packages/client/ui-chat/src/client/locale.ts',
+ 'packages/client/ui-chat/tests/chat-view.client.spec.tsx']) {
+ entries.set(path, readFileSync(resolve(upstream, path), 'utf8')
+ .replaceAll('深度求索中', '深度求索中'))
+}
+entries.set('packages/client/ui-deliverables/src/file-delivery-prompt.ts',
+ read('upstream-overrides/ui-deliverables/file-delivery-prompt.ts'))
+for (const path of ['packages/client/ui-workspace/src/client/locales.ts',
+ 'packages/client/ui-chat/src/client/apply.ts']) {
+ entries.set(path, readFileSync(resolve(upstream, path), 'utf8')
+ .replaceAll('DeepViewer/Projects', 'DeepViewer/Projects')
+ .replaceAll("'DeepViewer: native file preview'", "'DeepViewer: native file preview'"))
+}
+const projectsCommandPath = 'packages/api/workspace-controller/src/commands.ts'
+entries.set(projectsCommandPath, readFileSync(resolve(upstream, projectsCommandPath), 'utf8')
+ .replace('const root = process.env.DEEPVIEWER_PROJECTS_ROOT',
+ 'const root = process.env.DEEPVIEWER_PROJECTS_ROOT ?? process.env.DEEPVIEWER_PROJECTS_ROOT'))
+entries.set('apps/web/public/deepviewer-mascot.png',
+ readFileSync(resolve(appRoot, 'assets/deepviewer-mascot-portrait.png')))
+// Keep the existing resident-input regression suite aligned with the brand
+// overlay; all editor identity and draft-survival assertions remain intact.
+const heroTestPath = 'packages/client/ui-conversation/tests/skeleton.client.spec.tsx'
+entries.set(heroTestPath, readFileSync(resolve(upstream, heroTestPath), 'utf8')
+ .replaceAll('What shall we build?', 'What would you like to do today?')
+ .replaceAll('让我们做点什么', '今天想做点什么?')
+ .replace("expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 150.374 160')",
+ "expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/deepviewer-mascot.png')"))
+
+entries.set('packages/client/ui-settings-general/src/client/AboutSection.tsx',
+ read('upstream-overrides/ui-settings-general/AboutSection.tsx')
+ .replaceAll('__DEEPVIEWER_VERSION__', app.version)
+ .replaceAll('__DEEPVIEWER_BUILD_NUMBER__', String(app.buildNumber))
+ .replaceAll('__DEEPSEEK_HARNESS_VERSION__', core.version))
+entries.set('packages/client/ui-brand-official/src/client/Brand.tsx',
+ read('upstream-overrides/ui-brand-official/Brand.tsx'))
+entries.set('packages/client/ui-settings-general/src/client/AboutSection.module.css',
+ read('upstream-overrides/ui-settings-general/AboutSection.module.css'))
+
+const localePath = 'packages/client/ui-settings-general/src/client/locales.ts'
+let locales = readFileSync(resolve(upstream, localePath), 'utf8')
+for (const language of ['zh', 'en']) {
+ const start = `/* DeepViewer override:start about-deepviewer-locales-${language} */`
+ const end = `/* DeepViewer override:end about-deepviewer-locales-${language} */`
+ const from = locales.indexOf(start)
+ const to = locales.indexOf(end)
+ if (from < 0 || to <= from || locales.indexOf(start, from + start.length) >= 0) {
+ throw new Error(`Cannot locate the pinned About locale block: ${language}`)
+ }
+ const fragment = read(`upstream-overrides/ui-settings-general/AboutLocales.${language}.fragment`)
+ .trim().split('\n').map(line => ` ${line}`).join('\n')
+ locales = locales.slice(0, from + start.length) + '\n' + fragment + '\n ' + locales.slice(to)
+}
+entries.set(localePath, locales)
+entries.set('apps/web/public/deepviewer-icon.png',
+ readFileSync(resolve(appRoot, 'assets/deepviewer-icon-macos26-1024.png')))
+entries.set('apps/web/public/deepviewer-icon-dark.png',
+ readFileSync(resolve(appRoot, 'assets/deepviewer-icon-dark-1024.png')))
+
+let changed = false
+const hash = createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url)))
+for (const [relative, content] of entries) {
+ const target = resolve(upstream, relative)
+ const bytes = Buffer.from(content)
+ hash.update(relative).update(bytes)
+ if (!existsSync(target) || !readFileSync(target).equals(bytes)) {
+ mkdirSync(dirname(target), { recursive: true })
+ writeFileSync(target, bytes)
+ changed = true
+ }
+}
+const digest = hash.digest('hex')
+const built = ['packages/client/ui-settings-general/lib/client.js',
+ 'packages/client/ui-brand-official/lib/client.js', 'apps/web/dist/deepviewer-icon.png',
+ 'apps/web/dist/deepviewer-icon-dark.png', 'apps/web/dist/deepviewer-mascot.png']
+const needsBuild = changed || !existsSync(stamp) || readFileSync(stamp, 'utf8') !== digest
+ || built.some(path => !existsSync(resolve(upstream, path)))
+if (process.argv.includes('--build') && needsBuild) {
+ const result = spawnSync('pnpm', ['run', 'build:official'], { cwd: upstream, stdio: 'inherit' })
+ if (result.error) throw result.error
+ if (result.status !== 0) throw new Error(`DeepViewer client build failed: ${result.status}`)
+ mkdirSync(dirname(stamp), { recursive: true })
+ writeFileSync(stamp, digest)
+}
+console.log(`DeepViewer branding ${app.version} (${app.buildNumber}): ${needsBuild ? 'synchronized' : 'current'}`)
diff --git a/apps/deepviewer-desktop/scripts/sync-upstream-overrides.mjs b/apps/deepviewer-desktop/scripts/sync-upstream-overrides.mjs
index eed098f..b40135e 100644
--- a/apps/deepviewer-desktop/scripts/sync-upstream-overrides.mjs
+++ b/apps/deepviewer-desktop/scripts/sync-upstream-overrides.mjs
@@ -1,722 +1,11 @@
-import { syncDesktopNetwork } from './sync-desktop-network.mjs'
-import { syncChatModeOverrides, chatModePatchPath } from './sync-chat-mode.mjs'
-import { stageBetterSidebar } from './stage-better-sidebar.mjs'
-import { buildReasoningPlugin } from './build-reasoning-plugin.mjs'
-import { execFileSync, spawn } from 'node:child_process'
-import { createHash } from 'node:crypto'
-import {
- cpSync,
- existsSync,
- lstatSync,
- mkdirSync,
- readFileSync,
- realpathSync,
- readdirSync,
- rmSync,
- statSync,
- symlinkSync,
- writeFileSync,
-} from 'node:fs'
-import { dirname, resolve } from 'node:path'
+// Compatibility command for callers of the former overlay pipeline.
+// Product changes are now owned by apps/deepviewer-adapter; old 0.1.7 snapshots are never replayed.
+import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
-import {
- adaptSubscriptionsPlugin,
- SUBSCRIPTIONS_DSH_PEER_VERSION,
- SUBSCRIPTIONS_UI_ADAPTER_ID,
-} from './adapt-subscriptions-plugin.mjs'
-import { prepareSubscriptionsClient, imageGenerateStylesPath } from './prepare-subscriptions-client.mjs'
-
-const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
-const projectRoot = resolve(appRoot, '..', '..')
-const upstreamRoot = resolve(projectRoot, 'upstream', 'deepseek-harness')
-const subscriptionsPluginName = 'dsh-plugin-subscriptions'
-const subscriptionsPluginVersion = '0.3.1'
-const subscriptionsPluginSource = resolve(projectRoot, 'node_modules', subscriptionsPluginName)
-const subscriptionsPluginTarget = resolve(upstreamRoot, 'node_modules', subscriptionsPluginName)
-const subscriptionsPluginStampPath = resolve(upstreamRoot, '.deepviewer-subscriptions-plugin')
-const subscriptionsUiAdapterPath = resolve(appRoot, 'scripts', 'adapt-subscriptions-plugin.mjs')
-const subscriptionsPluginPeers = new Map([
- ['@deepseek-ai/cordis', resolve(upstreamRoot, 'vendor', 'cordis')],
- ['@deepseek-ai/dsh-attachment', resolve(upstreamRoot, 'packages', 'attachment', 'attachment')],
- ['@deepseek-ai/dsh-home-paths', resolve(upstreamRoot, 'packages', 'util', 'home-paths')],
- ['@deepseek-ai/dsh-llm', resolve(upstreamRoot, 'packages', 'llm', 'llm')],
- ['@deepseek-ai/dsh-tools', resolve(upstreamRoot, 'packages', 'core', 'tools')],
- ['@deepseek-ai/schemastery', resolve(upstreamRoot, 'vendor', 'schemastery')],
-])
-const desktopManifest = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
-const desktopVersion = desktopManifest.version
-const desktopBuildNumber = desktopManifest.buildNumber
-const harnessManifest = JSON.parse(readFileSync(resolve(upstreamRoot, 'package.json'), 'utf8'))
-const harnessVersion = harnessManifest.version
-const expectedHarnessCommit = 'fb2c4b9e698e30edb738bca4cf0618587db7d203'
-if (harnessVersion !== '0.1.5-rc.2' || execFileSync('git', ['rev-parse', 'HEAD'], { cwd: upstreamRoot, encoding: 'utf8' }).trim() !== expectedHarnessCommit) {
- throw new Error('DeepViewer requires DSH 0.1.5-rc.2 at the pinned official commit')
-}
-if (typeof desktopVersion !== 'string' || !/^\d+\.\d+\.\d+$/u.test(desktopVersion)) {
- throw new Error(`Invalid DeepViewer version: ${String(desktopVersion)}`)
-}
-if (!Number.isInteger(desktopBuildNumber) || desktopBuildNumber < 1) {
- throw new Error(`Invalid DeepViewer build number: ${String(desktopBuildNumber)}`)
-}
-if (typeof harnessVersion !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(harnessVersion)) {
- throw new Error(`Invalid DeepSeek Harness version: ${String(harnessVersion)}`)
-}
-syncDesktopNetwork(upstreamRoot)
-
-const sourcePath = resolve(
- appRoot,
- 'upstream-overrides',
- 'ui-primitives',
- 'BrandWordmark.tsx',
-)
-const targetPath = resolve(
- upstreamRoot,
- 'packages',
- 'client',
- 'ui-primitives',
- 'src',
- 'BrandWordmark.tsx',
-)
-// DSH 0.1.5 owns right-sidebar tabs, file path resolution, and Chat composition.
-// Extend its current seats instead of restoring the removed details-view API.
-const fileOverrides = [
- { name: 'delivery-alias-validation', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/presented.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/presented.ts') },
- { name: 'dedupe-delivery-chips', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/Deliverables.tsx'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/client/Deliverables.tsx') },
- { name: 'workspace-delivery', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/workspace-delivery.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/workspace-delivery.ts') },
- { name: 'present-delivery-boundary', sourcePath: resolve(appRoot, 'upstream-overrides/tool-present/index.ts'), targetPath: resolve(upstreamRoot, 'packages/fs/tool-present/src/index.ts') },
- { name: 'present-delivery-contract', sourcePath: resolve(appRoot, 'upstream-overrides/tool-present/types.ts'), targetPath: resolve(upstreamRoot, 'packages/fs/tool-present/src/types.ts') },
- { name: 'file-delivery-auto-delivery.host.spec.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/auto-delivery.host.spec.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/tests/auto-delivery.host.spec.ts') },
- { name: 'file-delivery-prompt.host.spec.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/prompt.host.spec.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/tests/prompt.host.spec.ts') },
- { name: 'file-delivery-auto-delivery.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/auto-delivery.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/auto-delivery.ts') },
- { name: 'file-delivery-file-delivery-prompt.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/file-delivery-prompt.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/file-delivery-prompt.ts') },
- { name: 'file-delivery-index.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/index.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/src/index.ts') },
- { name: 'file-delivery-package.json', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/package.json'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/package.json') },
- { name: 'file-delivery-tsconfig.host.json', sourcePath: resolve(appRoot, 'upstream-overrides/ui-deliverables/tsconfig.host.json'), targetPath: resolve(upstreamRoot, 'packages/client/ui-deliverables/tsconfig.host.json') },
- { name: 'native-theme-preference', sourcePath: resolve(appRoot, 'upstream-overrides/ui-layout/theme-presenter.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-layout/src/client/theme-presenter.ts') },
- { name: 'progressive-process-tests', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/progressive-process.client.spec.tsx'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/tests/progressive-process.client.spec.tsx') },
- { name: 'progressive-progressive-process.ts', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/progressive-process.ts'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/progressive-process.ts') },
- { name: 'progressive-ProgressiveChatNodeList.tsx', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ProgressiveChatNodeList.tsx'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ProgressiveChatNodeList.tsx') },
- { name: 'progressive-ProgressiveChatNodeList.module.css', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ProgressiveChatNodeList.module.css'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ProgressiveChatNodeList.module.css') },
- {
- name: "deepviewer-brand-slot-components",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-brand-official/Brand.tsx"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-brand-official/src/client/Brand.tsx"),
- },
- {
- name: "deepviewer-brand-slot-registration",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-brand-official/index.ts"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-brand-official/src/client/index.ts"),
- },
- {
- name: "document-root-portal-primitive",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-primitives/Portal.tsx"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-primitives/src/Portal.tsx"),
- },
- {
- name: "global-settings-shell",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-settings-general/SettingsRoot.tsx"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-settings-general/src/client/SettingsRoot.tsx"),
- },
- {
- name: "global-settings-layout",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-settings-general/SettingsRoot.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-settings-general/src/client/SettingsRoot.module.css"),
- },
- {
- name: "about-deepviewer-section",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-settings-general/AboutSection.tsx"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-settings-general/src/client/AboutSection.tsx"),
- template: true,
- },
- {
- name: "about-deepviewer-layout",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-settings-general/AboutSection.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-settings-general/src/client/AboutSection.module.css"),
- },
- {
- name: "about-deepviewer-icon",
- sourcePath: resolve(appRoot, "assets/deepviewer-icon-macos26-1024.png"),
- targetPath: resolve(upstreamRoot, "apps/web/public/deepviewer-icon.png"),
- },
- {
- name: "EmptyHero.tsx",
- sourcePath: resolve(appRoot, "upstream-overrides/dsh-015/EmptyHero.tsx"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx"),
- },
-]
-const cssOverrides = [
- { name: 'session-drive-loader', sourcePath: resolve(appRoot, 'upstream-overrides/ui-workspace/SessionDrive.module.css'), targetPath: resolve(upstreamRoot, 'packages/client/ui-workspace/src/client/rows/Rows.module.css') },
- { name: 'progressive-status', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ProgressiveStatus.module.css'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.module.css') },
- {
- name: 'thinking-status-neutral-shimmer',
- sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ThinkingStatus.module.css'),
- targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.module.css'),
- },
- {
- name: 'active-composer-bottom-spacing',
- sourcePath: resolve(appRoot, 'upstream-overrides/dsh-015/InputBarBottomSpacing.module.css'),
- targetPath: resolve(upstreamRoot, 'packages/client/ui-conversation/src/client/skeleton/InputBar.module.css'),
- },
- {
- name: "hide-empty-hero-branding",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/HideEmptyHeroBranding.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css"),
- },
- {
- name: "bottom-align-empty-composer",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/BottomAlignEmptyComposer.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css"),
- },
- {
- name: "structural-macos-safe-areas",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-layout/DesktopSafeAreas.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-layout/src/client/AppFrame.module.css"),
- },
- {
- name: "center-sidebar-wordmark",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-sidebar/BrandWordmark.module.css"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-sidebar/src/client/SidebarRoot.module.css"),
- },
-]
-const textOverrides = [
- { name: 'search-drive-title', sourcePath: resolve(appRoot, 'upstream-overrides/ui-workspace/search-drive-title.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-workspace/src/client/rows/Rows.tsx'), needle: '{result.title} ', indent: '', markerKind: 'raw' },
- { name: 'session-drive-title', sourcePath: resolve(appRoot, 'upstream-overrides/ui-workspace/session-drive-title.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-workspace/src/client/rows/Rows.tsx'), needle: '{title} ', indent: '', markerKind: 'raw' },
- { name: 'session-drive-loader', sourcePath: resolve(appRoot, 'upstream-overrides/ui-workspace/SessionDrive.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-workspace/src/client/rows/Rows.tsx'), needle: ' ', indent: '', markerKind: 'raw' },
- { name: 'process-en', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/process-en.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/locale.ts'), needle: " 'view.chat': 'Chat',", indent: '', markerKind: 'raw' },
- { name: 'process-zh', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/process-zh.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/locale.ts'), needle: " 'view.chat': '\u5bf9\u8bdd',", indent: '', markerKind: 'raw' },
- { name: 'progressive-status-wait', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/status-wait.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.tsx'), needle: "{running && }", indent: '', markerKind: 'raw' },
- { name: 'progressive-list-props', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/list-props.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.tsx'), needle: " s.order)", indent: '', markerKind: 'raw' },
- { name: 'progressive-pending-hook', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/pending-hook.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.tsx'), needle: " useTranscriptView, useProjection, t,", indent: '', markerKind: 'raw' },
- { name: 'progressive-list-route', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/list-route.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.tsx'), needle: "const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) {\n return order.map", indent: '', markerKind: 'raw' },
- { name: 'progressive-list-import', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/list-import.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatView.tsx'), needle: "import { ChatNodeSeat } from './ChatNodeSeat.tsx'", indent: '', markerKind: 'raw' },
- { name: 'progressive-seat-project', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/seat-project.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx'), needle: " const routedNode = node as ChatNode | undefined", indent: '', markerKind: 'raw' },
- { name: 'progressive-seat-arg', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/seat-arg.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx'), needle: " nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,", indent: '', markerKind: 'raw' },
- { name: 'progressive-seat-prop', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/seat-prop.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx'), needle: " readonly nodeKey: string", indent: '', markerKind: 'raw' },
- { name: 'progressive-seat-import', sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/seat-import.fragment'), targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx'), needle: "import css from './ChatView.module.css'", indent: '', markerKind: 'raw' },
- {
- name: 'thinking-status-en',
- sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ThinkingStatus.en.fragment'),
- targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/locale.ts'),
- needle: " 'chat.deepDiving': 'Deep diving...',",
- indent: ' ', markerKind: 'code',
- },
- {
- name: 'thinking-status-zh',
- sourcePath: resolve(appRoot, 'upstream-overrides/ui-chat/ThinkingStatus.zh.fragment'),
- targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/locale.ts'),
- needle: " 'chat.deepDiving': '深度求索中...',",
- indent: ' ', markerKind: 'code',
- },
- {
- name: 'scoped-rpc-webserver',
- sourcePath: resolve(appRoot, 'upstream-overrides/dsh-015/scoped-rpc-webserver.fragment'),
- targetPath: resolve(upstreamRoot, 'packages/client/connection/src/rpc-host.ts'),
- needle: ' () => owner.webServer.register(route),',
- indent: ' ', markerKind: 'code',
- },
- {
- name: 'official-preview-native-bridge',
- sourcePath: resolve(appRoot, 'upstream-overrides/dsh-015/official-preview-native-bridge.fragment'),
- targetPath: resolve(upstreamRoot, 'packages/client/ui-chat/src/client/apply.ts'),
- needle: 'export function apply(ctx: Context): void {\n',
- indent: ' ', markerKind: 'code', preserveNeedle: true,
- },
- {
- name: "external-client-workspace-manifest",
- sourcePath: resolve(appRoot, "upstream-overrides/build/ExternalWorkspaceManifest.ts.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/tsdown.client.ts"),
- needle: " if (cached !== undefined) return cached\n",
- indent: " ",
- markerKind: "code",
- preserveNeedle: true,
- },
- {
- name: "document-root-portal-export",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-primitives/PortalExport.ts.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-primitives/src/index.ts"),
- needle: "export { Modal } from './Modal.tsx'\n",
- indent: "",
- markerKind: "code",
- preserveNeedle: true,
- },
- {
- name: "deepviewer-hero-leaves-composer",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/HeroWelcomeMoved.tsx.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx"),
- needle: " {hero && }",
- indent: " ",
- },
- {
- name: "deepviewer-hero-scroll-seat",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/HeroWelcomeSeat.tsx.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx"),
- needle: " \n",
- indent: " ",
- preserveNeedle: true,
- },
- {
- name: "deepviewer-hero-headline-zh",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/HeroHeadline.zh.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/locales.ts"),
- needle: " 'hero.headline': '探索未至之境',",
- indent: " ",
- markerKind: "code",
- },
- {
- name: "deepviewer-hero-headline-en",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-conversation/HeroHeadline.en.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-conversation/src/client/locales.ts"),
- needle: " 'hero.headline': 'Into the Unknown',",
- indent: " ",
- markerKind: "code",
- },
- {
- name: "structural-main-safe-area",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-layout/CenterColumn.tsx.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-layout/src/client/AppFrame.tsx"),
- needle: "function CenterColumn(props: { children?: ReactNode }) {\n return
{props.children}
\n}",
- indent: "",
- markerKind: "code",
- },
- {
- name: "structural-sidebar-safe-area",
- sourcePath: resolve(appRoot, "upstream-overrides/ui-layout/SidebarSafeArea.tsx.fragment"),
- targetPath: resolve(upstreamRoot, "packages/client/ui-layout/src/client/AppFrame.tsx"),
- needle: "