dsh-mobile-flow 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -16
- package/README.zh.md +22 -16
- package/lib/client.js +107 -51
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Two mobile fixes for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) Web UI:
|
|
6
6
|
|
|
7
7
|
1. **In-flow composer** — on narrow screens (≤720px) the input bar and AI confirmation cards scroll with the page instead of pinning to the viewport floor, so swiping up gives the message transcript the full screen back.
|
|
8
|
-
2. **Native input takeover** — on narrow screens the draft surface becomes a native `<textarea>`, sidestepping the Lexical contenteditable IME flow that swallows text on Android keyboards (voice input most of all).
|
|
8
|
+
2. **Native input takeover** — on narrow screens **and on any touch device** (a large tablet included) the draft surface becomes a native `<textarea>`, sidestepping the Lexical contenteditable IME flow that swallows text on Android/HarmonyOS keyboards (voice input most of all).
|
|
9
9
|
|
|
10
10
|
A client-side overlay plus one slot component. No product source is modified; removing the plugin restores the stock behavior exactly.
|
|
11
11
|
|
|
@@ -21,18 +21,19 @@ The stock composer's text surface is a **Lexical contenteditable** (`createEdito
|
|
|
21
21
|
|
|
22
22
|
## What it does
|
|
23
23
|
|
|
24
|
-
On viewports ≤720px (the same breakpoint the official question card uses):
|
|
24
|
+
On viewports ≤720px (the same breakpoint the official question card uses) the first four are layout fixes; the rest key off **"narrow screen or touch device"** — the IME problem has nothing to do with screen size, and a HarmonyOS tablet loses typed text at 1280px just the same (user report 2026-10-05: MatePad + Doubao voice input):
|
|
25
25
|
|
|
26
26
|
1. **The composer seat joins the document flow** — input bar and confirmation cards now live at the end of the transcript. Swipe up and they scroll out of view; the transcript becomes full-screen.
|
|
27
27
|
2. **Short conversations still dock to the bottom** — when the transcript is shorter than one screen, the message area stretches so the composer stays flush with the viewport floor, visually identical to the stock behavior. Long conversations get the full-screen treatment.
|
|
28
28
|
3. **Floating controls re-anchor** — the back-to-bottom button and turn navigator no longer reserve height for the sticky seat and sit close to the viewport floor again.
|
|
29
29
|
4. **Slim edges** — the content-layer paddings that keep the transcript, header, input card, and confirmation cards well off the screen edges (16–32px per side) shrink to a slim 4px side inset (8px top), reclaiming most of the wasted width without the cramped feel of full-bleed. The page shell itself never adds whitespace; the gaps come entirely from these paddings.
|
|
30
|
-
5. **No auto-focus on session switch** — the stock UI returns focus to the input box on every mount / session switch (a desktop convenience), which pops the on-screen keyboard over half the screen on phones.
|
|
31
|
-
6. **Workspace row actions always visible** — the trailing buttons on workspace and session rows (the ⋯ menu with rename/delete and the + for a new session in that workspace) surface on hover only; touch has no hover, so narrow viewports show them whenever the sidebar list is rendered.
|
|
32
|
-
7. **Native input takeover** — the draft surface is drawn by a native textarea whose metrics match the stock box exactly (geometry is measured from the live composer every frame the card relayouts). Enter still performs the official send gesture (including `/` menu arbitration and the busy-Enter policy), file pastes are forwarded to the official attachment intake, and locked states follow the product's own editability gate.
|
|
33
|
-
8. **The send button follows the field** — as soon as the field holds text, the send button reads as enabled (a live blue circle instead of the disabled grey one) and one tap sends it (queue/steer while a turn streams). While typing that state is painted by pure CSS with **zero DOM writes**, so the "no DOM movement while typing" invariant stands.
|
|
30
|
+
5. **No auto-focus on session switch** — the stock UI returns focus to the input box on every mount / session switch (a desktop convenience), which pops the on-screen keyboard over half the screen on phones (and is just as unwelcome on a tablet). Narrow viewports and touch devices swallow that programmatic focus; tapping the input box still focuses it normally.
|
|
31
|
+
6. **Workspace row actions always visible** — the trailing buttons on workspace and session rows (the ⋯ menu with rename/delete and the + for a new session in that workspace) surface on hover only; touch has no hover, so narrow viewports and touch devices show them whenever the sidebar list is rendered.
|
|
32
|
+
7. **Native input takeover** — narrow screens and touch devices are taken over by default (a mouse-driven desktop is left alone); the draft surface is drawn by a native textarea whose metrics match the stock box exactly (geometry is measured from the live composer every frame the card relayouts). Enter still performs the official send gesture (including `/` menu arbitration and the busy-Enter policy), file pastes are forwarded to the official attachment intake, and locked states follow the product's own editability gate.
|
|
33
|
+
8. **The send button follows the field** — as soon as the field holds text, the send button reads as enabled (a live blue circle instead of the disabled grey one) and one tap sends it (queue/steer while a turn streams), with the tap's own trailing `click` swallowed so the product can never read that same tap as an interrupt (v0.8.1). While typing that state is painted by pure CSS with **zero DOM writes**, so the "no DOM movement while typing" invariant stands.
|
|
34
|
+
9. **The switch is not a phone control** — the tool-row 「输入法✓/✗」 chip renders on every viewport, so a large tablet always has a one-tap say in this; it is also the fallback for a device the default gate fails to recognise as touch.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
**A mouse-driven desktop is completely unaffected** (a touch device is taken over however wide it is — that is the point of this change).
|
|
36
37
|
|
|
37
38
|
## How it works
|
|
38
39
|
|
|
@@ -45,7 +46,7 @@ The plugin ships a browser half (`exports["./client"]`, declared via `dsh.client
|
|
|
45
46
|
## Requirements
|
|
46
47
|
|
|
47
48
|
- DeepSeek Harness Web profile (`dsh web`), any recent 0.1.x release
|
|
48
|
-
- Selectors and slot contracts verified against 0.1.2-rc.1
|
|
49
|
+
- Selectors and slot contracts verified against 0.1.2-rc.1, 0.1.5-rc.1 and 0.2.0-rc.2 (2026-10-04, by the live probes below plus a static audit of the shipped client bundles); they target product slot contracts that are stable within a version line but may need small updates after a major product revamp
|
|
49
50
|
|
|
50
51
|
## Install
|
|
51
52
|
|
|
@@ -65,16 +66,18 @@ Restart `dsh web`, then refresh the browser page.
|
|
|
65
66
|
|
|
66
67
|
## Overrides (optional)
|
|
67
68
|
|
|
68
|
-
The native takeover is on by default **on narrow viewports
|
|
69
|
+
The native takeover is on by default **on narrow viewports and on any touch device** — screen size is not the criterion. To override it — for instance to exercise it on a desktop browser:
|
|
69
70
|
|
|
70
71
|
```js
|
|
71
72
|
// Browser console, same origin, persists
|
|
72
73
|
localStorage.setItem("dsh-mobile-flow:input", "off"); // never take over
|
|
73
|
-
localStorage.setItem("dsh-mobile-flow:input", "on"); //
|
|
74
|
-
localStorage.setItem("dsh-mobile-flow:input", "force"); //
|
|
75
|
-
localStorage.removeItem("dsh-mobile-flow:input"); // back to default
|
|
74
|
+
localStorage.setItem("dsh-mobile-flow:input", "on"); // always take over (any viewport, desktop included)
|
|
75
|
+
localStorage.setItem("dsh-mobile-flow:input", "force"); // same as "on" (what the URL flag =1 writes)
|
|
76
|
+
localStorage.removeItem("dsh-mobile-flow:input"); // back to default: narrow or touch
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
The easier route is the 「输入法✓/✗」 chip in the tool row: it reports **what this device is actually doing** (not the stored preference), and one tap flips between "always" and "never".
|
|
80
|
+
|
|
78
81
|
One-shot override: `?dsh-mobile-input=1` (force) / `=0` (off).
|
|
79
82
|
|
|
80
83
|
Diagnostics have their own persisted switch (the tool-row 诊断 button writes it; no URL typing needed):
|
|
@@ -95,20 +98,21 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // back to default
|
|
|
95
98
|
|
|
96
99
|
## Verify
|
|
97
100
|
|
|
98
|
-
Open a session on a phone (or a desktop DevTools window narrowed to ≤720px)
|
|
101
|
+
Open a session on a phone (or a desktop DevTools window narrowed to ≤720px) — **a tablet works the same at any width**:
|
|
99
102
|
|
|
100
103
|
- Swipe up a few screens: the input bar and confirmation cards scroll away with the messages.
|
|
101
104
|
- The draft surface should be a native textarea (the page carries `[data-mobile-input]`). Type with an IME or voice input: text must not be cleared.
|
|
102
105
|
- Send with the send button or Enter (same gesture as the stock composer).
|
|
103
106
|
- Send-button state: **with text in the field the button is live** (the grey→live flip follows the field's content); one tap sends one message, first tap included.
|
|
104
|
-
- The tool row shows **
|
|
107
|
+
- The tool row shows **one chip**, 「输入法✓」(the escape hatch); a 600 ms long press on it calls out the 「诊断」 chip.
|
|
108
|
+
- On 0.2.0 the first load of a fresh browser profile shows the product's 「预览版说明」 preview notice; acknowledge it (继续) before verifying — it takes focus and paints over the composer, so nothing under it can be typed into.
|
|
105
109
|
- The v0.6.0 regression to watch: **no layout movement while typing** — the field's height stays put until focus
|
|
106
110
|
leaves, and only then follows the content.
|
|
107
|
-
- Widen the window
|
|
111
|
+
- Widen the window: the stock behavior returns on a **mouse-driven** desktop; a tablet/phone stays taken over however wide it gets (the tool row keeps the 「输入法✓」 chip).
|
|
108
112
|
|
|
109
113
|
## Update discipline (this plugin's convention)
|
|
110
114
|
|
|
111
|
-
1. **Hot updates only**: after a change,
|
|
115
|
+
1. **Hot updates only**: after a change, **a page refresh is enough** — never make the user clear caches. Client bundles are served `immutable`, but the rev is recomputed from the artifact file's mtime/size by the client-modules HMR watch (measured 2026-10-06: new code served in a fresh page with no `dsh web` restart); only a disabled watch needs a restart.
|
|
112
116
|
2. **Always keep an escape hatch**: anything that takes over stock UI behaviour must be revertible from the page itself (see the 输入法✓/✗ button below), so a bad build never blocks normal use.
|
|
113
117
|
|
|
114
118
|
## Compatibility hardening (v0.5.1 / v0.5.2 / v0.6.0, after HarmonyOS / ArkWeb feedback)
|
|
@@ -219,6 +223,20 @@ Two fixes:
|
|
|
219
223
|
- **The look**: the machine draft is stale, but the FIELD knows whether it holds text, and `:placeholder-shown` is the one pure-CSS value probe. While the field holds text and the machine draft is still empty, the takeover paints that button enabled (opacity 1, pointer cursor, pressed feedback) — **zero DOM writes**, the hard rule stands. Once the draft is in sync (e.g. after a commit) the button is left exactly as the product renders it.
|
|
220
224
|
- **The gesture**: the card's capture `pointerdown` now recognises the submit control (the trailing row's last primary button; an interruptible child's extra Stop circle renders before it) and, when a draft is still uncommitted, **takes the gesture**: commit the draft, then run **the product's own send gesture** — the same path Enter takes (slash adjudication, busy policy, steer/queue). One tap, one send, no engine timing involved. Under a running turn the button now wears the send arrow while the field holds text (the Stop square is hidden and the product's own arrow path is painted as a mask), so face and gesture agree.
|
|
221
225
|
|
|
226
|
+
**v0.8.1 (tablet re-check: "sending a message kills the session")**: the report was that sending while a turn streams
|
|
227
|
+
**terminates that turn** — the message lands in the pending queue instead, and 「插话发送」 (steer) is greyed out
|
|
228
|
+
forever (only a *running* turn can be steered), so the only way out was delete-and-resend. The cause sits in the
|
|
229
|
+
v0.7.3 gesture bridge: canceling `pointerdown` does **not** cancel the tap's own `click` (measured 2026-10-12, Chrome
|
|
230
|
+
152: a trusted `click` on that very button ~100 ms later, when the finger lifts). By then the carried submit has
|
|
231
|
+
emptied the machine draft, so the product reads its own button as the running turn's **Stop** control and calls
|
|
232
|
+
`session/cancel` — the turn the user just sent into dies with `turn/end aborted reason user`, the queued message stays
|
|
233
|
+
behind (`keepInbox`), exactly what the host log showed. The fix records the carried control and **swallows that one
|
|
234
|
+
trailing click** (same button, within 1 s) in the card's capture phase (`preventDefault` + `stopPropagation`), so one
|
|
235
|
+
tap is one send; a click with no carried tap in front of it still reaches the product (tapping the button with an
|
|
236
|
+
empty field remains the stock Stop). Verified by two new jsdom assertions plus the live page at 640×1024 and
|
|
237
|
+
1024×768: only `session/prompt` is sent, no `session/cancel`, the turn keeps streaming; the empty-field tap still
|
|
238
|
+
sends `session/cancel`.
|
|
239
|
+
|
|
222
240
|
**Escape hatch**: a small tool-row button (**输入法✓ / 输入法✗**, narrow viewports only) swaps back to the stock
|
|
223
241
|
input box and remembers the choice — no device can be left stuck.
|
|
224
242
|
|
package/README.zh.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)Web UI 的移动端插件,两件事:
|
|
6
6
|
|
|
7
7
|
1. **in-flow composer**:窄屏(≤720px)时输入框与 AI 确认选择框随页面滚动,而不是钉死在屏幕底部——上滑阅读时消息区拿回全屏空间。
|
|
8
|
-
2.
|
|
8
|
+
2. **原生输入框接管**:窄屏**或触屏设备**(大屏平板同样在内)时输入区换成原生 `<textarea>`,绕开 Lexical contenteditable 在 Android/鸿蒙输入法(尤其是语音输入)下吞字/清空已输入文字的问题。
|
|
9
9
|
|
|
10
10
|
纯客户端实现(CSS overlay + 一个 slot 组件),不修改产品源码;卸载插件即完全还原官方行为。
|
|
11
11
|
|
|
@@ -21,18 +21,19 @@
|
|
|
21
21
|
|
|
22
22
|
## 实际效果
|
|
23
23
|
|
|
24
|
-
≤720px
|
|
24
|
+
≤720px 视口(与官方问题卡片的断点一致)下,前四条是布局修复;后几条按「窄屏或触屏设备」生效——**输入法问题跟屏幕大小无关**,鸿蒙平板在 1280px 上照样吞字(2026-10-05 用户反馈:MatePad + 豆包语音输入):
|
|
25
25
|
|
|
26
26
|
1. **composer 座位回归文档流** —— 输入框和确认卡片位于消息流末尾。上滑时随内容滚出视口,消息全屏可读。
|
|
27
27
|
2. **短会话仍然贴底** —— 消息不足一屏时消息区自动拉伸,输入框保持贴视口底,视觉与官方行为完全一致;长会话则享受全屏阅读。
|
|
28
28
|
3. **浮动控件重新锚定** —— 回到底部按钮、轮次导航不再为吸底座位预留高度,回到贴近视口底的位置。
|
|
29
29
|
4. **纤细边距** —— 消息区、会话头部、输入卡、确认卡片与屏幕边缘之间的内容层 padding(每边 16–32px)收窄为左右 4px、顶部 8px 的纤细边距,收复大部分被浪费的宽度,又不像完全贴边那样局促。页面外壳本身不产生空白,间隙完全来自这些 padding。
|
|
30
|
-
5. **切会话不自动聚焦** ——
|
|
31
|
-
6. **workspace 行操作按钮常显** —— 分组行与会话行尾部的按钮(⋯ 菜单含重命名/删除、+ 在该 workspace
|
|
32
|
-
7. **原生输入框接管** ——
|
|
33
|
-
8. **发送按钮跟「输入框里有什么」走** ——
|
|
30
|
+
5. **切会话不自动聚焦** —— 官方在每次挂载/切换会话时把焦点还给输入框(桌面便利设计),手机上会弹出输入法占掉半屏,平板上也一样挡事。窄屏与触屏设备上拦截这类程序化聚焦;你主动点输入框时照常聚焦。
|
|
31
|
+
6. **workspace 行操作按钮常显** —— 分组行与会话行尾部的按钮(⋯ 菜单含重命名/删除、+ 在该 workspace 新建会话)官方仅在悬停时显示;触屏没有悬停,窄屏与触屏设备下侧边栏列表渲染时常显这两个按钮。
|
|
32
|
+
7. **原生输入框接管** —— 窄屏或触屏设备默认接管(鼠标操作的桌面端不动),输入区绘制成原生 textarea,外观、内边距、字号行高与官方完全一致(几何按官方输入区实时测量对齐)。回车仍是官方发送手势(含 `/` 菜单仲裁、busy-Enter 策略),粘贴图片转发给官方附件入口,锁定态跟随官方可编辑性开关。
|
|
33
|
+
8. **发送按钮跟「输入框里有什么」走** —— 只要输入框里有字,右下角发送按钮就是**可点的亮色**(不再是灰的),点一下即发送(运行中的回合则是排队/引导),并且把这一记 tap 自己的尾随 `click` 吞掉,官方就再也不会把同一次点击读成「打断」(v0.8.1);打字期间这个状态由纯 CSS 驱动,**零 DOM 写入**,不破坏「打字期零 DOM 变动」这条硬约束。
|
|
34
|
+
9. **开关不分屏幕大小** —— 工具行的「输入法✓/✗」在任何视口都显示(不只在手机上),所以「大屏平板要不要用原生输入框」永远有一键可切的地方;它同时也兼容「本插件没识别出这是触屏设备」的情况。
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
**鼠标操作的桌面端完全不受影响**(触屏设备即使屏幕很宽也会接管——这正是本次改动要的结果)。
|
|
36
37
|
|
|
37
38
|
## 工作原理
|
|
38
39
|
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
## 环境要求
|
|
46
47
|
|
|
47
48
|
- DeepSeek Harness Web profile(`dsh web`),任意较新的 0.1.x 版本
|
|
48
|
-
- 选择器与 slot 契约已在 0.1.2-rc.1
|
|
49
|
+
- 选择器与 slot 契约已在 0.1.2-rc.1、0.1.5-rc.1 与 0.2.0-rc.2 上验证(0.2.0 复核于 2026-10-04,用下方真机探针 + 装机客户端包静态审查);它们瞄准产品 slot 契约,同一版本线内稳定,产品大改版后可能需要小幅更新
|
|
49
50
|
|
|
50
51
|
## 安装
|
|
51
52
|
|
|
@@ -65,16 +66,18 @@ dsh plugin --profile web add github:imroc/dsh-mobile-flow
|
|
|
65
66
|
|
|
66
67
|
## 开关(可选)
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
原生输入框接管默认**在窄屏(≤720px)或触屏设备上启用**——跟屏幕多大无关。需要覆盖时(例如在桌面浏览器上验证):
|
|
69
70
|
|
|
70
71
|
```js
|
|
71
72
|
// 浏览器控制台,同源页面均生效,持久化
|
|
72
73
|
localStorage.setItem("dsh-mobile-flow:input", "off"); // 永远用官方输入框
|
|
73
|
-
localStorage.setItem("dsh-mobile-flow:input", "on"); //
|
|
74
|
-
localStorage.setItem("dsh-mobile-flow:input", "force"); //
|
|
75
|
-
localStorage.removeItem("dsh-mobile-flow:input"); //
|
|
74
|
+
localStorage.setItem("dsh-mobile-flow:input", "on"); // 永远接管(任何视口,含桌面)
|
|
75
|
+
localStorage.setItem("dsh-mobile-flow:input", "force"); // 与 "on" 等价(URL 参数 =1 写的也是它)
|
|
76
|
+
localStorage.removeItem("dsh-mobile-flow:input"); // 恢复默认:窄屏或触屏设备自动接管
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
页面上更省事的办法:点工具行的「输入法✓/✗」——它显示的是**当前这台设备实际有没有在接管**(不是存了什么偏好),点一下即在「永远接管」与「永远不接管」之间切换。
|
|
80
|
+
|
|
78
81
|
一次性覆盖也可用 URL 参数:`?dsh-mobile-input=1`(强制)/`=0`(关闭)。
|
|
79
82
|
|
|
80
83
|
诊断开关同理(持久化,点工具行「诊断」按钮即可,无需手打 URL):
|
|
@@ -95,19 +98,20 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
|
95
98
|
|
|
96
99
|
## 验证
|
|
97
100
|
|
|
98
|
-
手机上(或桌面 DevTools 缩到 ≤720px
|
|
101
|
+
手机上(或桌面 DevTools 缩到 ≤720px 窄窗)打开一个会话;**平板上(任何宽度)同样适用**:
|
|
99
102
|
|
|
100
103
|
- 往上滑几屏——输入框和确认卡片应随消息滚出屏幕。
|
|
101
104
|
- 输入区应是原生 textarea(页面里存在 `[data-mobile-input]`)。用输入法打字/语音输入,文字不应被自动清除。
|
|
102
105
|
- 发送:点发按钮或按回车(回车等同官方发送手势)。
|
|
103
106
|
- 发送按钮状态:**输入框里有字时按钮就是亮色可点的**(灰→亮由字段内容驱动);点一下即发送,一次点击一条消息。
|
|
104
|
-
-
|
|
107
|
+
- 工具行日常只有**一个小按钮**「输入法✓」(逃生通道);在它上面**长按 600ms** 才会叫出「诊断」(再长按收起)。
|
|
108
|
+
- 0.2.0 上全新浏览器 profile 首次打开会弹产品的「预览版说明」提示框,先点「继续」再验证——它会夺走焦点并盖在输入框上,不点掉的话下面什么都打不进去。
|
|
105
109
|
- 关键回归点(v0.6.0):**打字期间不要有任何布局跳动**——字段高度在聚焦期间不变,失焦后才按内容长高。
|
|
106
|
-
-
|
|
110
|
+
- 拉宽窗口:**用鼠标的桌面才恢复官方行为**;平板/手机即使屏幕很宽也应保持接管(工具行有「输入法✓」)。
|
|
107
111
|
|
|
108
112
|
## 更新纪律(本插件的约定)
|
|
109
113
|
|
|
110
|
-
1.
|
|
114
|
+
1. **必须支持热更新**:改完**刷新页面即可**,不要让用户自己清缓存——客户端产物带 `immutable` 缓存,但 rev 是按产物文件 mtime/size 重算的(client-modules 的 HMR 监视,2026-10-06 实测:改完不重启 dsh-web,新开页面即拿到新码);只有监视被关掉时才需要重启 dsh-web。
|
|
111
115
|
2. **必须预留逃生通道**:凡是接管官方 UI 行为的功能,都要能在页面上一条操作回退(见下文「输入法✓/✗」按钮),改坏了不影响正常使用。
|
|
112
116
|
|
|
113
117
|
## 兼容性加固(v0.5.1 / v0.5.2 / v0.6.0,HarmonyOS / ArkWeb 实测反馈)
|
|
@@ -162,6 +166,8 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
|
162
166
|
- **外观**:机器草稿是旧的,但**字段自己知道有没有字**,而 `:placeholder-shown` 是纯 CSS 里唯一能读字段值的探针。于是接管卡片在「字段有字 + 机器草稿仍为空」时把发送按钮画成启用态(opacity 1、手型光标、按下有反馈)——**零 DOM 写入**,硬约束不破;机器草稿已经同步(如已提交)时不动它,保持官方原样。
|
|
163
167
|
- **手势**:卡片捕获阶段的 `pointerdown` 判断「点的是不是提交按钮(trailing 行里最后一颗 primary,可中断子会话额外的 Stop 圆钮在它前面)」且「还有未提交的文字」,是则**拿走这次手势**:先落草稿,再走**官方自己的发送手势**(与回车同一条路:斜杠仲裁、busy 策略、排队/引导),一次点击一条消息,不再看引擎脸色。运行中的回合里,字段有字时按钮**换成发送箭头**(隐藏官方的 Stop 方块,用官方同一份箭头 path 画 mask),点它就是发送——脸和手势一致。
|
|
164
168
|
|
|
169
|
+
**v0.8.1(平板复验反馈:发一条消息,正在跑的回合被打断)**:现象是「一发送,会话就被终止」——消息进了待发送队列,而「插话发送」永远是灰的(只有**运行中**的回合才能插话),只能删掉那条消息重新发。根因在 v0.7.3 的手势桥接:**取消 `pointerdown` 并不能取消这一记 tap 自己的 `click`**(2026-10-12 在 Chrome 152 上实测:手指抬起后约 100 ms,同一颗按钮上照样派发一次 `isTrusted` 的 click)。那时我们 carry 的那次提交已经把机器草稿清空,官方读到「运行中 + 草稿为空」就把它当成 **Stop 圆钮**,于是发出 `session/cancel` —— 用户刚发进去的那个回合当场被杀(宿主机日志里正是 `turn/end aborted reason user`,且因为 `keepInbox` 排队消息留在队列里),与现象完全对上。修法:carry 成功时记下这颗按钮,紧随其后的那一记 click(同一颗按钮、1 秒内)在卡片捕获阶段 `preventDefault + stopPropagation` 吞掉——一次 tap 就是一次发送;**没有 carry 的 click 照旧放行**(空输入框时点它就是官方的 Stop,不能挡)。验证:jsdom 新增两条断言 + 真实页面探针(竖屏 640×1024、横屏 1024×768 各跑一遍:只发 `session/prompt`、没有 `session/cancel`、回合继续跑;空输入框点击仍发出 `session/cancel`)。
|
|
170
|
+
|
|
165
171
|
**v0.7.2(手机反馈:新建会话后点输入框没反应)**:新会话刚建出来、**workspace 还没解析出来**时,官方卡片其实是「选择工作区」的**选择器触发器**(`aria-haspopup="menu"`、`contenteditable="false"`、没有绑编辑器),根本不是输入框。旧版接管照样压上去,还把这份「不可编辑」**锁存**成原生 textarea 的 `readOnly` —— 于是:点输入框**不弹键盘、不出现光标**,同时把官方那记「打开选择器」的点击吞掉,用户既不能打字也打不开选择器;而且 workspace 解析好之后它**不会自愈**(只有换会话重新挂载才恢复),正好对上「第一次新建会话没反应、切走再新建就好」的现象(2026-09-19)。
|
|
166
172
|
|
|
167
173
|
处理:
|
package/lib/client.js
CHANGED
|
@@ -88,13 +88,26 @@ window.__ModuleLoader__.load({
|
|
|
88
88
|
" display: none !important;",
|
|
89
89
|
" }",
|
|
90
90
|
"}",
|
|
91
|
+
"/* Touch devices of any width: a tablet has no hover either, so the same",
|
|
92
|
+
" two affordances stay visible there. Only these two rules ride the",
|
|
93
|
+
" coarse-pointer query — the layout transforms above are phone tuning",
|
|
94
|
+
" (a tablet keeps the stock docked composer and stock paddings). */",
|
|
95
|
+
"@media (any-pointer: coarse) {",
|
|
96
|
+
" [class*='_rowActions'] {",
|
|
97
|
+
" display: inline-flex !important;",
|
|
98
|
+
" }",
|
|
99
|
+
" [class*='_sessionRow'] [class*='_time'] {",
|
|
100
|
+
" display: none !important;",
|
|
101
|
+
" }",
|
|
102
|
+
"}",
|
|
91
103
|
].join("\n");
|
|
92
104
|
|
|
93
105
|
/* Native-input takeover styles. These ride OUTSIDE the media query on
|
|
94
|
-
purpose: the JS half decides when the takeover is live (narrow viewport
|
|
95
|
-
by default, or an explicit override) and marks the card
|
|
96
|
-
data-mobile-input-active, so a forced takeover also works on a
|
|
97
|
-
viewport. Every rule below is inert until that marker is
|
|
106
|
+
purpose: the JS half decides when the takeover is live (a narrow viewport
|
|
107
|
+
or a touch device by default, or an explicit override) and marks the card
|
|
108
|
+
with data-mobile-input-active, so a forced takeover also works on a
|
|
109
|
+
desktop viewport. Every rule below is inert until that marker is
|
|
110
|
+
present. */
|
|
98
111
|
var INPUT_CSS = [
|
|
99
112
|
"/* ── dsh-mobile-flow: native textarea takeover ── */",
|
|
100
113
|
"/* The stock draft surface keeps its box (it is what sizes the card) but",
|
|
@@ -197,11 +210,13 @@ window.__ModuleLoader__.load({
|
|
|
197
210
|
" -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z' fill='%23000'/%3E%3C/svg%3E\") center / 16px 16px no-repeat;",
|
|
198
211
|
" mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z' fill='%23000'/%3E%3C/svg%3E\") center / 16px 16px no-repeat;",
|
|
199
212
|
"}",
|
|
200
|
-
"/* Escape hatch in the composer tool row (
|
|
201
|
-
" returns the stock input box when the native
|
|
202
|
-
" we cannot test on
|
|
203
|
-
"
|
|
204
|
-
"
|
|
213
|
+
"/* Escape hatch in the composer tool row (every viewport — a tablet needs it",
|
|
214
|
+
" as much as a phone): one tap returns the stock input box when the native",
|
|
215
|
+
" one misbehaves on a device we cannot test on, and it is also how a device",
|
|
216
|
+
" the default gate does not detect asks for the takeover. Its neighbour",
|
|
217
|
+
" switches the diagnostics surfaces on and off (a tokenized URL is rewritten",
|
|
218
|
+
" before plugins load, so a phone cannot always pass ?dsh-mobile-input=...",
|
|
219
|
+
" from the address bar). */",
|
|
205
220
|
"[data-mobile-input-toggle],",
|
|
206
221
|
"[data-mobile-input-copy],",
|
|
207
222
|
"[data-mobile-input-diagnostics] {",
|
|
@@ -333,7 +348,8 @@ window.__ModuleLoader__.load({
|
|
|
333
348
|
diagnostics.add("h=" + window.innerHeight
|
|
334
349
|
+ " growth=" + readGrowthMode()
|
|
335
350
|
+ " input=" + String(readOverride())
|
|
336
|
-
+ " narrow=" + String(window.matchMedia !== undefined && window.matchMedia(NARROW_QUERY).matches)
|
|
351
|
+
+ " narrow=" + String(window.matchMedia !== undefined && window.matchMedia(NARROW_QUERY).matches)
|
|
352
|
+
+ " touch=" + String(isTouchDevice()));
|
|
337
353
|
ctx.effect(function () {
|
|
338
354
|
return watchKeyboard(diagnostics);
|
|
339
355
|
});
|
|
@@ -377,11 +393,12 @@ window.__ModuleLoader__.load({
|
|
|
377
393
|
}, createNativeInputToggle(diagnostics)));
|
|
378
394
|
}
|
|
379
395
|
|
|
380
|
-
/* 5) No auto-focus on session switch (narrow viewports):
|
|
381
|
-
unlock effect focuses the contenteditable on every mount /
|
|
382
|
-
switch (packages/client/ui-conversation/.../InputBar.tsx, deps
|
|
396
|
+
/* 5) No auto-focus on session switch (narrow viewports and touch devices):
|
|
397
|
+
InputBar's unlock effect focuses the contenteditable on every mount /
|
|
398
|
+
session switch (packages/client/ui-conversation/.../InputBar.tsx, deps
|
|
383
399
|
[locked, sessionId, editor]) — a desktop convenience that pops the
|
|
384
|
-
IME over half the screen on phones
|
|
400
|
+
IME over half the screen on phones AND on tablets (a big screen has no
|
|
401
|
+
room for a software keyboard either). Swallow programmatic focus() on
|
|
385
402
|
the composer's editable unless the user just tapped it (or is
|
|
386
403
|
keyboard-navigating); taps reach the box via the normal focus path.
|
|
387
404
|
Question-card textareas (autoFocus on purpose) are not contenteditable
|
|
@@ -410,7 +427,7 @@ window.__ModuleLoader__.load({
|
|
|
410
427
|
var nativeFocus = HTMLElement.prototype.focus;
|
|
411
428
|
HTMLElement.prototype.focus = function (opts) {
|
|
412
429
|
if (
|
|
413
|
-
narrow.matches &&
|
|
430
|
+
(narrow.matches || isTouchDevice()) &&
|
|
414
431
|
this.isContentEditable === true &&
|
|
415
432
|
typeof this.closest === "function" &&
|
|
416
433
|
this.closest("[data-composer-seat]") !== null &&
|
|
@@ -477,6 +494,24 @@ window.__ModuleLoader__.load({
|
|
|
477
494
|
the `dsh-mobile-input` query parameter for one page load. */
|
|
478
495
|
var OVERRIDE_KEY = "dsh-mobile-flow:input";
|
|
479
496
|
var NARROW_QUERY = "(max-width: 720px)";
|
|
497
|
+
/* The takeover answers a MOBILE IME, and a mobile IME has nothing to do with
|
|
498
|
+
the viewport width: a HarmonyOS/Android tablet runs the same keyboard (and
|
|
499
|
+
loses typed text the same way) at 1280px. Touch is the honest signal the
|
|
500
|
+
page can read, so the default gate is "narrow OR touch" — a mouse-driven
|
|
501
|
+
desktop, where the Lexical editor is not at risk, stays untouched.
|
|
502
|
+
(Reported 2026-10-05 from a MatePad + Doubao voice input.) */
|
|
503
|
+
var TOUCH_QUERY = "(any-pointer: coarse)";
|
|
504
|
+
|
|
505
|
+
function isTouchDevice() {
|
|
506
|
+
try {
|
|
507
|
+
if (typeof window.matchMedia === "function" && window.matchMedia(TOUCH_QUERY).matches === true) return true;
|
|
508
|
+
} catch (error) { /* fall through to the capability probe */ }
|
|
509
|
+
try {
|
|
510
|
+
if (typeof navigator === "object" && navigator !== null
|
|
511
|
+
&& typeof navigator.maxTouchPoints === "number" && navigator.maxTouchPoints > 0) return true;
|
|
512
|
+
} catch (error) { /* no navigator: treat as a mouse device */ }
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
480
515
|
|
|
481
516
|
function readOverride() {
|
|
482
517
|
try {
|
|
@@ -806,33 +841,23 @@ window.__ModuleLoader__.load({
|
|
|
806
841
|
return media !== null && media.matches === true;
|
|
807
842
|
}
|
|
808
843
|
|
|
809
|
-
/**
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
var
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
var update = function () { setNarrow(media.matches === true); };
|
|
824
|
-
update();
|
|
825
|
-
if (typeof media.addEventListener === "function") media.addEventListener("change", update);
|
|
826
|
-
else if (typeof media.addListener === "function") media.addListener(update);
|
|
827
|
-
return function () {
|
|
828
|
-
if (typeof media.removeEventListener === "function") media.removeEventListener("change", update);
|
|
829
|
-
else if (typeof media.removeListener === "function") media.removeListener(update);
|
|
830
|
-
};
|
|
831
|
-
}, [media]);
|
|
832
|
-
return narrow;
|
|
844
|
+
/**
|
|
845
|
+
* Whether the takeover should own the surface on this viewport.
|
|
846
|
+
*
|
|
847
|
+
* Preference first ("on"/"force" = always, "off" = never); with no stored
|
|
848
|
+
* preference the DEVICE decides: narrow viewports (the original gate) or
|
|
849
|
+
* any touch device (a tablet's IME is the same problem at a larger width).
|
|
850
|
+
* @param media - the narrow-viewport matcher, or null.
|
|
851
|
+
*/
|
|
852
|
+
function takeoverLive(media) {
|
|
853
|
+
var override = readOverride();
|
|
854
|
+
if (override === "on" || override === "force") return true;
|
|
855
|
+
if (override === "off") return false;
|
|
856
|
+
if (media !== null && media.matches === true) return true;
|
|
857
|
+
return isTouchDevice();
|
|
833
858
|
}
|
|
834
859
|
|
|
835
|
-
/**
|
|
860
|
+
/** Viewport + device flag, live across rotation, resize and override edits. */
|
|
836
861
|
function useTakeoverViewport() {
|
|
837
862
|
var media = React.useMemo(function () {
|
|
838
863
|
try {
|
|
@@ -1618,10 +1643,14 @@ window.__ModuleLoader__.load({
|
|
|
1618
1643
|
var primaries = trailing.querySelectorAll("[class*='_primary']");
|
|
1619
1644
|
return primaries.length !== 0 && primaries[primaries.length - 1] === button;
|
|
1620
1645
|
};
|
|
1646
|
+
/** The tap whose gesture this bridge carried, waiting for the engine's
|
|
1647
|
+
own trailing `click` (see onClickCapture). */
|
|
1648
|
+
var carriedClick = null;
|
|
1621
1649
|
var onPointerDown = function (event) {
|
|
1622
1650
|
var area = areaRef.current;
|
|
1623
1651
|
var wrap = wrapRef.current;
|
|
1624
1652
|
if (area === null || wrap === null || event.target === area) return;
|
|
1653
|
+
carriedClick = null; /* a new tap supersedes any carry still waiting */
|
|
1625
1654
|
var x = event.clientX;
|
|
1626
1655
|
var y = event.clientY;
|
|
1627
1656
|
debug("tap " + Math.round(x) + "," + Math.round(y) + " target=" + (event.target && event.target.tagName));
|
|
@@ -1651,11 +1680,37 @@ window.__ModuleLoader__.load({
|
|
|
1651
1680
|
if (submitAt(button) === true && pendingRef.current !== null) {
|
|
1652
1681
|
event.preventDefault();
|
|
1653
1682
|
mirrorNow();
|
|
1654
|
-
|
|
1683
|
+
var carried = sendGesture();
|
|
1684
|
+
debug("carry tap -> " + (carried ? "submit" : "nothing"));
|
|
1685
|
+
/* Canceling `pointerdown` does NOT cancel the tap's own `click`
|
|
1686
|
+
(measured 2026-10-12 on Chrome 152: a trusted click on this very
|
|
1687
|
+
button ~100 ms later, after the finger lifts). By then the
|
|
1688
|
+
carried submit has emptied the machine draft, so the product
|
|
1689
|
+
reads its own button as the running turn's Stop control and
|
|
1690
|
+
cancels the turn the user just sent into — "sending a message
|
|
1691
|
+
kills the session", then the message sits in the queue with
|
|
1692
|
+
steering unavailable (only a running turn can be steered).
|
|
1693
|
+
Swallow that one trailing click. */
|
|
1694
|
+
carriedClick = carried ? { button: button, at: Date.now() } : null;
|
|
1655
1695
|
return;
|
|
1656
1696
|
}
|
|
1697
|
+
carriedClick = null;
|
|
1657
1698
|
mirrorNow();
|
|
1658
1699
|
};
|
|
1700
|
+
/** The engine's click for a tap this bridge already carried: the same
|
|
1701
|
+
control, moments later, and never a second gesture. */
|
|
1702
|
+
var onClickCapture = function (event) {
|
|
1703
|
+
var carried = carriedClick;
|
|
1704
|
+
carriedClick = null;
|
|
1705
|
+
if (carried === null || Date.now() - carried.at > 1000) return;
|
|
1706
|
+
var target = event.target;
|
|
1707
|
+
var insideButton = carried.button !== null && typeof carried.button.contains === "function"
|
|
1708
|
+
&& target !== null && (target === carried.button || carried.button.contains(target) === true);
|
|
1709
|
+
if (insideButton !== true && submitAt(buttonAt(event.clientX, event.clientY)) !== true) return;
|
|
1710
|
+
event.preventDefault();
|
|
1711
|
+
event.stopPropagation();
|
|
1712
|
+
debug("swallow trailing click of a carried tap");
|
|
1713
|
+
};
|
|
1659
1714
|
/* Engines without a working inert: if anything hands focus back to the
|
|
1660
1715
|
stock editor, take it straight back to the field. */
|
|
1661
1716
|
var onFocusIn = function (event) {
|
|
@@ -1672,11 +1727,13 @@ window.__ModuleLoader__.load({
|
|
|
1672
1727
|
if (document.visibilityState === "hidden") mirrorNow();
|
|
1673
1728
|
};
|
|
1674
1729
|
card.addEventListener("pointerdown", onPointerDown, true);
|
|
1730
|
+
card.addEventListener("click", onClickCapture, true);
|
|
1675
1731
|
document.addEventListener("focusin", onFocusIn, true);
|
|
1676
1732
|
window.addEventListener("pagehide", onHide);
|
|
1677
1733
|
document.addEventListener("visibilitychange", onHide);
|
|
1678
1734
|
return function () {
|
|
1679
1735
|
card.removeEventListener("pointerdown", onPointerDown, true);
|
|
1736
|
+
card.removeEventListener("click", onClickCapture, true);
|
|
1680
1737
|
document.removeEventListener("focusin", onFocusIn, true);
|
|
1681
1738
|
window.removeEventListener("pagehide", onHide);
|
|
1682
1739
|
document.removeEventListener("visibilitychange", onHide);
|
|
@@ -1761,17 +1818,19 @@ window.__ModuleLoader__.load({
|
|
|
1761
1818
|
}
|
|
1762
1819
|
|
|
1763
1820
|
/**
|
|
1764
|
-
* Escape hatch in the composer tool row (
|
|
1765
|
-
* swaps the native input back to the stock editor and remembers it,
|
|
1766
|
-
* device the takeover misbehaves on is never a dead end.
|
|
1821
|
+
* Escape hatch in the composer tool row (every viewport, not just phones):
|
|
1822
|
+
* one tap swaps the native input back to the stock editor and remembers it,
|
|
1823
|
+
* so a device the takeover misbehaves on is never a dead end. It is also the
|
|
1824
|
+
* only way to ask for the takeover on a device this build does not detect —
|
|
1825
|
+
* a large tablet reports touch, but a device that lies needs the switch.
|
|
1767
1826
|
* @returns the slot entry component.
|
|
1768
1827
|
*/
|
|
1769
1828
|
function createNativeInputToggle(diagnostics) {
|
|
1770
1829
|
return function NativeInputToggle() {
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
var
|
|
1830
|
+
/* The chip reports WHAT THE TAKEOVER IS DOING on this device, not the raw
|
|
1831
|
+
preference: on a wide mouse-only desktop the default is "off" and the
|
|
1832
|
+
chip must say so (and offer to turn it on). */
|
|
1833
|
+
var on = useTakeoverViewport();
|
|
1775
1834
|
var revealState = React.useState(function () { return readReveal(); });
|
|
1776
1835
|
var revealed = revealState[0];
|
|
1777
1836
|
var setRevealed = revealState[1];
|
|
@@ -1797,7 +1856,6 @@ window.__ModuleLoader__.load({
|
|
|
1797
1856
|
};
|
|
1798
1857
|
React.useEffect(function () {
|
|
1799
1858
|
var update = function () {
|
|
1800
|
-
setPreference(readOverride());
|
|
1801
1859
|
setRevealed(readReveal());
|
|
1802
1860
|
};
|
|
1803
1861
|
window.addEventListener(PREFERENCE_EVENT, update);
|
|
@@ -1810,8 +1868,6 @@ window.__ModuleLoader__.load({
|
|
|
1810
1868
|
endPress();
|
|
1811
1869
|
};
|
|
1812
1870
|
}, []);
|
|
1813
|
-
if (!narrow) return null;
|
|
1814
|
-
var on = preference !== "off";
|
|
1815
1871
|
var label = (on ? "原生输入框:已开启(点击改用官方输入框)" : "原生输入框:已关闭(点击启用)")
|
|
1816
1872
|
+ ";长按显示/隐藏诊断按钮";
|
|
1817
1873
|
var diagnosing = diagnosticsModes().length !== 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-flow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mobile fixes for the DeepSeek Harness Web UI: on narrow (≤720px) screens the input bar and AI confirmation cards scroll with the page instead of pinning to the viewport floor, edges slim down,
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Mobile fixes for the DeepSeek Harness Web UI: on narrow (≤720px) screens the input bar and AI confirmation cards scroll with the page instead of pinning to the viewport floor, edges slim down, and touch devices (any width, a tablet included) no longer auto-focus the input or hide the workspace row actions; the draft surface becomes a native textarea on narrow screens and on any touch device (ArkWeb/Android-IME hardened: zero DOM writes while typing, resizing only at commit points, with a tool-row escape hatch and an on-device diagnostics bench) so mobile IMEs stop clearing typed text and stop dropping the keyboard; the send button follows the field's content (live as soon as there is text, one tap sends), not the deliberately stale machine draft.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|