# 手机界面(界面半边详解) > `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.4,区块最底部,两角色都有)**:一键下载 `zen-remote-debug--<时间戳>.json`,可直接附到 GitHub issue——五个板块(`_meta` 版本与角色 / `config` 配置形态 / `status` 全部诊断聚合 / host 角色另含 `gateway` 网关摘要 / `logs` 近 300 条插件事件),浏览器端再并入附件入口守卫的分入口计数与当前会话信号。两层脱敏:配置**按字段名挑选**(deviceToken 等秘密构造性缺席),全部文本再过统一脱敏器(绝对路径只留叶子、家目录折叠为 `~`、URL 主机名降级为地址类型但端口保留、token 形态串打码、`zr~` id 截断);对话内容与附件在任何一层都不进报告。经网关打开时该路由被 via 墙拒绝——报告只能在本机导出(`GET /_dsh/zen-remote/client/debug-report`,CU-3)。 - **插件桥(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/` 再经中继;离线时写一律本地拒绝,`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` 下隐藏的 `` 就整体让位」处理。顺带修正一个旧说法:`` 在浏览器/PWA 里弹的是**当前客户端设备**的文件选择器,不是宿主机桌面的弹窗。真正压垮让位方案的是 DSH 0.2.x:独立回形针挪进了 `+` 命令菜单,隐藏的 `` 却留在 `_tools` 里——让位探测从此永远命中,手机输入区一枚回形针都不剩(issue #31)。修复后手机外壳不再让位:自研回形针常驻,走自己的上传路由,各版本 DSH 下体验一致;DSH ≤ 0.1.5 官方仍渲染的第二枚独立回形针由 CSS 在 <768px 隐藏,行内始终只有一枚。桌面端(≥768px)一切照旧,官方回形针照常使用。 安全上的几句话:这条 HTTP 路由只接受同源请求,文件名清洗后只取叶子名,写入路径校验不允许越出工作目录,单文件默认上限 20MB(可在 profile 里的插件行用 `maxUploadBytes` 调整)。远程访问时,这条路由和其它请求一样在网关半边的配对墙之后。 ### 附件入口守卫(drop / 回形针 / 粘贴,2.2.6 起拖拽、2.4 起三入口) 桌面端把文件从访达拖进会话输入区、或经回形针选择、或往编辑器里粘贴文件时,宿主有一条「同机零拷贝」捷径:Electron 的 `__DSH_HOST_PATHS__` 能读出文件的磁盘路径,`addFiles` 对**带路径的非图片**文件不传字节、直接插一条 `@本机绝对路径` 的引用。远程会话里这个路径是**子客户端机器**的路径,服务端的会话永远读不到,引用落地就是坏的(issue #49:拖拽之外,回形针与粘贴走的是同一条捷径)。浏览器半边在 document 上装了 **capture 阶段**的三个守卫(`src/client/effects/remote-drop.ts`,判定纯函数在 `src/client-data/remote-drop.ts`):`drop` 事件、composer 隐藏选择器的 `change`(结构特征判定:`input[type=file][multiple][hidden]` 且**无 accept 属性**——导入/恢复类选择器通常带 accept 或可见,不在此列)、编辑器 `paste`(仅当事件目标在 `[contenteditable="true"]` 内,设置页/搜索框不归它管)。当前会话是 `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']`,事件发生时现场读;读不到按本机会话处理——误判方向安全:最坏把本机拖拽当成远程多传了字节,不会丢数据)、事件是受信的。**干预条件分入口**:拖拽沿用「有文件即拦」的 2.2.6 语义;回形针与粘贴只在批次里**至少一个文件带路径且非图片**(正是零拷贝坏分支)时才拦——无路径文件、纯图片、纯文本一律放行,不碰本来就能用的流程。拖入/粘贴**文件夹**时整批拦下(文件夹没法用单文件上传修),出一条 3 秒自动消失的提示(locale key `remoteDropFolderBlocked`);目录判定优先于改道,`webkitGetAsEntry` 不可用时视为无文件夹(与宿主同一默认)。回形针改道时还会清掉 `input.value`(否则同文件二次选择不触发 change)。重包装或 `DataTransfer.items.add` 抛错时放行原事件回归现状——宁坏勿丢。 误伤边界(无 accept 特征门的已知局限):第三方插件若也有「隐藏 + 多选 + 无 accept」的选择器,且恰逢远程会话为当前会话,其选中的带路径非图片文件会被改道进远程上传——最坏结果是文件进了远程会话(立即可见、可重来),不丢数据;特征门已把面收窄到最小。宿主改掉 InputBar 结构时症状是回形针/粘贴回到路径 chip 现状(拖拽不受影响),靠计数与真机验收兜底。 降级:`DragEvent` 不可用或 `DataTransfer` 不可构造时**整体不装**,行为与未安装本功能完全一致。诊断:设置页(子客户端角色)诊断区「附件入口守卫」一行,显示安装状态与拦截 / 重派发 / 拦下文件夹 / 放行 / 重包失败五个计数,以及按 drop / 回形针 / 粘贴 分入口的拦截与放行计数(`remoteDropDiagnostics()`,浏览器端计数,渲染时现读)。计数口径分入口:回形针与粘贴在本机会话不进计数(远程门先于计数);拖拽沿用 2.2.6 行为,本机拖拽仍记一次放行。 ### 分享图 信息卡操作行的第五枚按钮「分享图」把当前会话导出成可保存/转发的 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-*)`,调试参数在时可以整体假冒——这是后面两层缓解的基础。 这套补偿原本止步于 1023px 断点,但网关对一切非 desktop 设备都注入 `viewport-fit=cover`,于是**宽幅平板的独立 PWA(≥1024px:iPad 横屏、12.9/13 吋 iPad 竖屏恰好 1024、安卓平板)**会画进状态栏下方而无人买单——顶部控件被系统区压住、难以点按。现由 `misc.css.ts` 一条 `(min-width: 1024px) and (display-mode: standalone)` 双门控的规则补偿:锚点是官方 AppFrame(`[data-shell-overlay]` 的直接父层,与 `findFrame()` 同源),`border-box` + 上下两个变量。桌面浏览器(display-mode 不匹配)、桌面 App(整个样式表被 `dshDesktop` 门挡掉、也拿不到 cover)与一切 `env()` 为 0 的环境构造性 no-op,≥1024px 桌面逐像素不变的硬约束不受影响;规则由 `scripts/check-tablet-safe-area.mjs` 钉住。 在这套变量之上,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 真机报告、逐项实测): - 官方工具行新增了原生附件回形针,手机端一度出现两枚——现在运行时探测宿主 自带的 ``,命中即整组让位(见「附件上传」一节),不命中 照旧加载(该让位机制已于 #31 移除); - 官方右侧栏的 ExpandButton 落在**新的** `conversation.session.header.corner` 槽里,逃过了头部两条 blanket hide,顶到右上角——已隐藏(隐藏它的选择器 必须 ≥(0,3,1):老的 utilities 反隐藏规则在 0.1.5 下恰好命中这个 `:last-child` 角位,轻量级 hide 会被它的 `flex !important` 压掉,实测两次才定位), 头部侧栏钮改为三态路由(见「会话页头部五件套」); - composer 从 `