dsh-mobile-flow 0.7.1 → 0.7.3
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 +36 -1
- package/README.zh.md +19 -0
- package/lib/client.js +186 -20
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ On viewports ≤720px (the same breakpoint the official question card uses):
|
|
|
30
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.
|
|
31
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
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.
|
|
33
34
|
|
|
34
35
|
Desktop (wide viewports) is completely unaffected.
|
|
35
36
|
|
|
@@ -99,6 +100,7 @@ Open a session on a phone (or a desktop DevTools window narrowed to ≤720px):
|
|
|
99
100
|
- Swipe up a few screens: the input bar and confirmation cards scroll away with the messages.
|
|
100
101
|
- 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
102
|
- Send with the send button or Enter (same gesture as the stock composer).
|
|
103
|
+
- 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.
|
|
102
104
|
- The tool row shows **two chips**: 「输入法✓」(the escape hatch) and 「诊断」(open the test bench).
|
|
103
105
|
- The v0.6.0 regression to watch: **no layout movement while typing** — the field's height stays put until focus
|
|
104
106
|
leaves, and only then follows the content.
|
|
@@ -190,6 +192,33 @@ mount — completes with nothing focused, then `focus()` to bring the IME back,
|
|
|
190
192
|
**long-press it for 600ms** to call out the **诊断** (diagnostics) button, long-press again to put it away. While
|
|
191
193
|
diagnostics are on, both 诊断 and 复制日志 (copy log) show up by themselves.
|
|
192
194
|
|
|
195
|
+
**v0.7.2 (phone report: tapping the input box after "new session" did nothing)**: right after a new session is
|
|
196
|
+
created its **workspace may not be resolved yet** — in that window the product's card is not an input at all but the
|
|
197
|
+
workspace **picker trigger** (`aria-haspopup="menu"`, `contenteditable="false"`, no bound editor). The takeover used
|
|
198
|
+
to claim it anyway and **latched** that non-editability into the native textarea's `readOnly`: tapping the box raised
|
|
199
|
+
no keyboard and no caret, the card's own "open the picker" tap was swallowed, so the user could neither type nor pick
|
|
200
|
+
a workspace — and it did **not** recover once the workspace resolved (only a session switch, i.e. a remount, did).
|
|
201
|
+
That is exactly the reported "first new session dead, switch away and create another and it works" pattern.
|
|
202
|
+
|
|
203
|
+
Fixes:
|
|
204
|
+
|
|
205
|
+
- The takeover now **asks the product whether the composer is a real text input** before owning it, using two
|
|
206
|
+
product-owned attributes it never writes itself: `aria-haspopup="menu"` (picker trigger) and `aria-disabled="true"`
|
|
207
|
+
(blocked composer / removed session / offline parent). When it is not an input, the takeover stays completely out of
|
|
208
|
+
the way: no card marker, no seat, no swallowed tap — the product's own picker tap works.
|
|
209
|
+
- That question is answered **live** (`MutationObserver` on those two attributes): the moment the workspace resolves
|
|
210
|
+
and the composer becomes a real input, the takeover attaches — no latch.
|
|
211
|
+
- Belt and braces: the takeover only ever writes `contenteditable="false"`, so any observed `"true"` is the product
|
|
212
|
+
making its composer editable again — the field's `readOnly` is cleared then, so a latched field can never outlive
|
|
213
|
+
the state that caused it.
|
|
214
|
+
|
|
215
|
+
**v0.7.3 (phone report: with the native input on, typed text left the bottom-right send button grey — it looked like nothing could be sent)**: the button is derived from the MACHINE draft, and the takeover keeps that draft deliberately stale while the user types (the ArkWeb lesson: commit points only). So the product kept believing the composer was empty and left its send button disabled at 40 % opacity. The old bridge (replay the tap on the button the commit enabled) depended on the engine re-testing that control after the pointer had already landed, which differs per engine — and under a running turn the same button wore the Stop square, so a tap could interrupt instead of send.
|
|
216
|
+
|
|
217
|
+
Two fixes:
|
|
218
|
+
|
|
219
|
+
- **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
|
+
- **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
|
+
|
|
193
222
|
**Escape hatch**: a small tool-row button (**输入法✓ / 输入法✗**, narrow viewports only) swaps back to the stock
|
|
194
223
|
input box and remembers the choice — no device can be left stuck.
|
|
195
224
|
|
|
@@ -216,14 +245,20 @@ things, that **typing writes nothing to the page DOM**.
|
|
|
216
245
|
- 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
246
|
- 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
247
|
- IME candidate/assist behavior itself belongs to the system keyboard and is outside the plugin's control.
|
|
248
|
+
- **While a turn streams and the field holds uncommitted text**, that button means Send (queue/steer) and no Stop affordance is offered — clear the field to interrupt. This matches the product's own choice of Send as the primary action whenever a draft exists.
|
|
219
249
|
|
|
220
250
|
## Development
|
|
221
251
|
|
|
222
252
|
```sh
|
|
223
253
|
npm install --no-save jsdom react@18 react-dom@18
|
|
224
|
-
node test/takeover.test.mjs
|
|
254
|
+
node test/takeover.test.mjs # runs the real client bundle in a jsdom composer card
|
|
255
|
+
node test/probe-live.mjs <token> # live app: takeover contract + diagnostics switches (CDP, 390x844)
|
|
256
|
+
node test/probe-send.mjs <token> # live app: the send button's look, one-tap send, the running turn
|
|
225
257
|
```
|
|
226
258
|
|
|
259
|
+
The launch token comes from `journalctl --user -u dsh-web | grep -o 'token=[A-Za-z0-9_-]*' | tail -1`
|
|
260
|
+
(a new one per restart; the probes spend it on their first navigation).
|
|
261
|
+
|
|
227
262
|
The test boots the shipped `lib/client.js`, renders the slot entry through React against a
|
|
228
263
|
composer-card DOM, and asserts the render path, the draft mirror (typing / IME composition /
|
|
229
264
|
machine-side writes), the Enter gesture, and the phase gate that hands the surface back to the
|
package/README.zh.md
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
5. **切会话不自动聚焦** —— 官方在每次挂载/切换会话时把焦点还给输入框(桌面便利设计),手机上会弹出输入法占掉半屏。窄屏时拦截这类程序化聚焦;你主动点输入框时照常聚焦。
|
|
31
31
|
6. **workspace 行操作按钮常显** —— 分组行与会话行尾部的按钮(⋯ 菜单含重命名/删除、+ 在该 workspace 新建会话)官方仅在悬停时显示;触屏没有悬停,窄屏下侧边栏列表渲染时常显这两个按钮。
|
|
32
32
|
7. **原生输入框接管** —— 输入区绘制成原生 textarea,外观、内边距、字号行高与官方完全一致(几何按官方输入区实时测量对齐)。回车仍是官方发送手势(含 `/` 菜单仲裁、busy-Enter 策略),粘贴图片转发给官方附件入口,锁定态跟随官方可编辑性开关。
|
|
33
|
+
8. **发送按钮跟「输入框里有什么」走** —— 只要输入框里有字,右下角发送按钮就是**可点的亮色**(不再是灰的),点一下即发送(运行中的回合则是排队/引导);打字期间这个状态由纯 CSS 驱动,**零 DOM 写入**,不破坏「打字期零 DOM 变动」这条硬约束。
|
|
33
34
|
|
|
34
35
|
桌面端(宽视口)完全不受影响。
|
|
35
36
|
|
|
@@ -99,6 +100,7 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
|
99
100
|
- 往上滑几屏——输入框和确认卡片应随消息滚出屏幕。
|
|
100
101
|
- 输入区应是原生 textarea(页面里存在 `[data-mobile-input]`)。用输入法打字/语音输入,文字不应被自动清除。
|
|
101
102
|
- 发送:点发按钮或按回车(回车等同官方发送手势)。
|
|
103
|
+
- 发送按钮状态:**输入框里有字时按钮就是亮色可点的**(灰→亮由字段内容驱动);点一下即发送,一次点击一条消息。
|
|
102
104
|
- 工具行应有**两个小按钮**:「输入法✓」(逃生通道)与「诊断」(开测试台)。
|
|
103
105
|
- 关键回归点(v0.6.0):**打字期间不要有任何布局跳动**——字段高度在聚焦期间不变,失焦后才按内容长高。
|
|
104
106
|
- 拉宽窗口即恢复官方行为。
|
|
@@ -153,6 +155,21 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
|
153
155
|
|
|
154
156
|
**v0.7.1**:工具行清爽化——日常只留 **「输入法✓/✗」** 一个按钮(逃生通道),**长按 600ms** 才把 **「诊断」** 按钮叫出来(再长按收起);诊断开着时「诊断」「复制日志」两个按钮自动出现,方便你取日志或关掉。
|
|
155
157
|
|
|
158
|
+
**v0.7.3(手机反馈:勾选「输入法」后输入文本,右下角发送按钮是灰的,像没法发)**:根因是**按钮读的是机器草稿,而接管期间机器草稿故意是旧的**——打字时插件只在提交点才回写草稿(ArkWeb 收键盘的教训),于是官方发送按钮一直以为输入框是空的,保持禁用(40% 透明度的灰)。旧代码虽然写了「提交后补发点击」的桥接,但它依赖引擎在指针落下之后再复查一次这个刚被"点亮"的按钮,不同引擎结果不一样,且运行中的回合还会把 Stop 方块画在这颗按钮上(点下去可能变成打断)。
|
|
159
|
+
|
|
160
|
+
改法两条:
|
|
161
|
+
|
|
162
|
+
- **外观**:机器草稿是旧的,但**字段自己知道有没有字**,而 `:placeholder-shown` 是纯 CSS 里唯一能读字段值的探针。于是接管卡片在「字段有字 + 机器草稿仍为空」时把发送按钮画成启用态(opacity 1、手型光标、按下有反馈)——**零 DOM 写入**,硬约束不破;机器草稿已经同步(如已提交)时不动它,保持官方原样。
|
|
163
|
+
- **手势**:卡片捕获阶段的 `pointerdown` 判断「点的是不是提交按钮(trailing 行里最后一颗 primary,可中断子会话额外的 Stop 圆钮在它前面)」且「还有未提交的文字」,是则**拿走这次手势**:先落草稿,再走**官方自己的发送手势**(与回车同一条路:斜杠仲裁、busy 策略、排队/引导),一次点击一条消息,不再看引擎脸色。运行中的回合里,字段有字时按钮**换成发送箭头**(隐藏官方的 Stop 方块,用官方同一份箭头 path 画 mask),点它就是发送——脸和手势一致。
|
|
164
|
+
|
|
165
|
+
**v0.7.2(手机反馈:新建会话后点输入框没反应)**:新会话刚建出来、**workspace 还没解析出来**时,官方卡片其实是「选择工作区」的**选择器触发器**(`aria-haspopup="menu"`、`contenteditable="false"`、没有绑编辑器),根本不是输入框。旧版接管照样压上去,还把这份「不可编辑」**锁存**成原生 textarea 的 `readOnly` —— 于是:点输入框**不弹键盘、不出现光标**,同时把官方那记「打开选择器」的点击吞掉,用户既不能打字也打不开选择器;而且 workspace 解析好之后它**不会自愈**(只有换会话重新挂载才恢复),正好对上「第一次新建会话没反应、切走再新建就好」的现象(2026-09-19)。
|
|
166
|
+
|
|
167
|
+
处理:
|
|
168
|
+
|
|
169
|
+
- 接管前先问产品一句「**你现在真是输入框吗**」——只看产品自己写、插件从不碰的两个属性:`aria-haspopup="menu"`(workspace 选择器触发器)与 `aria-disabled="true"`(被 blocked / removed / 离线父会话锁住)。不是输入框就**完全不接管**:不标记卡片、不渲染 seat、不吞点击,官方那记点击照常打开选择器。
|
|
170
|
+
- 这份判断**跟着产品实时更新**(`MutationObserver` 盯着这两个属性),workspace 一解析好、产品把 composer 变成真输入框,接管立刻接上——不再有「锁存」。
|
|
171
|
+
- 兜底自愈:插件只会把官方编辑器写成 `contenteditable="false"`,所以只要观测到 `"true"` 就一定是产品自己恢复可编辑 —— 此时同步把字段的 `readOnly` 解开(历史上被锁存的字段不再永久失灵)。
|
|
172
|
+
|
|
156
173
|
**逃生开关**:输入框工具行新增小按钮 **「输入法✓ / 输入法✗」**(仅窄屏显示)——一键在原生输入框与官方输入框之间切换并记住选择;任何设备上都不会被卡死。
|
|
157
174
|
|
|
158
175
|
**诊断(工具行「诊断」按钮,或 URL `?dsh-mobile-input=bench` / `,debug`)**:
|
|
@@ -175,6 +192,7 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
|
|
|
175
192
|
- 原生接管期间,输入区内联的 chip/装饰(如 `@` 引用装饰)不显示;进入命令 claim 阶段会自动切回官方编辑器。
|
|
176
193
|
- 移动端「粘贴图片」通过转发给官方附件入口实现,浏览器若不允许构造粘贴事件则退化为用回形针按钮选择文件。
|
|
177
194
|
- 输入法自身的候选词栏/联想行为由系统输入法决定,插件无法控制。
|
|
195
|
+
- **回合运行中 + 输入框里有未提交文字**时,那颗按钮是「发送」(排队/引导),不再提供「停止」——想打断这个回合就先清空输入框(清空后按钮立刻变回 Stop)。这与官方「草稿非空时主按钮是发送」的取向一致。
|
|
178
196
|
|
|
179
197
|
## 开发与自测
|
|
180
198
|
|
|
@@ -183,6 +201,7 @@ npm install --no-save jsdom react@18 react-dom@18
|
|
|
183
201
|
node test/takeover.test.mjs # jsdom:渲染 + 打字路径(含"打字期零 DOM 写入"断言)
|
|
184
202
|
node test/facts-probe.mjs <token> # 真实页面:DOM/CSS 结构与引擎能力
|
|
185
203
|
node test/probe-live.mjs <token> # 真实页面:接管契约 + 诊断开关(CDP,390x844 移动视口)
|
|
204
|
+
node test/probe-send.mjs <token> # 真实页面:发送按钮的亮色状态 + 一次点击发送 + 运行中回合
|
|
186
205
|
```
|
|
187
206
|
|
|
188
207
|
token 取自 `journalctl --user -u dsh-web | grep -o 'token=[A-Za-z0-9_-]*' | tail -1`(每次重启换新;单次有效,探针只用一次)。
|
package/lib/client.js
CHANGED
|
@@ -156,6 +156,47 @@ window.__ModuleLoader__.load({
|
|
|
156
156
|
" color: var(--dsw-alias-label-tertiary);",
|
|
157
157
|
" cursor: not-allowed;",
|
|
158
158
|
"}",
|
|
159
|
+
"/* Send-button honesty (phone report 2026-09-21: text typed, the send",
|
|
160
|
+
" button stays grey — it reads as \"cannot send\"). The product derives",
|
|
161
|
+
" that button from the MACHINE draft, and the takeover keeps the machine",
|
|
162
|
+
" draft deliberately stale while the user types (the ArkWeb fix), so a",
|
|
163
|
+
" filled field sits next to a disabled, 40%-opacity button. The field's",
|
|
164
|
+
" own value state is the truth the eye needs, and :placeholder-shown is",
|
|
165
|
+
" the one PURE-CSS value probe — no DOM write, which is the hard rule",
|
|
166
|
+
" while a field has focus. The gesture is carried by the commit bridge in",
|
|
167
|
+
" the card's capture pointerdown, so the look and the tap agree: one tap,",
|
|
168
|
+
" one send. The submit button is the trailing row's LAST primary control;",
|
|
169
|
+
" an interruptible child's extra Stop circle renders before it. */",
|
|
170
|
+
"[data-composer-card][data-mobile-input-active]:has([data-mobile-input]:not(:placeholder-shown)) [class*='_trailing'] > [class*='_primary']:last-child:disabled {",
|
|
171
|
+
" opacity: 1;",
|
|
172
|
+
" cursor: pointer;",
|
|
173
|
+
"}",
|
|
174
|
+
"/* Pressed feedback for that same button: the product's own :active rules",
|
|
175
|
+
" only cover the enabled state, and a tap with no visible answer reads as",
|
|
176
|
+
" a dead control. */",
|
|
177
|
+
"[data-composer-card][data-mobile-input-active]:has([data-mobile-input]:not(:placeholder-shown)) [class*='_trailing'] > [class*='_primary']:last-child:disabled:active {",
|
|
178
|
+
" opacity: 0.72;",
|
|
179
|
+
"}",
|
|
180
|
+
"/* The running-turn case: while a turn streams, the product puts its Stop",
|
|
181
|
+
" square on that button (its stale-empty draft tells it the composer holds",
|
|
182
|
+
" nothing, so interrupting is the only action left). With text in the",
|
|
183
|
+
" field the tap SENDS — the bridge turns it into the product's own submit",
|
|
184
|
+
" gesture (queue / steer under a running turn) — so the face has to say",
|
|
185
|
+
" send. Gated on the machine draft still being EMPTY (the stock editor is",
|
|
186
|
+
" never empty in that state), so the arrow only ever replaces the Stop",
|
|
187
|
+
" square and never the product's own send arrow. The glyph is the",
|
|
188
|
+
" product's own arrow: 16px, currentColor on the blue fill. */",
|
|
189
|
+
"[data-composer-card][data-mobile-input-active]:has([data-composer-input]:empty):has([data-mobile-input]:not(:placeholder-shown)) [class*='_trailing'] > [class*='_primary']:last-child:not(:disabled) > svg {",
|
|
190
|
+
" display: none;",
|
|
191
|
+
"}",
|
|
192
|
+
"[data-composer-card][data-mobile-input-active]:has([data-composer-input]:empty):has([data-mobile-input]:not(:placeholder-shown)) [class*='_trailing'] > [class*='_primary']:last-child:not(:disabled)::after {",
|
|
193
|
+
" content: '';",
|
|
194
|
+
" width: 16px;",
|
|
195
|
+
" height: 16px;",
|
|
196
|
+
" background: currentColor;",
|
|
197
|
+
" -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
|
+
" 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
|
+
"}",
|
|
159
200
|
"/* Escape hatch in the composer tool row (narrow viewports only): one tap",
|
|
160
201
|
" returns the stock input box when the native one misbehaves on a device",
|
|
161
202
|
" we cannot test on. Its neighbour switches the diagnostics surfaces on",
|
|
@@ -723,6 +764,40 @@ window.__ModuleLoader__.load({
|
|
|
723
764
|
}
|
|
724
765
|
}
|
|
725
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Whether the product's composer on this card is a LIVE TEXT INPUT.
|
|
769
|
+
*
|
|
770
|
+
* The resident composer card is reused for states that are not inputs at
|
|
771
|
+
* all: a blank-session hero whose workspace is not resolved yet renders the
|
|
772
|
+
* same surface as a workspace-PICKER trigger (tapping it opens the picker,
|
|
773
|
+
* `editor={null}`, `contenteditable="false"`), and a blocked/removed/offline
|
|
774
|
+
* session renders it disabled. The takeover must not own those surfaces: a
|
|
775
|
+
* read-only field that swallows the card's own tap is worse than no
|
|
776
|
+
* takeover — the user cannot type AND cannot open the picker.
|
|
777
|
+
*
|
|
778
|
+
* Two product-owned attributes carry that fact and the takeover never
|
|
779
|
+
* writes either (see the "no workspace yet" note on the editability refs):
|
|
780
|
+
* - `aria-haspopup="menu"` — the workspace-picker trigger state
|
|
781
|
+
* - `aria-disabled="true"` — the locked states (blocked composer, removed
|
|
782
|
+
* session, offline parent)
|
|
783
|
+
* `contenteditable` is deliberately NOT consulted here: the takeover forces
|
|
784
|
+
* it to "false" while it owns the surface, so reading it once at mount and
|
|
785
|
+
* latching the answer is what left a phone with a permanently read-only
|
|
786
|
+
* field (v0.7.2 phone report: "new session, tapping the input box does
|
|
787
|
+
* nothing").
|
|
788
|
+
*
|
|
789
|
+
* @param card - the resident composer card, or null.
|
|
790
|
+
* @returns true when the card currently hosts a real text input.
|
|
791
|
+
*/
|
|
792
|
+
function composerIsTextInput(card) {
|
|
793
|
+
if (card === null || typeof card.querySelector !== "function") return false;
|
|
794
|
+
var editor = card.querySelector("[data-composer-input]");
|
|
795
|
+
if (editor === null) return false;
|
|
796
|
+
if (editor.getAttribute("aria-haspopup") === "menu") return false;
|
|
797
|
+
if (editor.getAttribute("aria-disabled") === "true") return false;
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
726
801
|
/** Whether the takeover should own the surface on this viewport. */
|
|
727
802
|
function takeoverLive(media) {
|
|
728
803
|
var override = readOverride();
|
|
@@ -832,9 +907,16 @@ window.__ModuleLoader__.load({
|
|
|
832
907
|
var sessionId = props.sessionId;
|
|
833
908
|
var actions = props.inputActions;
|
|
834
909
|
var phase = input === undefined ? "plain" : input.phase;
|
|
910
|
+
/* Whether the product's composer is a real text input right now. Tracked
|
|
911
|
+
live (see the mount effect below), never latched: a new session is
|
|
912
|
+
routinely created before its workspace resolves, and that hero composer
|
|
913
|
+
is a workspace-picker trigger, not an input. */
|
|
914
|
+
var readyPair = React.useState(false);
|
|
915
|
+
var composerReady = readyPair[0];
|
|
916
|
+
var setComposerReady = readyPair[1];
|
|
835
917
|
/* Ownership is surface-level only: claims fall back to the stock editor
|
|
836
918
|
(see the section comment). */
|
|
837
|
-
var active = live && phase === "plain" && sessionId !== undefined;
|
|
919
|
+
var active = live && composerReady && phase === "plain" && sessionId !== undefined;
|
|
838
920
|
|
|
839
921
|
var wrapRef = React.useRef(null);
|
|
840
922
|
var areaRef = React.useRef(null);
|
|
@@ -855,9 +937,17 @@ window.__ModuleLoader__.load({
|
|
|
855
937
|
/* Whether the takeover currently owns the surface (read by handlers that
|
|
856
938
|
outlive a render). */
|
|
857
939
|
var activeRef = React.useRef(false);
|
|
858
|
-
/* The
|
|
859
|
-
|
|
860
|
-
|
|
940
|
+
/* The product's editability intent for the composer, kept across takeover
|
|
941
|
+
releases. The field mirrors THIS, never our own override: the takeover
|
|
942
|
+
forces contenteditable="false" while it owns the surface, so the
|
|
943
|
+
attribute cannot be read back as the product's answer. `forcedRef` marks
|
|
944
|
+
a "false" that is ours. (A latched read-only field used to survive the
|
|
945
|
+
composer becoming editable again — one phone report: a new session whose
|
|
946
|
+
workspace was not resolved yet left the input dead until the next
|
|
947
|
+
session switch.) */
|
|
948
|
+
var appEditableRef = React.useRef(true);
|
|
949
|
+
/* True while the "false" standing on the product's editor is OUR force. */
|
|
950
|
+
var forcedRef = React.useRef(false);
|
|
861
951
|
/* Last geometry applied: style writes are skipped when nothing moved, so
|
|
862
952
|
a strict engine's IME is not disturbed by pointless relayouts. */
|
|
863
953
|
var geometryRef = React.useRef("");
|
|
@@ -875,6 +965,34 @@ window.__ModuleLoader__.load({
|
|
|
875
965
|
return wrap === null ? null : wrap.closest("[data-composer-card]");
|
|
876
966
|
};
|
|
877
967
|
|
|
968
|
+
/* Is the product's composer a text input right now? Re-answered on every
|
|
969
|
+
product-side change of the two attributes that decide it, whether the
|
|
970
|
+
takeover is active or not — that is what lets the surface be handed
|
|
971
|
+
back when a new session opens without a resolved workspace, and taken
|
|
972
|
+
over again the moment the composer becomes an input. */
|
|
973
|
+
React.useEffect(function () {
|
|
974
|
+
var card = cardOf();
|
|
975
|
+
var update = function () {
|
|
976
|
+
var next = composerIsTextInput(cardOf());
|
|
977
|
+
setComposerReady(function (previous) {
|
|
978
|
+
if (previous !== next) {
|
|
979
|
+
debug("composer " + (next ? "is a text input -> takeover may own it"
|
|
980
|
+
: "is NOT a text input (picker/locked) -> stock surface stays"));
|
|
981
|
+
}
|
|
982
|
+
return next;
|
|
983
|
+
});
|
|
984
|
+
};
|
|
985
|
+
update();
|
|
986
|
+
if (card === null || typeof window.MutationObserver !== "function") return undefined;
|
|
987
|
+
var observer = new window.MutationObserver(update);
|
|
988
|
+
observer.observe(card, {
|
|
989
|
+
attributes: true,
|
|
990
|
+
attributeFilter: ["aria-haspopup", "aria-disabled"],
|
|
991
|
+
subtree: true,
|
|
992
|
+
});
|
|
993
|
+
return function () { observer.disconnect(); };
|
|
994
|
+
}, []);
|
|
995
|
+
|
|
878
996
|
/* The one invariant this whole file is built around (see the section
|
|
879
997
|
comment): while the field has focus the plugin writes NOTHING to the
|
|
880
998
|
DOM. Every measurement, resize and chrome sync below starts with this
|
|
@@ -995,17 +1113,32 @@ window.__ModuleLoader__.load({
|
|
|
995
1113
|
var editor = card === null ? null : card.querySelector("[data-composer-input]");
|
|
996
1114
|
if (editor === null) return;
|
|
997
1115
|
var placeholder = editor.getAttribute("data-placeholder");
|
|
998
|
-
|
|
1116
|
+
/* A surface that renders no placeholder still gets an invisible one:
|
|
1117
|
+
the CSS that keeps the send button honest reads the field's value
|
|
1118
|
+
state through :placeholder-shown, which needs the attribute to be
|
|
1119
|
+
there at all. */
|
|
1120
|
+
if (placeholder === null || placeholder === "") placeholder = " ";
|
|
1121
|
+
if (area.placeholder !== placeholder) area.placeholder = placeholder;
|
|
999
1122
|
/* The product's own gate (locked / inert / takeover states) rides this
|
|
1000
|
-
attribute; mirror it instead of re-deriving the policy.
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1123
|
+
attribute; mirror it instead of re-deriving the policy. The takeover
|
|
1124
|
+
only ever writes "false" AND marks those writes, so an unmarked value
|
|
1125
|
+
is the product speaking: "true" means it made its composer editable
|
|
1126
|
+
again, which must clear any latched read-only state (see
|
|
1127
|
+
`composerIsTextInput`). */
|
|
1128
|
+
if (!forcedRef.current && editor.getAttribute("contenteditable") === "false") {
|
|
1129
|
+
appEditableRef.current = false;
|
|
1130
|
+
} else if (editor.getAttribute("contenteditable") !== "false") {
|
|
1131
|
+
if (appEditableRef.current === false) debug("stock editor editable again");
|
|
1132
|
+
appEditableRef.current = true;
|
|
1133
|
+
forcedRef.current = false;
|
|
1134
|
+
}
|
|
1135
|
+
var editable = appEditableRef.current;
|
|
1004
1136
|
/* Keep the stock editor out of the browser's editable set for as long
|
|
1005
1137
|
as the takeover owns the surface (React re-applies its own value only
|
|
1006
1138
|
when the prop changes, so ours sticks until then). */
|
|
1007
1139
|
if (activeRef.current && editor.getAttribute("contenteditable") !== "false") {
|
|
1008
1140
|
editor.setAttribute("contenteditable", "false");
|
|
1141
|
+
forcedRef.current = true;
|
|
1009
1142
|
debug("re-forced contenteditable=false");
|
|
1010
1143
|
}
|
|
1011
1144
|
if (area.readOnly === editable) area.readOnly = !editable;
|
|
@@ -1043,11 +1176,16 @@ window.__ModuleLoader__.load({
|
|
|
1043
1176
|
var hadInert = editor.hasAttribute("inert");
|
|
1044
1177
|
var hadAria = editor.getAttribute("aria-hidden");
|
|
1045
1178
|
var hadEditable = editor.getAttribute("contenteditable");
|
|
1046
|
-
|
|
1179
|
+
/* Trust the product's value unless the "false" standing there is
|
|
1180
|
+
the one we wrote ourselves. */
|
|
1181
|
+
if (!forcedRef.current || hadEditable !== "false") {
|
|
1182
|
+
appEditableRef.current = hadEditable !== "false";
|
|
1183
|
+
}
|
|
1047
1184
|
if (typeof editor.inert === "boolean") editor.inert = true;
|
|
1048
1185
|
else editor.setAttribute("inert", "");
|
|
1049
1186
|
editor.setAttribute("aria-hidden", "true");
|
|
1050
1187
|
editor.setAttribute("contenteditable", "false");
|
|
1188
|
+
forcedRef.current = true;
|
|
1051
1189
|
restore = function () {
|
|
1052
1190
|
if (!hadInert) {
|
|
1053
1191
|
if (typeof editor.inert === "boolean") editor.inert = false;
|
|
@@ -1055,8 +1193,17 @@ window.__ModuleLoader__.load({
|
|
|
1055
1193
|
}
|
|
1056
1194
|
if (hadAria === null) editor.removeAttribute("aria-hidden");
|
|
1057
1195
|
else editor.setAttribute("aria-hidden", hadAria);
|
|
1058
|
-
|
|
1059
|
-
|
|
1196
|
+
/* Hand editability back to the product — but only while it still
|
|
1197
|
+
wants an input. When the takeover is released because the
|
|
1198
|
+
product locked the composer (picker trigger / blocked session),
|
|
1199
|
+
our own "false" IS the product's answer and must stand: writing
|
|
1200
|
+
the pre-takeover "true" back would re-enable a surface the
|
|
1201
|
+
product has just disabled (and hand the IME a hidden editable
|
|
1202
|
+
again — the exact bug the inert/aria-hidden pair exists for). */
|
|
1203
|
+
if (composerIsTextInput(card)) {
|
|
1204
|
+
editor.setAttribute("contenteditable", appEditableRef.current ? "true" : "false");
|
|
1205
|
+
forcedRef.current = false;
|
|
1206
|
+
}
|
|
1060
1207
|
};
|
|
1061
1208
|
}
|
|
1062
1209
|
} else {
|
|
@@ -1459,6 +1606,18 @@ window.__ModuleLoader__.load({
|
|
|
1459
1606
|
}
|
|
1460
1607
|
return null;
|
|
1461
1608
|
};
|
|
1609
|
+
/** The composer's submit control: the trailing row's LAST primary
|
|
1610
|
+
button (an interruptible child's extra Stop circle renders before
|
|
1611
|
+
it). It is the one control whose gate AND face read the machine
|
|
1612
|
+
draft — exactly the state the takeover keeps stale while typing. */
|
|
1613
|
+
var submitAt = function (button) {
|
|
1614
|
+
if (button === null || typeof button.className !== "string"
|
|
1615
|
+
|| button.className.indexOf("_primary") < 0) return false;
|
|
1616
|
+
var trailing = typeof button.closest === "function" ? button.closest("[class*='_trailing']") : null;
|
|
1617
|
+
if (trailing === null) return false;
|
|
1618
|
+
var primaries = trailing.querySelectorAll("[class*='_primary']");
|
|
1619
|
+
return primaries.length !== 0 && primaries[primaries.length - 1] === button;
|
|
1620
|
+
};
|
|
1462
1621
|
var onPointerDown = function (event) {
|
|
1463
1622
|
var area = areaRef.current;
|
|
1464
1623
|
var wrap = wrapRef.current;
|
|
@@ -1478,17 +1637,24 @@ window.__ModuleLoader__.load({
|
|
|
1478
1637
|
}
|
|
1479
1638
|
return;
|
|
1480
1639
|
}
|
|
1481
|
-
/* Outside the field: a toolbar action
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1640
|
+
/* Outside the field: a toolbar action. Everywhere else the commit is
|
|
1641
|
+
bookkeeping and the gesture runs untouched. The submit button is
|
|
1642
|
+
the exception: its gate AND its face read the machine draft, so
|
|
1643
|
+
while the user types they describe a composer the product has not
|
|
1644
|
+
heard about yet — a grey send button that a tap may never reach,
|
|
1645
|
+
or the running turn's Stop square on a button whose tap means
|
|
1646
|
+
send. Commit first (the submit reads the machine draft), then run
|
|
1647
|
+
the product's own send gesture — the same path Enter takes (slash
|
|
1648
|
+
adjudication, busy policy, steer/queue) — so one tap is one send,
|
|
1649
|
+
whatever the engine would have made of the stale control. */
|
|
1485
1650
|
var button = buttonAt(x, y);
|
|
1486
|
-
|
|
1487
|
-
mirrorNow();
|
|
1488
|
-
if (button !== null && wasDisabled && button.disabled === false) {
|
|
1651
|
+
if (submitAt(button) === true && pendingRef.current !== null) {
|
|
1489
1652
|
event.preventDefault();
|
|
1490
|
-
|
|
1653
|
+
mirrorNow();
|
|
1654
|
+
debug("carry tap -> " + (sendGesture() ? "submit" : "nothing"));
|
|
1655
|
+
return;
|
|
1491
1656
|
}
|
|
1657
|
+
mirrorNow();
|
|
1492
1658
|
};
|
|
1493
1659
|
/* Engines without a working inert: if anything hands focus back to the
|
|
1494
1660
|
stock editor, take it straight back to the field. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-flow",
|
|
3
|
-
"version": "0.7.
|
|
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, session switches no longer auto-focus the input, workspace row actions stay visible, and the draft surface becomes a native textarea (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.",
|
|
3
|
+
"version": "0.7.3",
|
|
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, session switches no longer auto-focus the input, workspace row actions stay visible, and the draft surface becomes a native textarea (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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"css"
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
|
-
"test": "node test/takeover.test.mjs"
|
|
43
|
+
"test": "node test/takeover.test.mjs && node test/input-gate.test.mjs"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"jsdom": "^27.0.0",
|