# 手机界面（界面半边详解）

> `dsh-zen-remote` 的界面半边完整文档：信息架构、断点策略、安全区体系、调试徽章、兼容插件。
> 安装与快速上手见[根 README](../README.zh-CN.md)；通道侧细节见 [remote-access.md](remote-access.md)。
> 界面基座衍生自 MIT 的 [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)。

## v2.0：从「窄屏适配」到「app 化外壳」

v1.0.0（fork 自 [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)）做的是「桌面布局压成窄屏」——保留桌面那套抽屉/侧栏，靠 CSS 把它们塞进手机宽度。v2.0 把手机端重写成「两级页面栈」的 app 信息架构：启动直接落会话列表主屏，点进去是独立的会话页，两页之间横向推入/推出，不再是「桌面页面的缩小版」。改造范围只到手机断点（<768px）；平板保持 v1.0.0 抽屉行为不倒退，桌面逐像素不变。

这次重写建立在上游 v1.0.0 打下的底子上（分主题 CSS 结构、构建脚本、平板兼容段大量沿用），在此向 [mexiaosqwq](https://github.com/mexiaosqwq/dsh-web-mobile) 的原始工作表示感谢。

## 2.0：远程会话与设置区块

2.0 起插件有「一个插件、两种角色」，界面半边随之多出两组部件：主服务端的共享部件与子客户端的远程部件。它们的共同点是**都注册在桌面端门（`dshDesktop`）之外**——见本节末尾的清单与缘由。

### 插件设置区块

设置区块挂在 DSH「插件」页的 `plugins.row.config` 槽位上（`src/client/settings/`），是 2.0 唯一的配置与管理界面：

- **主服务端区块**：角色（角色卡里带两角色共用的**设备名称**字段，即 `serverName`：host 上是其他设备侧边栏分组标题里的名字，与子客户端双向自动同步）→ 网关与反代（host/port/可信反代/限流）→ 配对（先选设备角色、显示书面指引，再生成）→ 设备列表（名称、角色、最近在线、是否订阅推送；改名 / `set-role` / 吊销）→ 推送（各策略字段 + 测试推送）→ 远程共享（闲置时长、自动开启、已共享会话列表与一键全关）；
- **子客户端区块**：角色（设备名称同一字段，作为配对时注册到服务端的名字）→ 服务端地址与配对（配对按角色卡里的设备名称注册，名字非法时配对按钮禁用）→ 连接状态（已连接 / 离线 / 版本有差异 / 令牌已吊销，可解配）→ 诊断（接口指纹差异组、运行时不兼容记录）；
- **经网关打开时只读**：手机或其它浏览器经网关打开设置页可以看，但生成配对码、改角色、吊销这些管理动作被拒绝（带 `x-zen-remote-via` 标记的请求一律拒绝 POST）——设备丢了也没法用它再配出新设备；
- 角色判定读的是 configForms 行文档里的已保存角色；行文档答不上来时回退探测 `/_dsh/mobile-nav/client-config`（该路由两角色都注册，报的是合并后的生效角色）。

- **插件桥（2.1）**：远程共享组之后多一个小节「插件桥」——主服务端区块里逐插件列出已登记的名字与版本：只含会话数据的标注「随会话共享转发」；含整机数据的给开关「允许转发整机数据」（缺省关），旁注「打开后，已配对的桌面应用端可在远程会话里读取该插件的整机数据」。开关走与其它字段同一条 staged 表单通道（提交整张 `pluginBridgeConsent` 表，只动该插件的键），保存后列表自动重拉（数据来自 `GET /_dsh/zen-remote/admin/plugin-bridge`，经网关打开时拒绝）。子客户端区块的诊断组里逐插件一行「名字 · 本机版本 · 状态」，状态文案：已桥接 / 服务端未安装 / 版本不同（服务端 x.y.z）/ 声明不一致 / 服务端主人未允许整机数据 / 中继离线 / 服务端 zen-remote 版本过旧；本机改道未启用时另加一行「本机改道未启用：<原因>」。文案与判定逻辑都是纯函数（`src/client-data/plugin-bridge.ts`）。

### 主服务端：共享部件

- **标题行远程图标**（`conversation.session.header.actions`，order 25，排在官方任务入口之后）：灰色为未开启，亮色为已开启，亮色带小圆点表示此刻有桌面应用端正在查看；悬停显示剩余闲置时间；点击开关当前会话的远程，带二次确认。
- **会话「…」菜单项**（`sidebar.workspaces.session.menu.item`，order 500，官方归档行之下、分组细线之后）：侧边栏会话行的「…」菜单里直接开启 / 关闭该会话的远程。
- 这两组部件只在 host 角色渲染与轮询，角色判定与设置页同一套两级决策，角色切换回来时自动恢复。

### 子客户端：远程部件

- **远程分组**：侧边栏在本地工作区后面按「服务端名 · 工作区名」多出几组，默认收起，里面是服务端已开启远程的会话；服务端的工作区只有其中至少一个会话开启了远程才会成组——没动过的工作区不显示成空分组，已显示过的分组里会话全部关闭后空分组留到刷新页面（DSH 界面对移除过的工作区 id 不再接受，见根 README 已知限制）；服务端离线时分组标注「（离线）」、输入禁用、后台退避重连；解配 / 吊销保留分组只换标注，这两种状态同样禁用输入、也不等自动重连（只能从设置页重新配对才恢复）；仅当换到另一台服务端才整组移除；
- **标题行连接图标**（同一槽位，order 26）：每个远程会话的连接状态——在线 / 离线 / 版本有差异；点击可关闭该会话的远程（二次确认，文案写明「关闭后需要在服务端重新开启」）；
- **产物下载**：产出台的卡片（`deliverables.file.actions`，order 40）与文档预览头（`sidebar.right.tab.document.actions`，order 40）各多一个「下载到本机」。远程会话里官方只有「预览」和「在服务端机器上打开」，产物此前无法落到子客户端磁盘——这个按钮用中继已放行的 `workspaceFiles/readBytes` 按窗口（默认 2 MiB，宿主的 `maxBytes` 更小时按 `details.limit` 自动缩小）读回字节，再由浏览器落盘；卡片上的入口从卡片自己的预览按钮 `title`（宿主 `resolveWorkspacePath` 的绝对路径）取路径，预览头直接用 owner 给的 `absolutePath`。两个入口都只在虚拟 id 会话渲染，主服务端与本地会话逐像素 no-op。同一版起 Word/Excel/PPT 预览改由**服务端** LibreOffice 出 PDF（`officeToPdf/render` 登记进中继白名单，PDF 字节走 `INVOKE_BINARY_RESULT_METHODS` 标记），不再报「此功能暂不支持远程会话」。
- **composer 横幅**（`conversation.input.dock`，order 15）：四种状态——会话被休眠或服务端关闭时显示「远程已关闭」及原因；服务端离线显示「暂时只读，恢复连接后自动可用」；令牌被吊销、解除配对也显示横幅（这两者不会自己恢复，文案指向设置页重新配对）。横幅出现的同时**输入一律禁用**，禁用走宿主自己的 composer-block 契约（`ctx.conversation.blocks.set(sessionId, { reason })`，输入框、发送钮与模型控件一起禁掉，`reason` 成为占位文案），链接恢复或组件卸载时清除，不碰宿主 DOM；
- 面板长尾：远程会话里带会话 id 的调用自动走中继；改动摘要 / diff 是例外路径——它们不走 typert 网关而是 plain-HTTP GET，浏览器侧的 fetch 包装把带虚拟 id 的 `/api/changes.*` 请求改写到子客户端路由 `/_dsh/zen-remote/client/http/<route>` 再经中继；离线时写一律本地拒绝，`terminal/follow` 也按写处理（它的附带会接管终端的输入控制权，离线放行等于悄悄抢走一条不在线连接的控制权，所以离线即拒绝）；认不准数据来源的面板直接隐藏，而不是显示本机错误数据（清单见根 README「已知限制」）。
- **关闭的会话与断线恢复**（CP4 / CP4-client-fix2）：服务端关闭某会话的远程（或它被闲置休眠扫掉）时，该会话**从侧边栏列表隐藏**——按「已归档」处理（宿主 `sessionVisible` 在默认筛选下不显示 archived 里的会话），打开「显示已归档」筛选仍能找到。唯一的例外是此刻正打开的会话页：页面停在原处——关闭不重试，已出过帧的会话流也不再报错，静默保持到页面自己关掉它（宿主把会话流的任何终止错误都渲染成对话区顶部的「历史加载失败：…」，而关闭原因只该由横幅表达）；若之后服务端重新共享、又有对它的调用成功，关闭记录随之清掉，这条流改为**正常结束**——宿主把它当作载具恢复、带着新历史自动重开。零帧的会话流、其他面板的流以及对它的 typert 单次请求仍以错误结束，但文字统一为「远程已关闭」，不透出服务端的内部报错（改动摘要 / diff 走的 plain-HTTP 转发例外：那里仍是只带错误码的 403）。composer 横幅显示「远程已关闭」及原因，输入经 composer-block 禁用；当前会话绝不进 archived（否则宿主守卫 `clearArchivedCurrent` 会把正打开的页踢回首页，横幅就无从显示），用户切到别的会话后（当前会话信号来自宿主持久化的选择存储，逐帧读取、另有 1 秒轮询兜底）它才转为隐藏。服务端重新共享（id 回到某工作区的 `sessionIds`）即从 archived 移出、回到正常位置。服务端断线期间已打开的远程会话页保持原状：会话流与任务列表类面板在中继恢复后自动续上——已出过帧的流以干净 end 结束，宿主界面把它当作载具错误立即重开、拿到恢复后的新快照；零帧的原位重开，重开同样走退避（1 秒起翻倍、上限 30 秒）。「远程已关闭」与「暂时只读」是真判决，不在续上之列；文件树变更与终端面板断线后需重新打开。

这些部件的数据源是 `GET /_dsh/zen-remote/client/remote-status`，该路由**两种角色都注册**（host 上应答空的 `{state:'unpaired'}` 结论，桌面端 App 里切角色不用换代码路径）；client 前缀下其余路由（配对代理、连接状态、立即重连、解配、plain-HTTP 转发）只在 client 角色挂载，host 角色对它们一律 404（最小暴露面）。

### 桌面端门之外生效的部件清单

手机外壳有一个硬门：客户端检测到宿主的 `dshDesktop` 接口（0.2.0 桌面端窗口都有）时，`<768px` 的 app 化外壳**整体不生效**，与窗口宽度无关——桌面端窗口拉到多窄都不会变成手机布局。这扇门在 `src/client/compat/desktop.ts`，以下部件**有意留在门外**（在门判定之前注册），否则在桌面端里会跟着一起消失：

| 部件 | 槽位 | 出现在哪些角色 |
| --- | --- | --- |
| 插件设置区块 | `plugins.row.config` | 两角色（内容按角色不同） |
| 标题行远程图标 + 会话「…」菜单项 | `conversation.session.header.actions` / `sidebar.workspaces.session.menu.item` | host |
| 标题行连接图标 + composer 横幅 | 同上槽位 / `conversation.input.dock` | client（仅远程会话渲染） |
| 产物下载按钮 | `deliverables.file.actions` / `sidebar.right.tab.document.actions` | client（仅远程会话渲染） |
| 远程分组（流合并进侧边栏） | `workspace/follow` 全局流的运行时包装 | client |

门外留下的只有这些；手机外壳本体（两级页面栈、composer 重排、手势、调试徽章）仍受门与 `<768px` 断点的双重约束，桌面 ≥1024px 逐像素 no-op 的硬约束不变。

## 效果

| 会话列表主屏 | 会话页 | 会话信息卡 | composer 权限 sheet |
| --- | --- | --- | --- |
| ![会话列表主屏](../assets/home.png) | ![会话页](../assets/session.png) | ![会话信息卡](../assets/info.png) | ![composer 权限 sheet](../assets/sheet.png) |

## 特性

### 会话列表主屏

启动永远落在这一屏（推送深链直达会话除外），不再是桌面抽屉的窄屏版本：

- 会话按圆角卡片排列（左侧头像色块 + 标题/状态两行 + 右侧时间），运行中的会话带状态点；
- 顶部就是当前 workspace 名，点开弹出切换单（含「全部」）；
- 会话列表上方一条可横滑的插件入口 chips（文件浏览、用量、Session log 等，按你实际装的插件动态出现）：手写适配的几个之外，**任何在官方侧栏注册了快捷入口的插件都会被自动发现**，装上就长出对应 chip，卸载后自动消失，不用等插件适配更新；行尾「···」打开显隐自定义单，选择保存在本地，刷新后还在；
- 右下「＋ 新建会话」文字胶囊，点按直接在当前 workspace 建会话；
- 右上角设置图标是官方设置弹窗的入口（迁到这里，不再挤在抽屉里）。

### 两级页面栈与转场

会话列表（主屏）与会话页是两个独立层，横向推入/推出转场；进会话页点行，回列表点头部返回按钮或左缘右滑。官方侧栏在手机断点下直接隐藏，不再是「点开的抽屉」——因为已经没有抽屉需要点开了。

### 会话页头部五件套

会话页头部只留五样东西：返回 · 会话名（+ 运行状态点）· 当前视图名（对话/轨迹，带双点指示）· 信息卡入口 · 侧栏入口。官方的 Chat/Trajectory 页签视觉隐藏但 DOM 还在，点信息卡里的分段控件相当于代点它。

侧栏入口按「现场有哪个侧栏」三态路由（2026-09-11 DSH 0.1.5 长出官方右侧栏之后定的规矩；2026-09-21 起官方优先，issue #11 / PR #12）：宿主有右侧栏（0.1.5+）就代点官方角位的展开钮 / 面板里的收起钮——装没装 dsh-better-sidebar 都一样，因为 better-sidebar 0.19 起自己不画右栏了，它注册的全部 tab（文件树 / 文件变动 / 终端 / 浏览器 / 第三方 `registerTab()`）都进了官方右侧栏，而这个面板在手机上由宿主自己切成全屏、自带收起钮；0.19 只剩一个底部工作台（`[data-dsh-bottom-toggle]` / `_bottomPanel`），手机上不开放。没有官方右栏但装了老版 better-sidebar（≤ 0.18，DSH < 0.1.5）就代点它自己的开关（原设计）；两者都没有就不渲染。无论哪种，官方角位的 ExpandButton 与 better-sidebar 自己的按钮在手机端都隐藏——头部只有我们这一枚侧栏钮。锚点、探测和代点集中在 `src/client/sidebar-panels.ts`，头部按钮、左缘回退手势、文件树 @ 引用后自动收起三处共用；全部锚在稳定 DOM 标记（`[data-sidebar-right-panel]` 及两个开合控件的 `data-sidebar-right-*`、老版的 `[data-dsh-better-sidebar]`），不看语言、不看类名散列。

### composer 重排

输入区整个按 app 输入条的思路重排：

- 底排控件全部图标化（附件 · 权限 · 模型 · 上下文环 · 发送），不再是挤在一起的文字按钮；
- 权限、模型这两个官方弹出菜单被 CSS 整成从底部升起的 sheet 样式（结构还是官方的，只换了皮肤）；
- `conversation.input.dock` 里的入口（git 分支等）压成一行可横滚的 mini chips，挪到输入卡外上方；
- 例外是 DSH 原生的待办卡片（`data-testid="todo-panel"`）：它不是 chip 而是可就地展开的卡片，因此单独豁免出 26px 胶囊笼子，保持整行宽度与自然高度——展开后的列表由官方自己限高 180px 并内部滚动，不会顶到输入框；
- composer 顶部不画分割线，改用渐变 mask——消息滚动到顶部/底部时自然淡入淡出，比一条硬边界更贴近原生 app。

### 会话信息卡

会话页头部的 ⓘ 打开一张底部信息卡，把原来占位置的统计栏收纳进来：轮数/步数/首字延迟/模型耗时/工具耗时/token 与缓存命中六格统计、Chat/Trajectory 切换、以及导出日志/重命名/Fork 会话/归档四个操作。

### 回合过程折叠

Chat 视图里，同一回合内的推理块、工具调用块默认折叠成一条「过程 · N 步」摘要行，点开才展开；最终回复文本永远直接可见。运行中的回合摘要行会跟着步数实时更新。默认仅手机断点生效；插件行配置 `config.turnFoldDesktop: true` 可让所有客户端在任意宽度下启用折叠（见 README 配置一节），单个浏览器也可以访问一次 `?mobile-nav-turn-fold=1` 自行开启（按浏览器记忆，`=0` 关闭）。

### 手势

- 会话页左边缘往右滑 = 返回列表（有浮层或工作台开着的话先关掉它）；
- 各类底部 sheet（信息卡、chips 自定义单、权限/模型菜单）支持下滑关闭。

**安卓系统返回手势（2026-08-20）**：安卓边缘内滑就是浏览器的「后退」，而手机端的
两级页面栈原本是纯 store 状态、从不压历史记录，于是后退发现无处可退 → 直接退出
PWA，再进来就是冷启动。插件自己的边缘手势也触发不了——那几十像素被系统手势先吃
掉，网页收不到 touch。

系统手势**拦不掉**（`systemGestureExclusionRects` 是原生 App 的 API），只能接住：
`history-nav.ts` 维护一个「可关闭层」栈并镜像进 `history`。进会话压一层、开信息卡
再压一层，系统返回于是变成「先关信息卡、再退回列表」，在列表按返回才退出应用（根
页面本该如此）。官方对话框、composer 的权限/模型 sheet、第三方面板由
`effects/modal-back.ts` 统一接管：它们的开关状态都锁在各自组件里没有公开 setter，
但共享一个 DOM 契约——打开时是 `[aria-modal="true"]`、按 Escape 关闭，所以按契约
兜底即可，不必为每个插件写适配。

纪律：**层只能单向关闭**。组件不自己把状态改成关，而是调 `popLayer()` 回退历史，
由 `popstate` 去执行 `close`。两头各改各的必然错位，错位表现为「返回手势没反应」。
排除自有弹层（`:not([data-mobile-nav])`）也是同一个道理——信息卡曾经被显式层和
通用观察器各登记一次，一次返回退两层，历史退到根而会话页还在屏幕上。

### 附件上传

手机外壳（<768px）底栏左侧常驻本插件的回形针按钮（`[data-mobile-nav="attach"]`；子智能体会话不显示，与官方一致——官方在子智能体会话里也禁用自己的文件选择）。点按直接拉起**手机本地**的系统选择器（iOS 上是相册/拍照/选取文件三选一，支持多选，单文件默认上限 20 MB）。图片和文件一视同仁：都上传到会话工作目录的 `.dsh-uploads/`，composer 上方出现可删除的预览 chip（图片缩略图、文件图标+文件名），并把 `@.dsh-uploads/文件名` 追加到输入框——发不发、什么时候发，由你按官方发送键决定，不会替你自动发出。

接收端点的完整契约：`POST /_dsh/mobile-nav/upload?session=<会话id>&name=<文件名>`，请求体是文件原始字节流（无 multipart 包装）。服务端把文件落盘到该会话工作区的 `.dsh-uploads/`（目录内自动写入自忽略的 `.gitignore`，内容 `*` + `!.gitignore`，已存在则不覆盖），返回相对路径；浏览器半边拿到路径后往草稿追加 `@.dsh-uploads/<文件名>` token，草稿上方的 `conversation.input.dock` 同步渲染缩略图/文件名预览条，点 `×` 或删掉正文里的 `@...` 即移除。

**为什么常驻自研按钮（#31 修复）**：DSH 0.1.5 起官方工具行自带了一枚独立回形针（原生附件流，文件落宿主机 `~/.dsh/attachments`），手机上出现过两枚回形针，插件于是按「运行时探测到官方工具行 `_tools` 下隐藏的 `<input type="file">` 就整体让位」处理。顺带修正一个旧说法：`<input type="file">` 在浏览器/PWA 里弹的是**当前客户端设备**的文件选择器，不是宿主机桌面的弹窗。真正压垮让位方案的是 DSH 0.2.x：独立回形针挪进了 `+` 命令菜单，隐藏的 `<input type="file">` 却留在 `_tools` 里——让位探测从此永远命中，手机输入区一枚回形针都不剩（issue #31）。修复后手机外壳不再让位：自研回形针常驻，走自己的上传路由，各版本 DSH 下体验一致；DSH ≤ 0.1.5 官方仍渲染的第二枚独立回形针由 CSS 在 <768px 隐藏，行内始终只有一枚。桌面端（≥768px）一切照旧，官方回形针照常使用。

安全上的几句话：这条 HTTP 路由只接受同源请求，文件名清洗后只取叶子名，写入路径校验不允许越出工作目录，单文件默认上限 20MB（可在 profile 里的插件行用 `maxUploadBytes` 调整）。远程访问时，这条路由和其它请求一样在网关半边的配对墙之后。

### 远程会话拖拽附件（2.2.6）

桌面端把文件从访达拖进会话输入区时，宿主有一条「同机零拷贝」捷径：Electron 的 `__DSH_HOST_PATHS__` 能读出拖入文件的磁盘路径，`addFiles` 于是不传字节、直接插一条 `@本机绝对路径` 的引用。远程会话里这个路径是**子客户端机器**的路径，服务端的会话永远读不到，引用落地就是坏的。2.2.6 起浏览器半边在 document 上装了一个 **capture 阶段**的 drop 拦截（`src/client/effects/remote-drop.ts`，判定纯函数在 `src/client-data/remote-drop.ts`）：当前会话是 `zr~` 虚拟 id 时，把拖入的文件重包装成不带磁盘路径的 `File`（`new File([f], …)`，Electron 对 JS 构造的 File 返回空路径），塞进新的 `DataTransfer` 后在 document 上重派发一个合成 drop 事件——宿主自己的 drop 管道（mask 清理、busy 拒绝、目录判定、addFiles 分支）原样接手，把它当普通浏览器拖拽走字节上传，下游由既有的 fetch-route 拦截经中继转服务端暂存。合成事件 `isTrusted === false`，拦截器自己不会再处理它，天然防自重入。

三重触发门，任一不满足就零干预（`passthrough`）：`__DSH_HOST_PATHS__` 存在（手机 / 纯浏览器宿主本来就走上传，不装拦截）、当前会话是远程虚拟 id（当前会话读 `localStorage['dsh.sessions.current']`，事件发生时现场读；读不到按本机会话处理——误判方向安全：最坏把本机拖拽当成远程多传了字节，不会丢数据）、事件是受信的。拖入**文件夹**时整批拦下（文件夹没法用单文件上传修），出一条 3 秒自动消失的提示（locale key `remoteDropFolderBlocked`）。`webkitGetAsEntry` 不可用时视为无文件夹（与宿主同一默认）。重包装或 `DataTransfer.items.add` 抛错时放行原事件回归现状——宁坏勿丢。

降级：`DragEvent` 不可用或 `DataTransfer` 不可构造时**整体不装**，行为与未安装本功能完全一致。诊断：设置页（子客户端角色）诊断区新增「远程拖拽守卫」一行，显示安装状态与拦截 / 重派发 / 拦下文件夹 / 放行 / 重包失败五个计数（`remoteDropDiagnostics()`，浏览器端计数，渲染时现读）。

### 分享图

信息卡操作行的第五枚按钮「分享图」把当前会话导出成可保存/转发的 PNG 长图（issue #7：手机上滚动截图拼长图太麻烦）。范围可选「全部对话」或「最近 3/5/10 轮」——后者在服务端裁剪，长会话只传尾部数据；轮次按**真提问**计数（复刻宿主 next-step 收件箱状态机：轮中的插话/授权回复留在轮内、不另起一轮），与 Chat 视图的肉眼轮次一致。

生成链路：host 侧新路由 `GET /_dsh/mobile-nav/share-export` 从**完整事件日志**折叠出人类视角的转写（append-origin 折叠：模型可见 surface 在替换落定后会抹掉用户已看过的内容，人类转写不能用；只留人类输入与助手正文，推理/工具调用不进图，图片附件 v1 以占位呈现）→ 手机端离屏渲染一张全内联样式的分享卡（头部条带标题/模型/日期/轮数，用户气泡与助手正文，品牌脚注；主题色读宿主 CSS 变量，深浅色自动跟随）→ 按像素预算分片栅格化后在客户端**流式拼接成一张 PNG 长图**（纯 JS 拼接器 + `CompressionStream`，逐片解码逐行写扫描线，任何时刻不整持整图像素）→ 系统分享面板单文件交付，浏览器不支持拼接时退化为多张分片下载。助手正文按 **markdown 子集**渲染（标题/粗斜体/行内代码/链接/删除线/列表/引用/表格/围栏代码块；自写解析器，未闭合标记回退字面量，不出星号乱码），用户消息保持纯文本。

分片与降级：每片物理像素 ≤ 12M（iOS canvas 面积上限 16.7M 留安全余量），像素比 2→1.5→1 按整卡总量逐级降级、拼接模式下全卡统一；仍超过 24 片则**保尾去头**——保留最后 24 片，首片标注「内容过长，已省略前 X 轮」（分享的对象是最近的对话，丢尾与语义相反）。宽内容（长代码行、宽表格）永不横向裁剪：任一片需要时整卡放宽到 ~780px 逻辑宽渲染（所有片同宽，扫描线才能首尾相接），再宽就整体几何缩放输出（图片查看器捏合缩放可复原可读性，完整性优先于字号）。

边界：空会话按钮置灰（未知统计时点击后由空数据兜底提示）；回合流式中允许生成，语义是「当前进度快照」；组合里没有 sessionQuery 服务的宿主上路由不挂载，客户端显示「需要新版 DSH」。该路由只读、`no-store`，远程访问时在网关半边的配对墙之后。桌面 ≥1024px 无任何变化——信息卡本身就是手机端专属 UI。调试参数 `?mobile-nav-share-preview=1` 在页面顶部渲染分享卡（无会话时用内置 fixture）并可驱动完整导出管线下载 PNG。

### 设置页与用量面板打通

主屏右上角的设置图标就是官方设置弹窗的入口（近全宽 sheet 皮肤，沿用 v1.0.0 的适配）；如果你装了 dsh-usage-stats，它的用量/余额入口会作为一个 chip 出现在主屏 chips 行里，点开即用，不用先钻进设置。

### 调试徽章

手机主屏顶栏连续点 5 下（或访问 `?mobile-nav-debug=1`）唤出悬浮诊断条，显示 URL/视口/媒体查询/浮层状态/JS 错误，手机端问题取证用。桌面调试时可以用 `?mobile-nav-inset=54` 或 `?mobile-nav-inset=54,34` 伪造安全区（假刘海），在没有真实刘海屏的浏览器里也能复现顶部/底部安全区相关的布局。

### 安全区体系与 iOS 26.x 视口缩水

页面各表面的安全区留白统一收进两个可注入的 CSS 变量（`--mnav-sat`/`--mnav-sab`），平时读 `env(safe-area-inset-*)`，调试参数在时可以整体假冒——这是后面两层缓解的基础。

在这套变量之上，iOS 26.x 的 Safari/PWA 上出现了一个已知系统缺陷：独立（加到主屏）PWA 第一次弹出软键盘后，布局视口会永久性地比屏幕矮一截（`innerHeight`/`visualViewport.height`/`100dvh` 一起变小），直到整个 app 被彻底退出重开。这不是本插件的 bug，社区已有记录（[参考文章](https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d)），本插件只做了两层缓解，不是根治：

1. **视口下沉检测**——检测到系统在底部裁掉一截可视区域时，把底部安全区补偿归零，避免在已经空白的系统条上再叠一层空白；
2. **主动摘窗重排**——在输入框失焦、冷启动 1 秒/3 秒、切回前台后各触发一次「强制页面重排」，逼 WebKit 重新测量视口；能不能真正治好缩水，取决于具体机型和 iOS 版本，失败几次后会自动停手不再重试。

苹果修好这个系统级问题之前，两层缓解只能减少影响面，不能保证每次都完全复原。

## 断点策略

| 宽度 | 行为 |
| --- | --- |
| < 768px（手机） | 本文档描述的 app 化布局全量生效 |
| 768–1023px（平板） | 保持 v1.0.0 现状：抽屉 + 设置/预览浮层限宽居中，不倒退 |
| ≥ 1024px（桌面） | 严格 no-op，逐像素与未安装时一致 |

## 更新日志

> `v2.0.0` 及更早的小节标题用的是手机端界面的代次，与 npm 包版本号不是一条线；
> 两套编号并行容易看混，从 `1.1.0` 起本表统一按 **npm 包版本**记录。

### 2.0.0

**新增**

- 一个插件、两种角色，界面半边多出两组部件（详见「2.0：远程会话与设置区块」一节）：
  - 插件设置区块：DSH「插件」页里的完整配置与管理界面，主服务端（网关 / 反代 / 配对 / 设备列表 / 推送 / 远程共享）与子客户端（服务端地址 / 连接状态 / 诊断）按角色不同；经网关打开时管理动作只读；
  - 主服务端共享部件：会话页标题行远程图标（灰 / 亮 / 亮带圆点，悬停剩余闲置时间，点击开关带确认）与侧边栏会话「…」菜单项；手机会话信息卡片里的「远程访问」开关（只在卡片打开期间拉共享表）；
  - 子客户端远程部件：侧边栏远程分组（离线 / 解配 / 吊销 / 版本差异标注、离线禁写）、标题行连接图标、「远程已关闭」composer 横幅；
- 以上部件全部注册在桌面端门（`dshDesktop`）之外——桌面端 App 正是主服务端/子客户端的宿主，门的语义是「手机外壳不生效」，不是「远程部件不生效」。

### 未发布

**新增**

- 分享图（issue #7）：信息卡新增「分享图」操作，把会话导出成**一张**可保存/转发的 PNG
  长图——全部对话或最近 3/5/10 轮（服务端裁剪、按真提问计轮，轮中插话不另起一轮）；
  分片只是渲染预算的中间产物，导出前流式拼接成单文件（不支持拼接的浏览器退化为多张
  下载）；超 24 片保尾去头并在首片标注省略数；长代码行/宽表格永不横向裁剪；助手正文
  按 markdown 子集渲染。手机上滚动截图拼长图太麻烦，这是第一等诉求（见「分享图」一节）。

**新增**

- 远程会话产物下载：产出台卡片与文档预览头各一个「下载到本机」，经中继
  `workspaceFiles/readBytes` 分段读回后由浏览器落盘；Word/Excel/PPT 预览改由服务端
  LibreOffice 出 PDF（`officeToPdf/*` 进入中继白名单，PDF 字节走二进制标记）。此前
  子客户端只能预览，产物拿不到本机。

**修复**

- 适配 DSH `0.1.5`（0.1.2 → 0.1.5-rc.2 的一批回归，2026-09-11 真机报告、逐项实测）：
  - 官方工具行新增了原生附件回形针，手机端一度出现两枚——现在运行时探测宿主
    自带的 `<input type="file">`，命中即整组让位（见「附件上传」一节），不命中
    照旧加载（该让位机制已于 #31 移除）；
  - 官方右侧栏的 ExpandButton 落在**新的** `conversation.session.header.corner`
    槽里，逃过了头部两条 blanket hide，顶到右上角——已隐藏（隐藏它的选择器
    必须 ≥(0,3,1)：老的 utilities 反隐藏规则在 0.1.5 下恰好命中这个 `:last-child`
    角位，轻量级 hide 会被它的 `flex !important` 压掉，实测两次才定位），
    头部侧栏钮改为三态路由（见「会话页头部五件套」）；
  - composer 从 `<textarea>` 换成 Lexical contenteditable（`[data-composer-input]`），
    S9 键盘守卫与 S10 的聚焦探针只认 TEXTAREA、全体失明——点官方附件钮的
    `keepFocus` 强制聚焦会直接弹键盘并把输入框顶上去，开 会话自动聚焦也复发。
    两个 effect 的识别同步扩到 contenteditable，实测：附件钮的强制聚焦被糊掉、
    用户直接点输入框的聚焦保留。
- 适配 dsh-better-sidebar `0.19`（issue #11、PR #12 及其补齐，2026-09-21，本机 0.1.5-rc.2 +
  0.19.1 真界面逐项核过）：
  - 头部侧栏钮点了没反应——0.19 退役了自己的右栏，老锚点
    `[data-dsh-better-sidebar] button[class$="_toggleButton"]` 命中 0 个元素，`?.click()`
    静默空操作。路由改为**官方右侧栏优先**（0.19 把全部 tab 都注册进去了，手机上宿主自己
    切全屏），老版 better-sidebar 分支只在 DSH < 0.1.5 时走；同一根因下死掉的另外几处
    （左缘回退手势收面板、文件树 @ 引用后自动收起）改为对官方面板生效，锚点集中到
    `sidebar-panels.ts`。首页「文件」chip、关闭 pill、`compat.css.ts` 的三段 better-sidebar
    规则都是老版专用，0.19 上按设计不命中（chip 因此不显示）——保留给老组合，不改。
  - 官方右栏全屏打开时顶部没留安全区，tab 条和「退出全屏 / 收起」钮压在手机状态栏下面
    （真机报告）：宿主把全屏面板设成 `position: fixed; inset: 0`，和左抽屉一样逃出了
    frame 的安全区 padding。给 `[data-sidebar-right-panel="fullscreen"]` 补上下两个
    安全区 padding（`layout.css.ts`，紧挨着左抽屉那条；`?mobile-nav-inset=54,34` 验过，
    tab 条落到 y=54、面板高度不变）。
  - 输入框打字后切后台再切回，点到文字上就瞬间失焦：Lexical 在 contenteditable 里生成
    `<p>` / `<span>`，pointerdown 落在子节点上，`hasAttribute('data-composer-input')`
    判空 → 视为未授权 → focusin 时被 S9 守卫 blur。不只切后台会触发，任何 focusout 之后
    再点到文字上都会。识别改为 `.closest('[data-composer-input]')`（PR #12）。
  - 模型弹层没变成底部抽屉、退化成原生小浮窗：0.1.5 的 ModelSelect 把菜单 portal 到了
    `document.body`（`id` 为 `${useId()}-menu`、`role=menu`，rc.2 上唯一带这种 id 的
    body 级菜单），`${MODEL} > [class$="_menu"]` 不再命中。抽屉规则、行高、
    `model-sheet-extras` 的搬家目标都改成两种写法并存（`:is()`）；z-index 保持 60
    （四点命中实测没有东西盖住它，也仍在信息卡的 70 之下），选项行高保持 48px 的 44pt 规矩。
- composer 行里第三方插件的入口(`conversation.input.right`)在手机端整体移进模型弹层,
  与「模型」「推理等级」并列成行——行是不换行的,模型名是唯一能让宽度的东西,
  订阅插件的速度 chip(带文字约 70px)加上识图开关会把它挤没(2026-09-06 用户报)
- 适配 dsh-vision-router(2.0.1 / 2.1.1):composer 识图开关在手机端压成 28px 圆形
  图标钮(眼睛为 CSS mask 图标,aria-label / aria-pressed 照旧),不再把
  模型名挤出 composer 行;桌面端保持插件原样(见兼容插件清单)。
- 适配 DSH `0.1.1`：活动 chip 点击失效（点了退回信息卡）。三处上游变化同时命中
  旧方案——子代理入口从 `header.actions` 槽移进新的 `header.lineage` 槽（面包屑
  内）；其根节点 class 变成 `"ZKlsPq_root "`（**带尾随空格**，`[class$="_root"]`
  整串后缀匹配直接失配）；触发器**只认可信输入**，`.click()` 与合成
  pointer/mouse 事件一律无效（真机逐项排除了插件样式的嫌疑后实测）。因此废弃
  「转发点击」，改为 `effects/native-trigger-overlay.ts` 把**真实的官方触发器**
  透明地铺在 pill 上——用户手指点到的就是官方元素，可信输入天然成立；弹层在
  0.1.1 里 portal 到 body 自行定位（336px，屏内），旧的锚定/重锚 CSS 一并删除。
  选择器统一改为按 header 定位、按 ARIA（`aria-haspopup="tree"`）区分，不再依赖
  槽名与根节点类名。

**内部**

- 测试从 133 增至 151：新增 share-export 路由集成测试（折叠全规则、range=last 切片、
  next-step 收件箱编排、折叠阶段抛错的护栏）与 `check-share-image.mjs` 纯函数自检；
  share-card 与 rasterize 改为共用 `src/client/share/share-constants.ts` 模板常量，
  检查脚本对模板字面量的正则锁随之退役为对常量模块的直接断言。

### 1.1.0

**新增**

- 安卓系统返回手势接管：页面栈与各类弹层镜像进 `history`，返回变成「先关弹层 →
  退回列表 → 退出应用」，不再一按就退出 PWA（见「手势」一节）；
- 会话页头部活动 chip：子代理 / 后台任务的计数与状态点（蓝色脉动＝运行中、绿色＝
  已完成、琥珀＝失败或被终止），点开直接弹官方原生列表；
- 推送时机重做：只在等授权 / 等回答时必推，回合结束改为 opt-in，子代理跑完不推
  （详见根 README 的「通知什么时候会响」）。

**改进**

- 正文行距 28px → 1.65 倍、段间距 16px → 12px，手机上一屏能多看几行；
- 会话列表主屏与会话页统一为同一个 `bg-base` 背景，卡片与 chips 改用次级表面，
  切页面不再有明显色阶跳变。

**修复**

- DSH 原生待办卡片在手机端点了没反应：`conversation.input.dock` 的 mini chip
  规则给槽内所有条目套了 `max-height: 26px` + `overflow: hidden`，而官方待办
  是就地展开的卡片而非 chip。展开动作其实一直是成功的（`aria-expanded` 翻了、
  8 行待办以 216px 挂载完毕），只是整张卡被剪在 26px 里，看上去像点不动。已按
  `data-testid="todo-panel"` 单独豁免（2026-08-20 真机视口实测）。
- 信息卡的缓存命中率升为主数据并保留一位小数，Token 收支降为副行——整数四舍五入
  会把 99.6% 显示成 100%，这一位不是装饰；
- 「关闭工作台」胶囊在深色下看不出边缘：填充是 `bg-base`、与身后表面同色，而黑色
  投影落在深色背景上等于不存在。改用内描边画边界，投影只负责浮起；
- 「不再弹出」按钮在手机上被官方确认按钮挤成两字宽：操作行是 flex，该按钮缺
  `flex-shrink` 保护，补 `flex: 0 0 auto` + `min-width: max-content`。

**内部**

- 构建与实测基准从 DSH `0.1.0-rc.6` 升到 `0.1.0-rc.7`（peer 与 dev 依赖同步）；
- README 的 release 徽章与依赖示例改由 `scripts/sync-doc-version.mjs` 跟着
  `package.json` 走，挂在 `version` 生命周期脚本上，`pnpm test` 里带一致性检查。
- 已知问题更正：经反代访问时设置页空白，真实原因是 DSH 官方**设置 RPC 仅对回环
  连接开放**（客户端按 `location.hostname` 判定），不是此前记录的「连接就绪超时」，
  也与本插件无关（2026-08-20 真机 USB 调试 + 本机对照实测）；
- 测试从 46 增至 73：新增返回手势层栈 7 条、推送策略 13 条、活动 chip 契约若干。

### v2.0.0

**新增**

- 两级页面栈 app 化重排：会话列表主屏 + 独立会话页，横向转场取代桌面抽屉；
- 会话页头部五件套（返回/会话名/视图指示/信息卡/better-sidebar 入口）；
- composer 重排：图标化底排控件、权限与模型菜单变身底部 sheet、顶部渐变 mask 取代硬分割线；
- 会话信息卡：Chat/Trajectory 切换 + 六格统计 + 导出/重命名/Fork/归档四个操作，从占地方的底部统计栏搬进按需打开的 sheet；
- 手势两件套：左缘右滑返回列表、各类底部 sheet 支持下滑关闭；
- 回合过程默认折叠：多步回合收成一条「过程 · N 步」摘要行，最终回复始终可见；
- 移动端附件上传：手机本地选择器 + 会话工作目录落盘 + composer 预览 chip + 草稿 @ 引用手动发送(见「附件上传」一节的安全说明)；
- 主屏插件入口 chips：任务看板/SSH/文件浏览/用量等按已装插件动态出现，显隐可自定义并本地持久化；未手写适配的插件也能自动长出 chip——从官方侧栏的快捷入口区里现场收割图标/文案/点击目标；
- 调试徽章：顶栏 5 连点或 `?mobile-nav-debug=1` 唤出，新增 `?mobile-nav-inset=` 伪造安全区参数，方便桌面复现刘海相关布局。

**修复**

- iOS 独立 PWA 键盘弹出后视口永久变矮的缓解（检测 + 主动重排，非根治，见上文说明）；
- 整页橡皮筋回弹导致顶栏/composer/FAB 跟手指一起位移的问题（锁文档滚动 + 消息区单独滚动）；
- PWA 冷启动首帧安全区未就位（首帧 `viewport-fit=cover` 改由 网关半边注入，不再等插件 JS 迟到补）；
- 会话行三点菜单弹出时抽屉/列表状态错乱、workbench 关闭按钮在桌面端泄漏等一系列实机反馈热修。

**内部**

- 作用域与 网关半边 理清分工：本插件管排版与交互，网关半边 只管通道（认证/推送/PWA 壳），两边 CSS 不再重叠；
- 新增 host 半区（`src/index.ts`）：从纯 client 插件变为带一条上传路由的插件；
- 大量实机反馈驱动的迭代热修（S1–S8 各切片）。

### v1.0.0

**新增 / 改进**

- 全屏预览浮层:预览底部浮层可一键放大到全视口(标题栏缩放按钮,刘海安全区适配),关闭预览或抽屉时自动还原;
- 设置弹窗重构:分类标签收进单行横向滚动(细滚动条提示),顶部工具栏并入标签行共用一行,手机上隐藏「打开配置文件」按钮,选项区显著变大;
- 分支胶囊:git 分支芯片移入输入卡片(todo 卡片之上),点击目标加大、按压即时反馈;
- 会话头部紧凑与顺序稳定:模式徽标窄处省略、subagent 按钮居中、文件按钮固定保留;
- 抽屉底部顺序:文件浏览 / 导出会话日志 固定在用量徽章之上;
- 模型 ID 完整显示:composer 行内有空间时不再省略;
- 文件树 / 预览交互:仅文件行可打开预览(目录行不再误弹缓存预览)、已选中行可重开、折叠按钮可靠关闭、预览打开时文件树自动让位;
- 诊断徽章:`?mobile-nav-debug=1` 实时显示 URL/视口/媒体查询/浮层/JS 错误,默认 no-op。

**修复**

- 目录选择器被设置弹窗规则劫持(issue #12):点笔形「编辑」进入手动输入后弹窗被顶到屏幕顶部、路径输入框被隐藏——目录选择器不再命中移动端设置适配规则;
- 预览浮层全屏几何规则回归(1779cd4)以及关闭后全屏标记残留;
- 抽屉底部徽章平票错序;subagent 弹层小屏溢出;统计栏误标 todo 面板。

**内部**

- CSS 按主题拆为 `src/client/styles/`(base / layout / compat / misc),client effects 拆分为独立模块;构建器支持子目录递归内联。

### v0.2.0

**新增 / 改进**

- 平板/宽幅移动端(768–1023px):设置弹窗与 Explorer/Preview 浮层限宽居中,避免右侧大块空白;
- Markdown 表格在移动端撑满消息列宽,减少表格内/右侧留白;
- 用户消息气泡自适应宽度:短消息紧凑靠右,长消息可撑满消息列宽;
- 模型选择器:模型名可完整显示,切换菜单在触发按钮上方水平居中;
- 底部权限/模型选择/上下文小圈间距优化(12/10/12)。

**修复**

- 预览浮层右上角缩放/收起按钮无法关闭的问题;
- 用户消息气泡全宽后因 `content-box` 导致溢出屏幕的问题;
- 模型切换菜单在小屏下偏左/溢出的问题。

## 兼容插件

- [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar)(0.19+:它的全部 tab 都在 DSH 0.1.5 官方右侧栏里,手机上宿主自己切全屏,本插件在会话页头部给它留入口位、左缘回退手势收它、文件树里点 @ 引用后自动收起——面板全屏盖住会话页,草稿变化看不见,收起本身就是点击反馈;≤ 0.18 自绘右栏的老版本:全宽抽屉 + 安全区 + 底部关闭 pill,只在 DSH < 0.1.5 上还会走到)——本机实测 **0.19.1**(老版规则实测 0.15.0)
- dsh-better-sidebar 的**远程会话处理**(PB-12 起分两档):**0.25.0、0.26.0、0.26.1、0.28.0-alpha.0、0.28.0、0.28.1 与 0.28.2** 七版由 zen-remote 的内置适配器(`src/adapters/dsh-better-sidebar/`)**代理**——它的文件浏览、编辑器、git 变动、任务等界面在远程会话里读写的是主服务端的数据(整机数据,需主服务端主人在设置页同意;`open.external` 远程拒绝、`settings.*` 与 `/sidebar/bundle` 留本机、侧聊转发（子会话继承父会话共享）;0.26 新方法已一并接入(`fs.readPage` 分页读、`sidechat.dequeue` 撤回排队消息、`sidechat.model`/`sidechat.models` 线程切模型、`/sidebar/write` 编辑器保存;0.28.1 再添 `git.history` 分页提交历史读;0.28.2 接口面不变,只把 detached 会话的 cwd 改为读存储 header(不再整份解码会话日志);`ws/sidechat` 推送不桥接,远程侧聊走 0.26 自带的 15s 兜底轮询,正确性无损))。**其他版本维持隐藏**:那些版本的界面都带 `{sessionId, cwd}` 语境调它自己的 `/sidebar/api/*` 接口,远程会话里这些请求落到子客户端本机——文件树会显示本机同名文件、编辑会真的写本机文件;远程会话(`html[data-zr-remote-session="1"]`)里按插件自己的挂载/渲染标记隐藏这些界面,选择器在 `src/client/styles/remote-session.css.ts`,未安装时一条都不命中。因为它接管了宿主的「文件」标签,未桥接的远程会话里暂时没有侧栏文件浏览;聊天或交付物里的文件链接(`dsh-resource://file/**`)会被它的编辑器注册抢先接住,展开一个空的右栏(根治要等 zen-remote 自己的远程文件树)。隐藏档的定位仍是**隐藏而非代理**:插件组件保持挂载并继续静默发只读请求,但所有写控件都在被隐藏的界面里,不会误写本机。PB-14 起显示规则按桥接状态生效:已桥接(0.25.0/0.26.0 且主服务端同意)时远程会话里**照常显示**(面板数据经适配器来自主服务端,隐藏规则全部让位);未桥接时维持隐藏,已打开的 tab 空态改为一行说明占位(better-sidebar 未在远程会话桥接——中英双语,随浏览器语言),空白工作台浮动按钮一并隐藏
- [@nanmicoder/dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams)(AgentTeams 活动浮层:手机端挪到会话头部下方、避开安全区,会话列表页隐藏;子代理会话头部保留可点的父会话面包屑用于切回)——本机实测 **0.1.9**
- [@ychris12138/dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats)(用量与余额,主屏 chips 行可直接打开;旧包名 `dsh-usage-stats` 已废弃,两个名字同时留在 profile 里会因重复的 `usage-stats` 行让 DSH 起不来)——本机实测 **0.2.9**
- [@opendsh/dsh-plugin-scheduled-tasks](https://github.com/Ceelog/dsh-plugins)(定时任务,主屏 chips 行自动收割入口)——本机实测 **0.2.3**
- dsh-at-file(@文件引用;它和本插件的附件 chip 读的是同一份草稿 token,手机端会为一个上传文件同时画出缩略图和文件名两条,故隐藏它 `.dsh-uploads/` 下那几行,其余 @ 引用不动;桌面端不隐藏——那边本插件的 chip 是关掉的,它是唯一的呈现)——本机实测 **0.6.7**
- [@ace-zone/dsh-market](https://www.npmjs.com/package/@ace-zone/dsh-market)(插件市场;它的弹窗顶栏在手机上放不下,关闭的 × 被挤出面板外,故隐藏标语/版本号/官网链接三个装饰位并让标题省略号收缩,语言切换和 × 保留;认插件自己渲染的 `.dshm-ver[title="@ace-zone/dsh-market"]` 版本片,同名的其他"市场"插件不会被误伤)——本机实测 **0.1.66**
- [dsh-vision-router](https://github.com/ysr666/dsh-vision-router)(视觉桥,composer 行内的识图开关胶囊;手机端压成 28px 圆形图标钮——原胶囊 👁+文字+激活 ✓ 约 90px 定宽,而手机 composer 行不换行、模型座位是唯一可收缩项,会被它挤没;按钮里只留一只 16px 眼睛:span 一律隐藏;2.1.x 起插件自带 svg 图标,只保留第一个 svg、用兄弟选择器隐掉其后的(当前是激活态的对钩),我们自己的 `::before` mask 眼睛降级为 `:has(svg)` 兜底、只给 2.0.x 的文字写法用;开合状态仍走插件自己的品牌描边/着色与 `aria-pressed`,可访问名走按钮自带的 aria-label)——本机实测 **2.0.1** 与 **2.1.1**。2.1.1 把 👁/✓ 从文字 span 换成 svg,只按 2.0.x 写的规则会渲染出**两只眼**且对钩无人隐藏(2026-09-04 用户报);回归由 `test/vision-router-compat-css.test.cjs` 守着
- [dsh-web-ui 全家桶](https://www.npmjs.com/package/@linxin666/dsh-web-ui-all)(文件树 / 预览 / 任务看板 / SSH / 宠物 / 会话统计 / 远程配对 / 设置)——沿用上游兼容规则,本次未扩展

## 安装

桌面端 App 与 Web 端都可以在左侧「插件」页 →「添加插件」→ 输入 `dsh-zen-remote` → 点「立即启用」；无头机器上的 Web 服务也可以用命令行：

```sh
dsh plugin --profile web add dsh-zen-remote
```

装完重启桌面端 App 或 `dsh web`。命令行不接受 `--profile desktop`（桌面端 profile 由 App 独占管理）；手动写法与本地开发装法见[根 README](../README.zh-CN.md#安装)。

## 构建

```sh
pnpm install
pnpm build   # 产物 lib/ 与源码同步入库,改动源码后重新构建再提交
```

## 验证

- `pnpm verify` 类型检查、`pnpm test` 全量测试;`dsh --profile web --dump-config` 应出现插件层;
- 自检脚本:`node scripts/check-sunk-viewport.mjs`、`node scripts/check-attach-upload.mjs`、`node scripts/check-upload-endpoint.mjs`、`node scripts/check-share-image.mjs`(需 Node ≥ 23.6);
- 移动端(390px):启动落会话列表、进出会话页转场、composer 权限/模型 sheet、信息卡统计与五个操作(导出/重命名/Fork/归档/分享图)、附件上传;
- 平板(768px):抽屉 + 限宽居中,与 v1.0.0 一致;
- 桌面端(≥1024px):与未安装时一致。

## 兼容性

需要 `:has()`(Chromium 105+);`prefers-reduced-motion: reduce` 下自动禁用动画。

## License

[MIT](../LICENSE)
