dsh-mobile-flow 0.4.0 → 0.7.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 +183 -8
- package/README.zh.md +144 -7
- package/lib/client.js +1803 -15
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [简体中文](README.zh.md)
|
|
4
4
|
|
|
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
9
|
|
|
9
|
-
|
|
10
|
+
A client-side overlay plus one slot component. No product source is modified; removing the plugin restores the stock behavior exactly.
|
|
11
|
+
|
|
12
|
+
## The problems
|
|
13
|
+
|
|
14
|
+
### 1. The composer is pinned to the bottom of the screen
|
|
10
15
|
|
|
11
16
|
The stock Web UI pins the composer seat with `position: sticky; bottom: 0`. That seat hosts not only the input bar but also the interactive cards the AI raises mid-conversation — `ask_user_question` option cards, approval prompts, plan review. On a phone all of them stay glued to the bottom of the screen, permanently eating a chunk of the viewport while you scroll back through replies.
|
|
12
17
|
|
|
18
|
+
### 2. Mobile IMEs clear text you already typed
|
|
19
|
+
|
|
20
|
+
The stock composer's text surface is a **Lexical contenteditable** (`createEditor` + `registerPlainText` in `packages/client/ui-conversation`). Android IMEs — voice keyboards above all — drive composition through Chrome's `InputConnection → beforeinput("insertCompositionText")` recomposition flow, and a framework editor answers those events by writing its own state back into the DOM. The text being composed, and on recomposition text already committed, gets wiped. A native `<textarea>` is edited by the platform itself and never enters that fight, which is why the same IME behaves in an ordinary textarea comment box.
|
|
21
|
+
|
|
13
22
|
## What it does
|
|
14
23
|
|
|
15
24
|
On viewports ≤720px (the same breakpoint the official question card uses):
|
|
@@ -20,20 +29,22 @@ On viewports ≤720px (the same breakpoint the official question card uses):
|
|
|
20
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.
|
|
21
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. In narrow viewports the programmatic focus is swallowed; tapping the input box still focuses it normally.
|
|
22
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.
|
|
23
|
-
7. **
|
|
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.
|
|
24
33
|
|
|
25
34
|
Desktop (wide viewports) is completely unaffected.
|
|
26
35
|
|
|
27
36
|
## How it works
|
|
28
37
|
|
|
29
|
-
The plugin ships a browser half (`exports["./client"]`, declared via `dsh.client.platform: "web"`), discovered by the client-modules scanner and loaded from the boot manifest.
|
|
38
|
+
The plugin ships a browser half (`exports["./client"]`, declared via `dsh.client.platform: "web"`), discovered by the client-modules scanner and loaded from the boot manifest.
|
|
30
39
|
|
|
31
|
-
All selectors target the product's stable `data-*` attributes (`data-composer-seat`, `data-conversation-scroll`, `data-phase`, `data-slot`), never CSS-Modules-hashed class names. The sticky-to-static switch is guarded with `:not(:has([data-conversation-composer-overlay]))` so views that own their composer overlay (e.g. trajectory) keep the official absolute positioning. On engines without `:has()` the rules degrade safely back to the stock sticky behavior.
|
|
40
|
+
- **Layout half**: one `<style>` tag with `@media (max-width: 720px)` overrides, removed on unload — fully reversible. All selectors target the product's stable `data-*` attributes (`data-composer-seat`, `data-conversation-scroll`, `data-phase`, `data-slot`), never CSS-Modules-hashed class names. The sticky-to-static switch is guarded with `:not(:has([data-conversation-composer-overlay]))` so views that own their composer overlay (e.g. trajectory) keep the official absolute positioning. On engines without `:has()` the rules degrade safely back to the stock sticky behavior.
|
|
41
|
+
- **Input half**: registered into the official `conversation.input.overlay` slot (a session-scope seat inside the composer card) with the standard props it carries (`useInput` / `inputActions`). Every keystroke is mirrored into the official input machine (`conversation.input.for(ctx).setDraft`), while the read side subscribes to the same store's live snapshot — so the send button, placeholder, `/` trigger pipeline, attachments and draft persistence all keep working, with no stale-render race to clobber fast typing. The Lexical editor keeps its layout box (hidden, never removed) because that box is what sizes the card for the mirrored draft.
|
|
42
|
+
- **Scope**: the takeover owns the surface only while the machine is `plain`. A claim (a `/` command picked from the menu, adjudicating, submitting) hands the surface straight back to the stock editor — command tokens and chips are state a plain-text mirror cannot reproduce.
|
|
32
43
|
|
|
33
44
|
## Requirements
|
|
34
45
|
|
|
35
46
|
- DeepSeek Harness Web profile (`dsh web`), any recent 0.1.x release
|
|
36
|
-
- Selectors verified against 0.1.2-rc.1; they target product slot contracts that are stable within a version line but may need small updates after a major product revamp
|
|
47
|
+
- Selectors and slot contracts verified against 0.1.2-rc.1 and 0.1.5-rc.1; they target product slot contracts that are stable within a version line but may need small updates after a major product revamp
|
|
37
48
|
|
|
38
49
|
## Install
|
|
39
50
|
|
|
@@ -51,14 +62,178 @@ dsh plugin --profile web add github:imroc/dsh-mobile-flow
|
|
|
51
62
|
|
|
52
63
|
Restart `dsh web`, then refresh the browser page.
|
|
53
64
|
|
|
65
|
+
## Overrides (optional)
|
|
66
|
+
|
|
67
|
+
The native takeover is on by default **on narrow viewports only** (≤720px). To override it — for instance to exercise it on a desktop browser:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
// Browser console, same origin, persists
|
|
71
|
+
localStorage.setItem("dsh-mobile-flow:input", "off"); // never take over
|
|
72
|
+
localStorage.setItem("dsh-mobile-flow:input", "on"); // narrow viewports only (default)
|
|
73
|
+
localStorage.setItem("dsh-mobile-flow:input", "force"); // take over on any viewport
|
|
74
|
+
localStorage.removeItem("dsh-mobile-flow:input"); // back to default
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
One-shot override: `?dsh-mobile-input=1` (force) / `=0` (off).
|
|
78
|
+
|
|
79
|
+
Diagnostics have their own persisted switch (the tool-row 诊断 button writes it; no URL typing needed):
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
localStorage.setItem("dsh-mobile-flow:diagnostics", "bench"); // device test bench
|
|
83
|
+
localStorage.setItem("dsh-mobile-flow:diagnostics", "debug"); // event panel
|
|
84
|
+
localStorage.removeItem("dsh-mobile-flow:diagnostics"); // off
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Growth policy (`commit` by default: resize at commit points only):
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
localStorage.setItem("dsh-mobile-flow:growth", "live"); // per-keystroke growth (v0.5.2 behaviour, for A/B)
|
|
91
|
+
localStorage.setItem("dsh-mobile-flow:growth", "none"); // always the floor height
|
|
92
|
+
localStorage.removeItem("dsh-mobile-flow:growth"); // back to default
|
|
93
|
+
```
|
|
94
|
+
|
|
54
95
|
## Verify
|
|
55
96
|
|
|
56
|
-
Open a session on a phone (or a desktop DevTools window narrowed to ≤720px):
|
|
97
|
+
Open a session on a phone (or a desktop DevTools window narrowed to ≤720px):
|
|
98
|
+
|
|
99
|
+
- Swipe up a few screens: the input bar and confirmation cards scroll away with the messages.
|
|
100
|
+
- 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.
|
|
101
|
+
- Send with the send button or Enter (same gesture as the stock composer).
|
|
102
|
+
- The tool row shows **two chips**: 「输入法✓」(the escape hatch) and 「诊断」(open the test bench).
|
|
103
|
+
- The v0.6.0 regression to watch: **no layout movement while typing** — the field's height stays put until focus
|
|
104
|
+
leaves, and only then follows the content.
|
|
105
|
+
- Widen the window and the stock behavior returns.
|
|
106
|
+
|
|
107
|
+
## Update discipline (this plugin's convention)
|
|
108
|
+
|
|
109
|
+
1. **Hot updates only**: after a change, restart `dsh web` so the bundle revision is recomputed — client bundles are served `immutable`, and a new rev is what guarantees a phone refresh picks up the new code (never make the user clear caches).
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
## Compatibility hardening (v0.5.1 / v0.5.2 / v0.6.0, after HarmonyOS / ArkWeb feedback)
|
|
113
|
+
|
|
114
|
+
The first release broke on **HarmonyOS 7's built-in browser (ArkWeb)**: taps rarely opened the keyboard, and only
|
|
115
|
+
one character could be typed. Both are addressed:
|
|
116
|
+
|
|
117
|
+
1. **Taps not reaching the field** — a paint/hit-order bug: the stock input row lives in `.grow`
|
|
118
|
+
(`position: relative`) which comes *after* our overlay seat in DOM order, so at equal stacking level it painted
|
|
119
|
+
(and hit-tested) above the textarea; a `document.elementFromPoint` probe hit the stock container at 3 of 5 sample
|
|
120
|
+
points inside the input box. Fixed with `z-index: 5` + an opaque seat background, `pointer-events: none` on the
|
|
121
|
+
hidden row, and a capture-phase `pointerdown` on the card that focuses the field for taps anywhere inside it.
|
|
122
|
+
2. **One character then nothing** — two defences: the DSH-side draft is never written back into a *focused* field
|
|
123
|
+
(that write resets an IME session; only a committed-send clear is applied), and the stock editor is taken out of
|
|
124
|
+
the focus/IME candidate tree (`inert` + `aria-hidden`, with a `focusin` guard for engines without `inert`);
|
|
125
|
+
geometry writes are idempotent and the field declares `user-select: text` / `touch-action: manipulation`.
|
|
126
|
+
|
|
127
|
+
**v0.5.2 (second round of HarmonyOS feedback)**: with the takeover on, the keyboard closed after every character. Cause: mirroring the draft on **every keystroke** re-rendered the composer card and made Lexical rewrite the hidden editor's DOM — a strict engine drops the IME when the editing surface churns. Now:
|
|
128
|
+
|
|
129
|
+
- **zero DOM churn while typing**: the field owns its text and only commits to the machine at commit points — Enter, blur, any tap outside the field (toolbar/send), page hide, unmount — plus immediately when a `/` or `@` trigger character is typed (the menus need the machine).
|
|
130
|
+
- **the field grows itself**: the textarea autosizes (cap from the product's own 14-line token, floor read from the stock `min-height`: 36px docked / 52px hero) and a matching `min-height` floor is put on the stock row's box, because the absolutely positioned seat cannot make the card grow; it shrinks back after a send clears the draft.
|
|
131
|
+
- **send-button bridge**: the stock send button is disabled while the machine draft is empty, so a tap outside the field commits first and, if that commit enabled the button, the tap is replayed onto it (one tap = send).
|
|
132
|
+
- the stock editor is additionally forced to `contenteditable="false"` next to `inert` + `aria-hidden`, so the IME can only ever target the textarea; while the field is focused the seat never moves, the hidden scroller is left alone, and geometry is rounded to whole pixels (no sub-pixel style churn).
|
|
133
|
+
|
|
134
|
+
**v0.6.0 (third round of HarmonyOS feedback, current)**: taking the mirror off the typing path was still not
|
|
135
|
+
enough — the **per-keystroke geometry writes** were the trigger. `autosize` set the field to `height: 0px` on every
|
|
136
|
+
key, read `scrollHeight`, wrote the height back (the focused editable collapsed once per keystroke), and the seat
|
|
137
|
+
re-aligned itself to the card's box. The rule is now absolute: **while the field has focus the plugin performs zero
|
|
138
|
+
DOM writes**.
|
|
139
|
+
|
|
140
|
+
- **one fixed height while typing**: the field keeps the product's own floor (36px docked / 52px blank-session hero)
|
|
141
|
+
and scrolls internally;
|
|
142
|
+
- **only commit points touch the DOM**: blur, Enter, page hide, unmount, phase flips — that is when measurement,
|
|
143
|
+
re-alignment and chrome syncing (placeholder, read-only) happen. Measurement is non-destructive (the
|
|
144
|
+
`height: 0px` probe is gone); after blur the field grows to its content, keeps the stock row's `min-height` in
|
|
145
|
+
step, and shrinks back when a send clears the draft (the committed send is the only write performed on a focused
|
|
146
|
+
field — it follows an explicit user action);
|
|
147
|
+
- **uncommitted text can no longer be wiped by an empty draft**: the machine draft is stale by design while typing,
|
|
148
|
+
so an empty publish is no longer read as "the user cleared the field";
|
|
149
|
+
- **the diagnostics panel stops perturbing what it measures**: lines land in a memory buffer and are painted only on
|
|
150
|
+
blur, on demand, or on a keyboard transition — the old panel wrote the DOM on every event, making it a suspect in
|
|
151
|
+
the very bug it reported;
|
|
152
|
+
- **keyboard transitions become facts**: window / visualViewport height changes (how ArkWeb reports the on-screen
|
|
153
|
+
keyboard) are timestamped next to the input events.
|
|
154
|
+
|
|
155
|
+
**v0.6.1 (fourth round, the slash case)**: on the device only ONE case still dropped the keyboard — a draft
|
|
156
|
+
starting with `/` (how skills are invoked by hand); plain text was already fine. The cause was the plugin's single
|
|
157
|
+
per-keystroke exception: `TRIGGER.test(fieldValue)` asked "does the text contain `/`", so **any draft containing a
|
|
158
|
+
slash mirrored the machine on every keystroke after it** — re-rendering the composer card, refreshing the command
|
|
159
|
+
menu and rewriting the hidden editor's DOM once per character. Now:
|
|
160
|
+
|
|
161
|
+
- **only the trigger character itself is mirrored** (typed or deleted), because the menu needs the machine; the
|
|
162
|
+
characters typed after it stay in the field until a commit point;
|
|
163
|
+
- **nothing is mirrored mid-composition** (the trigger count captured at `compositionstart` decides at the end);
|
|
164
|
+
- the diagnostics panel gained **「触发符:仅触发 / 逐键 / 关」** switches to A/B this on the device (the old
|
|
165
|
+
per-keystroke behaviour is one tap away, which is also how the cause was confirmed);
|
|
166
|
+
- the tool row gained a **「复制日志」** chip whenever diagnostics are on, and the log now records
|
|
167
|
+
`CHURN while focused` lines — app-side mutations (menu, Lexical) under a live IME are logged too, so "our writes"
|
|
168
|
+
and "the app re-rendering" can be told apart from a log alone.
|
|
169
|
+
|
|
170
|
+
**Known trade-off**: the slash menu still opens, but its list may not filter live while you type (the filter reads the
|
|
171
|
+
machine draft, which is deliberately stale until a commit point). Switch to 「触发符:逐键」 for live filtering, at the
|
|
172
|
+
cost of the keyboard dropping on that device.
|
|
173
|
+
|
|
174
|
+
**v0.7.0 (fifth round, the slash moment; ✅ confirmed on a HarmonyOS 7 ArkWeb device: plain typing, `/` commands and continuous input all behave)**: on the device only a SINGLE keyboard drop remained — the one at the
|
|
175
|
+
moment `/` is typed (everything after it is fine). The device log plus the stock-editor control (typing `/` there
|
|
176
|
+
keeps the keyboard) narrowed the mechanism to one thing: **republishing the draft makes the app rewrite the hidden
|
|
177
|
+
stock editor's DOM asynchronously** (`EDITOR childList +1`, outside our gesture), and ArkWeb drops the IME when an
|
|
178
|
+
editable surface changes from outside the input method. In the stock path that same DOM change IS the user's own
|
|
179
|
+
editing, inside the gesture, so nothing drops.
|
|
180
|
+
|
|
181
|
+
The trigger mirror is now **drop focus → land the write → take focus back**, entirely inside the keystroke's gesture:
|
|
182
|
+
`blur()` first (IME down), then `flushSync` (react-dom) so the re-render the write causes — editor rewrite and menu
|
|
183
|
+
mount — completes with nothing focused, then `focus()` to bring the IME back, still inside the gesture.
|
|
184
|
+
|
|
185
|
+
- the panel gained **「触发:重聚焦 (default) / 仅触发 (v0.6.1) / 逐键 / 关」** to A/B the strategies in one tap;
|
|
186
|
+
- a hidden slash-flow defect is fixed too: **picking a command from the menu now lands in the field** (the old
|
|
187
|
+
"focused field ignores external writes" guard swallowed it; it now only protects UNCOMMITTED text).
|
|
188
|
+
|
|
189
|
+
**v0.7.1**: the tool row stays clean — everyday it carries only the escape hatch (**输入法✓ / 输入法✗**);
|
|
190
|
+
**long-press it for 600ms** to call out the **诊断** (diagnostics) button, long-press again to put it away. While
|
|
191
|
+
diagnostics are on, both 诊断 and 复制日志 (copy log) show up by themselves.
|
|
192
|
+
|
|
193
|
+
**Escape hatch**: a small tool-row button (**输入法✓ / 输入法✗**, narrow viewports only) swaps back to the stock
|
|
194
|
+
input box and remembers the choice — no device can be left stuck.
|
|
195
|
+
|
|
196
|
+
**Diagnostics** (tool-row **诊断** button, or URL `?dsh-mobile-input=bench` / `,debug`):
|
|
197
|
+
|
|
198
|
+
- ⚠️ **a tokenized URL has its query rewritten away by the shell** (it is gone before plugins apply), so on a phone
|
|
199
|
+
use the tool-row button: it persists the request in localStorage and reloads. The URL parameter works for
|
|
200
|
+
cookie-authenticated (token-less) loads.
|
|
201
|
+
- the panel only occupies the top of the screen (variants collapsed behind 「展开变体 A-E」), so **the real composer
|
|
202
|
+
field below it stays typeable — test that first**; expand the variants to localise the culprit — **A** bare textarea (normal flow, no JS writes), **B** textarea inside a `height:0`
|
|
203
|
+
absolute container (the seat's shape), **C** B plus a style write per keystroke (the pre-0.6 autosize, the
|
|
204
|
+
control), **D** B with logging only (the 0.6 behaviour), **E** bare textarea inside an iframe (a clean document).
|
|
205
|
+
The variant followed by a `KEYBOARD ...px` line is the guilty one;
|
|
206
|
+
- three switches (**生产: commit / live / none**) A/B the production field's growth policy on the device itself;
|
|
207
|
+
- the **debug** panel records the composer's tap coordinates and hit target, focus changes, input/composition events
|
|
208
|
+
and geometry writes. Both panels copy their log with one tap, and neither repaints while a field has focus (a panel
|
|
209
|
+
write would pollute the very evidence it collects).
|
|
210
|
+
|
|
211
|
+
**Verified on a live page**: `test/probe-live.mjs` drives the real app over CDP (390x844) and asserts, among other
|
|
212
|
+
things, that **typing writes nothing to the page DOM**.
|
|
213
|
+
|
|
214
|
+
## Known limits
|
|
215
|
+
|
|
216
|
+
- While the takeover is active, inline chip/decoration rendering inside the draft (e.g. `@` reference decorations) is not shown; entering a command claim switches back to the stock editor automatically.
|
|
217
|
+
- Pasting an image is implemented by forwarding the paste to the official attachment intake; browsers that refuse a constructed paste event fall back to the paperclip picker.
|
|
218
|
+
- IME candidate/assist behavior itself belongs to the system keyboard and is outside the plugin's control.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
npm install --no-save jsdom react@18 react-dom@18
|
|
224
|
+
node test/takeover.test.mjs # runs the real client bundle in a jsdom composer card
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The test boots the shipped `lib/client.js`, renders the slot entry through React against a
|
|
228
|
+
composer-card DOM, and asserts the render path, the draft mirror (typing / IME composition /
|
|
229
|
+
machine-side writes), the Enter gesture, and the phase gate that hands the surface back to the
|
|
230
|
+
stock editor. It exists because a broken entry renders nothing visible — only a console error.
|
|
57
231
|
|
|
58
232
|
## Rollback
|
|
59
233
|
|
|
60
234
|
- Bundle install: `dsh plugin --profile web remove dsh-mobile-flow`
|
|
61
235
|
- Manual: remove the dependency and the `dsh.profile.bundles` entry, restart `dsh web`
|
|
236
|
+
- Temporarily: `localStorage.setItem("dsh-mobile-flow:input", "off")` and refresh (turns off the native input only; the layout fixes stay)
|
|
62
237
|
|
|
63
238
|
No product source is modified; upgrades do not overwrite it.
|
|
64
239
|
|
package/README.zh.md
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | **简体中文**
|
|
4
4
|
|
|
5
|
-
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)Web UI
|
|
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. **原生输入框接管**:窄屏时输入区换成原生 `<textarea>`,绕开 Lexical contenteditable 在 Android 输入法(尤其是语音输入)下吞字/清空已输入文字的问题。
|
|
9
|
+
|
|
10
|
+
纯客户端实现(CSS overlay + 一个 slot 组件),不修改产品源码;卸载插件即完全还原官方行为。
|
|
8
11
|
|
|
9
12
|
## 解决的问题
|
|
10
13
|
|
|
14
|
+
### 1. 输入框吸在屏幕底部
|
|
15
|
+
|
|
11
16
|
官方 Web UI 用 `position: sticky; bottom: 0` 把 composer 座位钉在视口底。这个座位承载的不只是输入框,还有 AI 会话中途弹出的交互卡片——`ask_user_question` 选项卡、approval 权限确认框、plan review。手机上它们全部粘在屏幕底部,往回翻阅回复时永久遮挡一块视口。
|
|
12
17
|
|
|
18
|
+
### 2. 手机输入法输入后文字被清空
|
|
19
|
+
|
|
20
|
+
官方 composer 的文本面是 **Lexical contenteditable**(`packages/client/ui-conversation` 里 `createEditor` + `registerPlainText`)。Android 输入法(语音输入尤其明显)走 Chrome 的 `InputConnection → beforeinput("insertCompositionText")` 重组流程,框架编辑器会用自身 state 回写 DOM 来应答这些事件——正在拼写的文字、乃至重组时已提交的文字就被抹掉。原生 `<textarea>` 由平台自己编辑,不参与这场 DOM 与 state 的拉锯,所以同一个输入法在普通 textarea 评论框里一切正常(例如博客评论区的输入框)。
|
|
21
|
+
|
|
13
22
|
## 实际效果
|
|
14
23
|
|
|
15
24
|
≤720px 视口(与官方问题卡片的断点一致)下:
|
|
@@ -20,20 +29,22 @@
|
|
|
20
29
|
4. **纤细边距** —— 消息区、会话头部、输入卡、确认卡片与屏幕边缘之间的内容层 padding(每边 16–32px)收窄为左右 4px、顶部 8px 的纤细边距,收复大部分被浪费的宽度,又不像完全贴边那样局促。页面外壳本身不产生空白,间隙完全来自这些 padding。
|
|
21
30
|
5. **切会话不自动聚焦** —— 官方在每次挂载/切换会话时把焦点还给输入框(桌面便利设计),手机上会弹出输入法占掉半屏。窄屏时拦截这类程序化聚焦;你主动点输入框时照常聚焦。
|
|
22
31
|
6. **workspace 行操作按钮常显** —— 分组行与会话行尾部的按钮(⋯ 菜单含重命名/删除、+ 在该 workspace 新建会话)官方仅在悬停时显示;触屏没有悬停,窄屏下侧边栏列表渲染时常显这两个按钮。
|
|
23
|
-
7.
|
|
32
|
+
7. **原生输入框接管** —— 输入区绘制成原生 textarea,外观、内边距、字号行高与官方完全一致(几何按官方输入区实时测量对齐)。回车仍是官方发送手势(含 `/` 菜单仲裁、busy-Enter 策略),粘贴图片转发给官方附件入口,锁定态跟随官方可编辑性开关。
|
|
24
33
|
|
|
25
34
|
桌面端(宽视口)完全不受影响。
|
|
26
35
|
|
|
27
36
|
## 工作原理
|
|
28
37
|
|
|
29
|
-
插件带浏览器半(`exports["./client"]`,经 `dsh.client.platform: "web"` 声明),由 client-modules 扫描器发现并从 boot manifest
|
|
38
|
+
插件带浏览器半(`exports["./client"]`,经 `dsh.client.platform: "web"` 声明),由 client-modules 扫描器发现并从 boot manifest 装载到每个页面。
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
- **布局部分**:注入一个 `<style>` 标签(`@media (max-width: 720px)` 覆盖),卸载时移除标签——完全可逆。所有选择器都瞄准产品的稳定 `data-*` 属性(`data-composer-seat`、`data-conversation-scroll`、`data-phase`、`data-slot`),绝不依赖 CSS Modules 哈希类名。sticky→static 切换用 `:not(:has([data-conversation-composer-overlay]))` 排除了自带 composer overlay 的视图(如 trajectory)。不支持 `:has()` 的引擎上规则安全退回官方 sticky 行为。
|
|
41
|
+
- **输入框部分**:注册进官方的 `conversation.input.overlay` slot(composer 卡片内的会话级插槽),拿到官方标准 props(`useInput` / `inputActions`)。textarea 的每次输入都通过官方输入机(`conversation.input.for(ctx)` 的 `setDraft`)写回,读侧订阅同一个 store 的实时快照——因此发按钮、占位符、`/` 触发管线、附件、草稿持久化全部照常工作,且不会出现「回显旧值把刚输入的字冲掉」的竞态。官方 Lexical 编辑器保留布局盒(隐藏、不删除),卡片高度继续由草稿内容驱动。
|
|
42
|
+
- **范围**:仅在输入机处于 `plain` 阶段接管。一旦进入 claim(从 `/` 菜单选中命令、adjudicating、submitting),立即把输入面交还官方编辑器——命令 token/chip/装饰是纯文本镜像无法复现的状态。
|
|
32
43
|
|
|
33
44
|
## 环境要求
|
|
34
45
|
|
|
35
46
|
- DeepSeek Harness Web profile(`dsh web`),任意较新的 0.1.x 版本
|
|
36
|
-
-
|
|
47
|
+
- 选择器与 slot 契约已在 0.1.2-rc.1 与 0.1.5-rc.1 上验证;它们瞄准产品 slot 契约,同一版本线内稳定,产品大改版后可能需要小幅更新
|
|
37
48
|
|
|
38
49
|
## 安装
|
|
39
50
|
|
|
@@ -51,14 +62,140 @@ dsh plugin --profile web add github:imroc/dsh-mobile-flow
|
|
|
51
62
|
|
|
52
63
|
重启 `dsh web`,然后刷新浏览器页面。
|
|
53
64
|
|
|
65
|
+
## 开关(可选)
|
|
66
|
+
|
|
67
|
+
原生输入框接管默认**只在窄屏(≤720px)启用**。需要临时覆盖时(例如在桌面浏览器上验证):
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
// 浏览器控制台,同源页面均生效,持久化
|
|
71
|
+
localStorage.setItem("dsh-mobile-flow:input", "off"); // 永远用官方输入框
|
|
72
|
+
localStorage.setItem("dsh-mobile-flow:input", "on"); // 窄屏才接管
|
|
73
|
+
localStorage.setItem("dsh-mobile-flow:input", "force"); // 任何视口都接管
|
|
74
|
+
localStorage.removeItem("dsh-mobile-flow:input"); // 恢复默认
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
一次性覆盖也可用 URL 参数:`?dsh-mobile-input=1`(强制)/`=0`(关闭)。
|
|
78
|
+
|
|
79
|
+
诊断开关同理(持久化,点工具行「诊断」按钮即可,无需手打 URL):
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
localStorage.setItem("dsh-mobile-flow:diagnostics", "bench"); // 真机测试台
|
|
83
|
+
localStorage.setItem("dsh-mobile-flow:diagnostics", "debug"); // 事件面板
|
|
84
|
+
localStorage.removeItem("dsh-mobile-flow:diagnostics"); // 关闭
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
增高策略(默认 `commit`:只在提交点增高):
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
localStorage.setItem("dsh-mobile-flow:growth", "live"); // 逐键增高(v0.5.2 行为,A/B 用)
|
|
91
|
+
localStorage.setItem("dsh-mobile-flow:growth", "none"); // 永远固定高度
|
|
92
|
+
localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
93
|
+
```
|
|
94
|
+
|
|
54
95
|
## 验证
|
|
55
96
|
|
|
56
|
-
手机上(或桌面 DevTools 缩到 ≤720px
|
|
97
|
+
手机上(或桌面 DevTools 缩到 ≤720px 窄窗)打开一个会话:
|
|
98
|
+
|
|
99
|
+
- 往上滑几屏——输入框和确认卡片应随消息滚出屏幕。
|
|
100
|
+
- 输入区应是原生 textarea(页面里存在 `[data-mobile-input]`)。用输入法打字/语音输入,文字不应被自动清除。
|
|
101
|
+
- 发送:点发按钮或按回车(回车等同官方发送手势)。
|
|
102
|
+
- 工具行应有**两个小按钮**:「输入法✓」(逃生通道)与「诊断」(开测试台)。
|
|
103
|
+
- 关键回归点(v0.6.0):**打字期间不要有任何布局跳动**——字段高度在聚焦期间不变,失焦后才按内容长高。
|
|
104
|
+
- 拉宽窗口即恢复官方行为。
|
|
105
|
+
|
|
106
|
+
## 更新纪律(本插件的约定)
|
|
107
|
+
|
|
108
|
+
1. **必须支持热更新**:改完即重启 dsh-web 重算 bundle rev(客户端插件产物带 `immutable` 缓存,换 rev 才能保证手机刷新拿到新码),不要让用户自己清缓存。
|
|
109
|
+
2. **必须预留逃生通道**:凡是接管官方 UI 行为的功能,都要能在页面上一条操作回退(见下文「输入法✓/✗」按钮),改坏了不影响正常使用。
|
|
110
|
+
|
|
111
|
+
## 兼容性加固(v0.5.1 / v0.5.2 / v0.6.0,HarmonyOS / ArkWeb 实测反馈)
|
|
112
|
+
|
|
113
|
+
首版在**鸿蒙 7 自带浏览器(ArkWeb)**上暴露两个问题,已针对性加固:
|
|
114
|
+
|
|
115
|
+
1. **点击输入区弹不出输入法**(只有少数位置能点中)——根因是**绘制/命中层级**:官方输入行所在的 `.grow` 是 `position: relative` 且 DOM 顺序在我们的 overlay seat 之后,同级堆叠下它盖在我们上面。实测(Chromium 探针 `document.elementFromPoint`)输入框 5 个采样点里 3 个命中的是官方容器而不是 textarea。
|
|
116
|
+
修复:seat 加 `z-index: 5` + 不透明背景;官方输入行额外 `pointer-events: none`;并在卡片捕获阶段接管 `pointerdown`(点在输入框范围内就聚焦 textarea),几何漂移也不会再点不中。
|
|
117
|
+
2. **只能输入一个字符 / 输入法中断**——两条防御:
|
|
118
|
+
- **失焦前字段自持文本**:DSH 侧的非空草稿不再回写进正在聚焦的 textarea(IME 组合期间的回写会重置输入会话),只有「发送成功清空」这种外部清空才写回;
|
|
119
|
+
- **官方编辑器移出焦点/输入法候选**:接管期间给它加 `inert` + `aria-hidden`;若引擎不支持 `inert`,则用 `focusin` 守卫把被抢走的焦点抢回 textarea;几何写入改为幂等(无变化不写样式),避免无谓重排干扰 IME;textarea 显式 `user-select: text` / `touch-action: manipulation`(WebKit 系有继承 `user-select:none` 导致"键盘弹出但输不进去"的历史 bug)。
|
|
120
|
+
|
|
121
|
+
**v0.5.2(鸿蒙反馈第二轮)**:开启原生输入框后"每输入一个字符输入法就自动收起"。原因是**每次按键都镜像草稿** → 触发 composer 卡片重渲染 + Lexical 改写隐藏编辑器 DOM → ArkWeb 在编辑面churn 时收起键盘。改为:
|
|
122
|
+
|
|
123
|
+
- **打字期间零 DOM 变动**:字段自持文本,不再逐键回写机器;只在**提交点**同步——回车、失焦、点击输入框以外的任意位置(工具行/发送)、页面隐藏、组件卸载,以及输入 `/` `@` 触发字符时立即同步(菜单需要);
|
|
124
|
+
- **字段自己长高**:textarea 自动增高(上限沿用官方 14 行 token,下限读官方 min-height:停靠 36px / 新会话 hero 52px),并给官方输入行盒子加等高的 `min-height` 兜底(绝对定位的 seat 无法撑高卡片),发送清空后自动缩回;
|
|
125
|
+
- **发送按钮桥接**:机器草稿为空时官方发送按钮是禁用的,点击输入框以外区域时先提交草稿,若这次提交把按钮"点亮"了,就把这记点击补发给它(一次点击即可发送);
|
|
126
|
+
- 官方编辑器除 `inert` + `aria-hidden` 外,再强制 `contenteditable="false"`(把 IME 的可编辑候选集只留 textarea);
|
|
127
|
+
- 打字聚焦期间不改 seat 的 left/top、不动隐藏滚动容器(避免编辑面下的布局位移触发收键盘),几何像素取整避免亚像素抖动反复写样式。
|
|
128
|
+
|
|
129
|
+
**v0.6.0(鸿蒙反馈第三轮,当前)**:把镜像挪出打字路径仍然不够——**逐键的几何写入**才是收键盘的触发点:`autosize` 每按一次键就把字段 `style.height` 置 `0px`、读 `scrollHeight`、再写回(聚焦中的可编辑面在每个按键上塌陷一次),seat 还会随卡片盒子重新对齐。现在这是一条硬约束:**字段有焦点时插件零 DOM 写入**。
|
|
130
|
+
|
|
131
|
+
- **打字期间尺寸固定**:高度锁在产品自身的下限(停靠 36px / 新会话 hero 52px),超出内容在字段内滚动;
|
|
132
|
+
- **只有提交点才动 DOM**:失焦、回车、页面隐藏、组件卸载、阶段切换时才做测量 / 重对齐 / chrome 同步(placeholder、只读态);几何测量改为非破坏式(不再有 `height: 0px` 探针);失焦后按内容长高并同步官方输入行的 `min-height`,发送清空后缩回(发送清空由用户显式动作触发,是唯一在聚焦状态下写入的场景);
|
|
133
|
+
- **未提交的文字不会被空草稿冲掉**:打字期间机器草稿本来就是旧的,空发布不再被当成"用户清空了输入框";
|
|
134
|
+
- **诊断面板不再自我扰动**:日志先进内存缓冲,只在失焦 / 手动刷新 / 键盘开合时渲染——旧面板每事件写一次 DOM,本身就是收键盘的嫌疑;
|
|
135
|
+
- **键盘开合进入日志**:记录 window / visualViewport 高度变化(ArkWeb 用它上报软键盘),"键盘在某次按键后收起"于是变成有时间戳的事实。
|
|
136
|
+
|
|
137
|
+
**v0.6.1(鸿蒙反馈第四轮,斜杠场景)**:真机实测只剩一种情况还会收键盘——**以 `/` 开头的草稿**(手动触发技能的常见写法);普通文字已完全正常。原因是插件里唯一的逐键例外:`TRIGGER.test(整个字段值)` 判断的是「文本里有 `/`」,于是**只要草稿含斜杠,之后每敲一个字符都立刻回写机器草稿** → composer 卡片重渲染 + 命令菜单刷新 + 隐藏编辑器改写 DOM,每键一次 churn,正是 ArkWeb 收键盘的触发条件。改为:
|
|
138
|
+
|
|
139
|
+
- **只同步「触发字符本身」**:按 `/` 或 `@`(以及删掉它们)的那一次立即回写(菜单需要),**之后的字符只留在字段里**,等提交点(失焦/回车/点输入框以外/页面隐藏/卸载)才一次性写入;
|
|
140
|
+
- **输入法组合期间一律不同步**(用组合开始时的触发符计数判断),避免在组合中churn;
|
|
141
|
+
- **诊断面板新增「触发符:仅触发 / 逐键 / 关」开关**,可在真机上直接 A/B:切「逐键」即可复现旧行为(同时也验证了"逐键回写 = 收键盘"这条因果);
|
|
142
|
+
- **工具行新增「复制日志」按钮**(只要开着诊断就显示),不用点开面板就能复制日志;
|
|
143
|
+
- **日志新增 `CHURN while focused` 行**:聚焦期间应用侧(菜单/Lexical)自己发生的 DOM 变更也会被登记,用于区分"我们的写入"和"应用自己的重渲染"。
|
|
144
|
+
|
|
145
|
+
**已知取舍**:斜杠菜单会照常弹出,但列表可能不会随输入实时过滤(过滤读的是机器草稿,而草稿在提交点前是旧的)。要实时过滤就切「触发符:逐键」,代价是那台机器上每字符收键盘。
|
|
146
|
+
|
|
147
|
+
**v0.7.0(鸿蒙反馈第五轮,斜杠那一下;✅ 鸿蒙 7 ArkWeb 真机确认有效:刷新后普通打字、`/` 技能命令、连续输入均正常)**:真机实测只剩 `/` 触发那一刻的**单次**收键盘(之后继续输入正常)。真机日志 + 官方输入框对照实验(官方输入框打 `/` 不收键盘)把机制收窄到一点:**回写草稿会让应用异步重写隐藏的官方编辑器 DOM**(`EDITOR childList +1`,在我们手势之外发生),ArkWeb 对这种"输入法之外的可编辑面变化"会收键盘;而官方路径里那次变化**就是用户自己的编辑**(同一个手势内),所以没事。
|
|
148
|
+
|
|
149
|
+
处理:**触发字符的回写改成「放手 → 落盘 → 拿回来」**,整段发生在同一个按键手势内——先 `blur()`(输入法先下去),再用 `react-dom` 的 `flushSync` 让这次回写引起的重渲染**同步完成**(编辑器改写 + 菜单挂载都发生在没有焦点的时候),最后 `focus()` 把输入法拉回来(仍在手势内,引擎允许)。
|
|
150
|
+
|
|
151
|
+
- 面板新开关 **「触发:重聚焦(默认)/ 仅触发(v0.6.1 行为)/ 逐键 / 关」**,可一键比较;若「重聚焦」在你的机器上无效,切「仅触发」就是上一版行为(菜单能用、斜杠那一下要再点一次)。
|
|
152
|
+
- 顺带修掉一个斜杠流程的隐藏缺陷:**从菜单里选中命令现在能真正落进输入框了**(此前"字段有焦点就不接受外部写入"的守卫会把它挡掉,现已收敛为"只挡未提交文字")。
|
|
153
|
+
|
|
154
|
+
**v0.7.1**:工具行清爽化——日常只留 **「输入法✓/✗」** 一个按钮(逃生通道),**长按 600ms** 才把 **「诊断」** 按钮叫出来(再长按收起);诊断开着时「诊断」「复制日志」两个按钮自动出现,方便你取日志或关掉。
|
|
155
|
+
|
|
156
|
+
**逃生开关**:输入框工具行新增小按钮 **「输入法✓ / 输入法✗」**(仅窄屏显示)——一键在原生输入框与官方输入框之间切换并记住选择;任何设备上都不会被卡死。
|
|
157
|
+
|
|
158
|
+
**诊断(工具行「诊断」按钮,或 URL `?dsh-mobile-input=bench` / `,debug`)**:
|
|
159
|
+
|
|
160
|
+
- ⚠️ **带 token 的 URL 会被 shell 重写掉 query**(插件 apply 之前 query 已被清空),所以手机上请用工具行的「诊断」按钮——它把请求写进 localStorage,刷新即生效;不带 token 的浏览器地址栏访问也可直接用 URL 参数。
|
|
161
|
+
- **面板只占屏幕顶部**(变体默认收起,点「展开变体 A-E」打开),**下方真实输入框可直接打字**——先测真实输入框;要定位再展开变体,每格各输 3-4 个字:
|
|
162
|
+
- **A** 裸 textarea(普通流,零 JS 写入)——基线;
|
|
163
|
+
- **B** 零高容器内的 textarea(复刻 seat 结构,零 JS 写入)——测结构;
|
|
164
|
+
- **C** 同 B + 每次按键都写高度——复刻 v0.5.2 的逐键 autosize(对照组);
|
|
165
|
+
- **D** 同 B + 只记录、不写任何 DOM——v0.6 的行为;
|
|
166
|
+
- **E** iframe 隔离文档里的裸 textarea——测页面级因素。
|
|
167
|
+
- 判读:日志里哪一格后面跟着 `KEYBOARD ...px`,哪一格就是元凶(A/B/D 正常、C 收键盘 → 逐键写样式;连 A 都收 → 与插件无关)。
|
|
168
|
+
- 另有「生产:提交点增高 / 逐键增高 / 固定高度」三个切换,可在真机上直接 A/B 生产字段的增高策略(默认提交点增高)。
|
|
169
|
+
- **事件面板**(debug,与 bench 同时开启时只留 bench 的日志面板)记录 composer 的 tap 坐标/命中目标、focus 变化、input/composition、几何写入;两种面板都能一键复制日志。日志在**打字期间不刷新**(面板自己写 DOM 会污染取证),失焦、手动点「刷新」或键盘开合时才更新。
|
|
170
|
+
|
|
171
|
+
**真机自测记录**:见 [knowledge 条目](https://gitee.com/imroc/dsh-agent) 与 `test/probe-live.mjs`(CDP 驱动真实页面,断言"打字期间页面零 DOM 变更")。
|
|
172
|
+
|
|
173
|
+
## 已知限制
|
|
174
|
+
|
|
175
|
+
- 原生接管期间,输入区内联的 chip/装饰(如 `@` 引用装饰)不显示;进入命令 claim 阶段会自动切回官方编辑器。
|
|
176
|
+
- 移动端「粘贴图片」通过转发给官方附件入口实现,浏览器若不允许构造粘贴事件则退化为用回形针按钮选择文件。
|
|
177
|
+
- 输入法自身的候选词栏/联想行为由系统输入法决定,插件无法控制。
|
|
178
|
+
|
|
179
|
+
## 开发与自测
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
npm install --no-save jsdom react@18 react-dom@18
|
|
183
|
+
node test/takeover.test.mjs # jsdom:渲染 + 打字路径(含"打字期零 DOM 写入"断言)
|
|
184
|
+
node test/facts-probe.mjs <token> # 真实页面:DOM/CSS 结构与引擎能力
|
|
185
|
+
node test/probe-live.mjs <token> # 真实页面:接管契约 + 诊断开关(CDP,390x844 移动视口)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
token 取自 `journalctl --user -u dsh-web | grep -o 'token=[A-Za-z0-9_-]*' | tail -1`(每次重启换新;单次有效,探针只用一次)。
|
|
189
|
+
|
|
190
|
+
测试会加载真实的 `lib/client.js`,用 React 把 slot 组件渲染进一个 composer 卡片形状的 DOM,断言渲染路径、
|
|
191
|
+
草稿镜像(打字 / 输入法组合 / 机器侧写入)、回车手势、以及"claim 阶段把输入面交还官方编辑器"的开关。
|
|
192
|
+
存在的理由:slot 组件渲染出错时页面看不出异常,只有控制台报错。
|
|
57
193
|
|
|
58
194
|
## 回退
|
|
59
195
|
|
|
60
196
|
- Bundle 安装:`dsh plugin --profile web remove dsh-mobile-flow`
|
|
61
197
|
- 手动:移除依赖与 `dsh.profile.bundles` 条目,重启 `dsh web`
|
|
198
|
+
- 临时:`localStorage.setItem("dsh-mobile-flow:input", "off")` 后刷新页面(只关原生输入框,布局修复保留)
|
|
62
199
|
|
|
63
200
|
不修改产品源码;升级不会覆盖。
|
|
64
201
|
|