# 手机界面(界面半边详解)
> `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) 的原始工作表示感谢。
## 效果
| 会话列表主屏 | 会话页 | 会话信息卡 | composer 权限 sheet |
| --- | --- | --- | --- |
|  |  |  |  |
## 特性
### 会话列表主屏
启动永远落在这一屏(推送深链直达会话除外),不再是桌面抽屉的窄屏版本:
- 会话按圆角卡片排列(左侧头像色块 + 标题/状态两行 + 右侧时间),运行中的会话带状态点;
- 顶部就是当前 workspace 名,点开弹出切换单(含「全部」);
- 会话列表上方一条可横滑的插件入口 chips(文件浏览、用量、Session log 等,按你实际装的插件动态出现):手写适配的几个之外,**任何在官方侧栏注册了快捷入口的插件都会被自动发现**,装上就长出对应 chip,卸载后自动消失,不用等插件适配更新;行尾「···」打开显隐自定义单,选择保存在本地,刷新后还在;
- 右下「+ 新建会话」文字胶囊,点按直接在当前 workspace 建会话;
- 右上角设置图标是官方设置弹窗的入口(迁到这里,不再挤在抽屉里)。
### 两级页面栈与转场
会话列表(主屏)与会话页是两个独立层,横向推入/推出转场;进会话页点行,回列表点头部返回按钮或左缘右滑。官方侧栏在手机断点下直接隐藏,不再是「点开的抽屉」——因为已经没有抽屉需要点开了。
### 会话页头部五件套
会话页头部只留五样东西:返回 · 会话名(+ 运行状态点)· 当前视图名(对话/轨迹,带双点指示)· 信息卡入口 · 侧栏入口。官方的 Chat/Trajectory 页签视觉隐藏但 DOM 还在,点信息卡里的分段控件相当于代点它。
侧栏入口按「现场有哪个侧栏」三态路由(2026-09-11,DSH 0.1.5 长出官方右侧栏之后定的规矩):装了 dsh-better-sidebar 就代点它自己的开关(原设计);没装而宿主有右侧栏(0.1.5+)就代点官方角位的展开钮 / 面板里的收起钮;两者都没有就不渲染。无论哪种,官方角位的 ExpandButton 与 better-sidebar 自己的按钮在手机端都隐藏——头部只有我们这一枚侧栏钮。探测全部锚在稳定 DOM 标记(`[data-dsh-better-sidebar]`、`[data-sidebar-right-panel]` 及两个开合控件的 `data-sidebar-right-*`),不看语言、不看类名散列。
### 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])`)也是同一个道理——信息卡曾经被显式层和
通用观察器各登记一次,一次返回退两层,历史退到根而会话页还在屏幕上。
### 附件上传
composer 最左的回形针打开的是**手机本地**的文件选择器(iOS 上会弹相册/拍照/选取文件三选一)——官方那套文件选择器是在跑 DSH 的电脑上弹窗,手机远程用不了。图片和文件一视同仁:都上传到会话工作目录的 `.dsh-uploads/`,composer 上方出现可删除的预览 chip(图片缩略图、文件图标+文件名),并把 `@.dsh-uploads/文件名` 追加到输入框——发不发、什么时候发,由你按官方发送键决定,不会替你自动发出。
**宿主原生附件让位(2026-09-11)**:DSH 0.1.5 起官方工具行自带了一枚回形针(原生附件流,文件落 `~/.dsh/attachments`),手机上于是出现过两枚一样的回形针。现在 `host-attach.ts` 运行时探测官方工具行里自带的 ``(`_tools` 直接子元素、结构性选择器、不依赖语言):探测命中就整组不加载我们的附件 UI(按钮连隐藏 file input、chips 行都不渲染),输入区只留官方一枚;宿主没有(DSH ≤ 0.1.2)则一切照旧。首轮判定走 layout effect,首帧就不会闪出第二枚。
安全上的几句话:这个功能给插件的 host 半区新增了一条 HTTP 路由(`POST /_dsh/mobile-nav/upload`),只接受同源请求,文件名清洗后只取叶子名,写入路径校验不允许越出工作目录,单文件默认上限 20MB(可在 profile 里的插件行用 `maxUploadBytes` 调整)。远程访问时,这条路由和其它请求一样在 网关半边的配对墙之后。
### 分享图
信息卡操作行的第五枚按钮「分享图」把当前会话导出成可保存/转发的 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 包版本**记录。
### 未发布
**新增**
- 分享图(issue #7):信息卡新增「分享图」操作,把会话导出成**一张**可保存/转发的 PNG
长图——全部对话或最近 3/5/10 轮(服务端裁剪、按真提问计轮,轮中插话不另起一轮);
分片只是渲染预算的中间产物,导出前流式拼接成单文件(不支持拼接的浏览器退化为多张
下载);超 24 片保尾去头并在首片标注省略数;长代码行/宽表格永不横向裁剪;助手正文
按 markdown 子集渲染。手机上滚动截图拼长图太麻烦,这是第一等诉求(见「分享图」一节)。
**修复**
- 适配 DSH `0.1.5`(0.1.2 → 0.1.5-rc.2 的一批回归,2026-09-11 真机报告、逐项实测):
- 官方工具行新增了原生附件回形针,手机端一度出现两枚——现在运行时探测宿主
自带的 ``,命中即整组让位(见「附件上传」一节),不命中
照旧加载;
- 官方右侧栏的 ExpandButton 落在**新的** `conversation.session.header.corner`
槽里,逃过了头部两条 blanket hide,顶到右上角——已隐藏(隐藏它的选择器
必须 ≥(0,3,1):老的 utilities 反隐藏规则在 0.1.5 下恰好命中这个 `:last-child`
角位,轻量级 hide 会被它的 `flex !important` 压掉,实测两次才定位),
头部侧栏钮改为三态路由(见「会话页头部五件套」);
- composer 从 `