dsh-composer-focus-guard
Verifieddsh-composer-focus-guard · v0.1.0 · MIT · Web UI
DSH 插件:移动端切换会话时抑制 composer 的自动聚焦,避免软键盘被顶起
Install
dsh plugin add dsh-composer-focus-guard Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
DSH Composer Focus Guard
DSH(DeepSeek Harness)插件:移动端切换会话时不再把软键盘顶起来。
桌面端「切换会话后焦点自动回到输入框」是上游有意设计,移动端上这个程序化聚焦会把软 键盘弹出,正好挡住刚打开的那一屏内容。本插件只在触屏主指针设备上、切会话后的 极短窗口内拦下这一次聚焦,其它一切都保持上游原样。
- npm:https://www.npmjs.com/package/dsh-composer-focus-guard
- 仓库:https://github.com/xht-code/dsh-composer-focus-guard
特性
- 只影响该影响的那一次聚焦:三个判定(触屏主指针、切会话窗口、composer 可编辑根) 同时命中才拦截,桌面端与页面内其它输入行为完全不变;
- 零配置:装完即生效,
cordis.patch.yml无需任何字段; - 不改上游代码:不动
node_modules、不遮蔽conversation.composer.bar槽位,只在 DOM 边界包一层focus(); - 无持久状态:不写文件、不发网络请求,移除后不留残留。
解决的问题
上游 @deepseek-ai/dsh-client-ui-conversation 的 InputBar 里有一个无条件的解锁效果
(packages/client/ui-conversation/src/client/skeleton/InputBar.tsx):
// Unlock (mount / session switch) returns focus to the box ...
useEffect(() => {
if (locked || editor === null) return
focusDraftEditor(editor, revealSelection) // → editor.getRootElement()?.focus()
}, [locked, sessionId, editor]) // ← sessionId 在依赖里
- 没有配置项:
ui-conversation设置命名空间只注册了busyEnter一个字段; - 上游 master 与最新发布版行为一致,都没有设备判断;
- 槽位层面关不掉:
conversation.composer.bar是single槽位,二次注册只能遮蔽 (等于整个重写 composer),没有「包一层」的入口。
所以只能在 DOM 边界拦这一次 focus(),这也是本插件做的事。
环境要求
| 项 | 要求 |
|---|---|
| 宿主运行时 | 跟随 dsh web 的 Node.js(开发与验证使用 Node 24);本包 engines 声明 >=20 |
| DSH | 已在 0.1.5-rc.2 上实测;依赖的宿主内部契约见「工作机制」与「适用与限制」 |
| 设备 | 插件只在 (hover: none) and (pointer: coarse) 的设备上生效;桌面浏览器行为与上游一致 |
| 浏览器 | 需要 matchMedia 与 HTMLElement.prototype.focus(现代浏览器均支持) |
安装 / 更新
从 npm 安装(推荐):
dsh plugin --profile web add dsh-composer-focus-guard
更新与卸载:
dsh plugin --profile web update dsh-composer-focus-guard@latest
dsh plugin --profile web remove dsh-composer-focus-guard
安装后重启 dsh web 并刷新浏览器页面(bundle 列表在启动时读取,客户端产物变更
刷新即可)。
从源码安装(开发 / 本地调试)
git clone https://github.com/xht-code/dsh-composer-focus-guard.git
cd dsh-composer-focus-guard
pnpm install && pnpm build
dsh plugin --profile web add link:"$PWD"
pnpm dev # 可选:tsdown --watch
以 link: 装入时宿主直接读取本仓库的 lib/,因此改动后需要 pnpm build(或让
pnpm dev 常驻)。
入口行由插件自带的 bundle patch 层插入(cordis.patch.yml),dsh plugin 会自动把它
登记进 dsh.profile.bundles;不要再往 profile 自己的 cordis.patch.yml 里插一次,
那里重复插会因为 duplicate loader entry id 整体启动失败。
行为
生效条件三者同时满足才拦截,缺一不可:
| 判定 | 取值 | 说明 |
|---|---|---|
| 设备 | (hover: none) and (pointer: coarse) |
鼠标 / 触控板设备完全不生效 |
| 时机 | 切会话后 500ms 内 | 由会话级槽位条目的 layout effect 打开窗口 |
| 目标 | [data-composer-card] 内的 contenteditable |
命令面板搜索框、队列编辑框等真实 <input> 不受影响 |
用户主动点输入框时是浏览器原生聚焦,不经过 focus(),因此照常弹键盘——只是不再被
切会话这个动作顶起来。
工作机制
时序上不依赖运气:
- 窗口先开:守卫条目挂在
conversation.input.dock(scope: 'session'的 list 槽位),它在 layout effect 里打开抑制窗口; - 聚焦后到:React 保证同一次 commit 里所有
useLayoutEffect先于被动useEffect执行,而 InputBar 的自动聚焦正是一个被动useEffect; - 切会话必然重新打开窗口:宿主以
sessionId作 React key 渲染整个会话子树 (dsh-client-ui-session的renderSessionArea),会话切换即整棵子树重新挂载, 守卫条目随之重新执行 layout effect;sessionId本身也作为会话级 standard prop 注入,是本插件 effect 的第二个触发源。
抑制只发生在补丁内部:HTMLElement.prototype.focus 被包了一层,三个判定全部命中时
直接返回,否则原样调用原实现并转发 FocusOptions。
自查与调整
- 想确认当前设备是否被判定为触屏:浏览器控制台执行
matchMedia('(hover: none) and (pointer: coarse)').matches。 若设备形态特殊(带触控板的平板、桌面模式手机)返回false,把src/client/index.ts的isTouchPrimary()改成() => true即可对所有设备生效, 改完pnpm build并刷新页面(客户端产物变更只需刷新,不需要重启)。 - 想连「审批弹窗关闭后回焦」也一起抑制:那一次聚焦由
locked依赖触发,本插件目前 不覆盖(只覆盖sessionId变化),需要时为该条目补一个信号即可。
常见问题
装完没反应?
先确认三件事:dsh plugin --profile web add 之后重启过 dsh web;设备满足
(hover: none) and (pointer: coarse)(桌面浏览器上插件本来就是惰性的);浏览器页面已
刷新到最新客户端产物。
桌面端会被影响吗? 不会。非触屏主指针设备上补丁完全惰性,媒体查询只在「composer 可编辑根 + 窗口内」时 才被求值。
为什么不做成设置项?
上游 ui-conversation 的设置命名空间没有对应字段,插件侧也无法往宿主设置页注册字段;
治本方案见「适用与限制」。
会影响命令面板、队列编辑框吗?
不会。判定要求目标是 [data-composer-card] 内的 contenteditable;真实 <input> 与
卡片外的可编辑元素都不受影响。
和输入法有关吗?
无关。本插件不做输入法相关处理,只是在特定窗口内不调用 focus()。
开发
pnpm install # 安装依赖
pnpm build # 产出 lib/index.mjs(宿主)+ lib/client.js(客户端)+ 类型声明
pnpm dev # tsdown --watch,配合 link: 安装做本地调试
pnpm typecheck # 宿主 / 客户端 / 校验脚本 / 单元测试四层类型检查
pnpm test # 单元测试(vitest):抑制窗口计时、目标判定、补丁拦截与还原
pnpm test:integration # 集成验证:构建后按宿主契约装载真实产物,并跑接线预检
pnpm test:integration 的接线预检默认检查 ~/.dsh/profiles/web,非默认位置用环境变量
DSH_PROFILE_DIR 指定。
验证
pnpm test # 单元测试(vitest)
pnpm test:integration # 客户端冒烟(jsdom + 真实 react)+ 接线预检
pnpm typecheck
scripts/verify-client.ts 在 jsdom 里按 DSH 的 ModuleLoader 契约装载真实产物,断言
触屏 / 桌面、窗口内 / 窗口外、composer 内 / 外三类组合下的实际 focus() 结果;
scripts/verify-wiring.ts 断言所有「启动时才会报错」的静态契约(manifest 字段、产物
路径、patch 插行、profile 链接),因此重启不是唯一的反馈手段。
真机验证(需要触屏设备):
- 手机浏览器打开
dsh web,进入任一会话; - 输入框不自动聚焦,软键盘不弹出,首屏内容完整可见;
- 手动点击输入框:键盘正常弹出,输入与发送均正常;
- 切换到另一个会话:软键盘不再被顶起;
- 打开命令面板,搜索框可正常聚焦输入;
- 用桌面浏览器重复第 2–4 步:行为应与上游一致(切会话后焦点回到输入框)。
工程化
- 客户端半体只消费
slots,类型按本插件实际使用的面声明为本地窄接口(不引入@deepseek-ai/*类型包,避免版本错配); - 客户端只从宿主模块表
require('react'),产物 6KB 左右,不含第三方代码; - 宿主半体是空
apply:它的唯一职责是让dsh-client-modules有一条可扫描的 Loader 条目(浏览器侧模块图只从宿主 Loader 条目里装配); - 单元测试与两个校验脚本都以 TS 编写、纳入
pnpm typecheck(tsconfig.test.json/tsconfig.scripts.json),测试与脚本里复刻的宿主契约同样受类型检查约束。
适用与限制
- 依赖两个宿主内部标记:
[data-composer-card](被ui-input-trigger、ui-model-selection、ui-agent-preset、ui-message-feedback等多个官方包共同使用, 事实上的跨包稳定标记)与 InputBar 的被动 effect 语义,以及会话级槽位「按 sessionId 作 React key 重挂载」的行为。上游若重写 composer 或改动会话子树的重挂载方式,只需 回来核对src/client/guard.ts与src/client/index.ts两个文件。 - 抑制窗口是固定 500ms:覆盖同一次 commit 的被动 effect 与 Lexical 可能延后一帧的
聚焦。若编辑器就绪、或解锁(
locked由true变false)发生得更晚,那一次聚焦会 落在窗口外而不被抑制。 - 原型补丁不随 fiber 卸载还原:它只在抑制窗口内改变行为,而窗口只由本插件的条目打开, 卸载后即惰性;还原反而会在热重载窗口期(新 fiber 先装载、旧 fiber 后卸载)把新补丁 一起摘掉。多次热重载会在原型上叠加包装层,旧层因窗口过期而惰性,但不会回收。
- 治本方案仍是上游加一个判断或设置项,例如在该 effect 开头加
if (matchMedia('(hover: none) and (pointer: coarse)').matches) return,或在ui-conversation命名空间下加一个focusOnSessionSwitch设置。
回滚
dsh plugin --profile web remove dsh-composer-focus-guard
然后重启 dsh web。本插件不写任何持久状态,移除后不留残留。
安全与隐私
- 不发起网络请求,不读写文件,不采集数据;
- 唯一的全局副作用是在
HTMLElement.prototype.focus上包一层判定:命中时跳过本次聚焦, 其余情况原样转发(含FocusOptions); - 不修改上游包、不注入样式、不注册任何持久服务。
开源协议
本项目基于 MIT 协议开源。
Copyright (c) 2026 xht-code