Skip to content

dsh-composer-focus-guard

Verified

dsh-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

npm version License: MIT Release Stars Issues

DSH(DeepSeek Harness)插件:移动端切换会话时不再把软键盘顶起来

桌面端「切换会话后焦点自动回到输入框」是上游有意设计,移动端上这个程序化聚焦会把软 键盘弹出,正好挡住刚打开的那一屏内容。本插件只在触屏主指针设备上、切会话后的 极短窗口内拦下这一次聚焦,其它一切都保持上游原样。

特性

  • 只影响该影响的那一次聚焦:三个判定(触屏主指针、切会话窗口、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.barsingle 槽位,二次注册只能遮蔽 (等于整个重写 composer),没有「包一层」的入口。

所以只能在 DOM 边界拦这一次 focus(),这也是本插件做的事。

环境要求

要求
宿主运行时 跟随 dsh web 的 Node.js(开发与验证使用 Node 24);本包 engines 声明 >=20
DSH 已在 0.1.5-rc.2 上实测;依赖的宿主内部契约见「工作机制」与「适用与限制」
设备 插件只在 (hover: none) and (pointer: coarse) 的设备上生效;桌面浏览器行为与上游一致
浏览器 需要 matchMediaHTMLElement.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(),因此照常弹键盘——只是不再被 切会话这个动作顶起来。

工作机制

时序上不依赖运气:

  1. 窗口先开:守卫条目挂在 conversation.input.dockscope: 'session' 的 list 槽位),它在 layout effect 里打开抑制窗口;
  2. 聚焦后到:React 保证同一次 commit 里所有 useLayoutEffect 先于被动 useEffect 执行,而 InputBar 的自动聚焦正是一个被动 useEffect
  3. 切会话必然重新打开窗口:宿主以 sessionId 作 React key 渲染整个会话子树 (dsh-client-ui-sessionrenderSessionArea),会话切换即整棵子树重新挂载, 守卫条目随之重新执行 layout effect;sessionId 本身也作为会话级 standard prop 注入,是本插件 effect 的第二个触发源。

抑制只发生在补丁内部:HTMLElement.prototype.focus 被包了一层,三个判定全部命中时 直接返回,否则原样调用原实现并转发 FocusOptions

自查与调整

  • 想确认当前设备是否被判定为触屏:浏览器控制台执行 matchMedia('(hover: none) and (pointer: coarse)').matches。 若设备形态特殊(带触控板的平板、桌面模式手机)返回 false,把 src/client/index.tsisTouchPrimary() 改成 () => 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 链接),因此重启不是唯一的反馈手段。

真机验证(需要触屏设备):

  1. 手机浏览器打开 dsh web,进入任一会话;
  2. 输入框不自动聚焦,软键盘不弹出,首屏内容完整可见;
  3. 手动点击输入框:键盘正常弹出,输入与发送均正常;
  4. 切换到另一个会话:软键盘不再被顶起;
  5. 打开命令面板,搜索框可正常聚焦输入;
  6. 用桌面浏览器重复第 2–4 步:行为应与上游一致(切会话后焦点回到输入框)。

工程化

  • 客户端半体只消费 slots,类型按本插件实际使用的面声明为本地窄接口(不引入 @deepseek-ai/* 类型包,避免版本错配);
  • 客户端只从宿主模块表 require('react'),产物 6KB 左右,不含第三方代码;
  • 宿主半体是空 apply:它的唯一职责是让 dsh-client-modules 有一条可扫描的 Loader 条目(浏览器侧模块图只从宿主 Loader 条目里装配);
  • 单元测试与两个校验脚本都以 TS 编写、纳入 pnpm typechecktsconfig.test.json / tsconfig.scripts.json),测试与脚本里复刻的宿主契约同样受类型检查约束。

适用与限制

  • 依赖两个宿主内部标记:[data-composer-card](被 ui-input-triggerui-model-selectionui-agent-presetui-message-feedback 等多个官方包共同使用, 事实上的跨包稳定标记)与 InputBar 的被动 effect 语义,以及会话级槽位「按 sessionId 作 React key 重挂载」的行为。上游若重写 composer 或改动会话子树的重挂载方式,只需 回来核对 src/client/guard.tssrc/client/index.ts 两个文件。
  • 抑制窗口是固定 500ms:覆盖同一次 commit 的被动 effect 与 Lexical 可能延后一帧的 聚焦。若编辑器就绪、或解锁(lockedtruefalse)发生得更晚,那一次聚焦会 落在窗口外而不被抑制。
  • 原型补丁不随 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