dsh-mobile-flow 0.7.2 → 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 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.
@@ -210,6 +212,13 @@ Fixes:
210
212
  making its composer editable again — the field's `readOnly` is cleared then, so a latched field can never outlive
211
213
  the state that caused it.
212
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
+
213
222
  **Escape hatch**: a small tool-row button (**输入法✓ / 输入法✗**, narrow viewports only) swaps back to the stock
214
223
  input box and remembers the choice — no device can be left stuck.
215
224
 
@@ -236,14 +245,20 @@ things, that **typing writes nothing to the page DOM**.
236
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.
237
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.
238
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.
239
249
 
240
250
  ## Development
241
251
 
242
252
  ```sh
243
253
  npm install --no-save jsdom react@18 react-dom@18
244
- node test/takeover.test.mjs # runs the real client bundle in a jsdom composer card
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
245
257
  ```
246
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
+
247
262
  The test boots the shipped `lib/client.js`, renders the slot entry through React against a
248
263
  composer-card DOM, and asserts the render path, the draft mirror (typing / IME composition /
249
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,13 @@ 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
+
156
165
  **v0.7.2(手机反馈:新建会话后点输入框没反应)**:新会话刚建出来、**workspace 还没解析出来**时,官方卡片其实是「选择工作区」的**选择器触发器**(`aria-haspopup="menu"`、`contenteditable="false"`、没有绑编辑器),根本不是输入框。旧版接管照样压上去,还把这份「不可编辑」**锁存**成原生 textarea 的 `readOnly` —— 于是:点输入框**不弹键盘、不出现光标**,同时把官方那记「打开选择器」的点击吞掉,用户既不能打字也打不开选择器;而且 workspace 解析好之后它**不会自愈**(只有换会话重新挂载才恢复),正好对上「第一次新建会话没反应、切走再新建就好」的现象(2026-09-19)。
157
166
 
158
167
  处理:
@@ -183,6 +192,7 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
183
192
  - 原生接管期间,输入区内联的 chip/装饰(如 `@` 引用装饰)不显示;进入命令 claim 阶段会自动切回官方编辑器。
184
193
  - 移动端「粘贴图片」通过转发给官方附件入口实现,浏览器若不允许构造粘贴事件则退化为用回形针按钮选择文件。
185
194
  - 输入法自身的候选词栏/联想行为由系统输入法决定,插件无法控制。
195
+ - **回合运行中 + 输入框里有未提交文字**时,那颗按钮是「发送」(排队/引导),不再提供「停止」——想打断这个回合就先清空输入框(清空后按钮立刻变回 Stop)。这与官方「草稿非空时主按钮是发送」的取向一致。
186
196
 
187
197
  ## 开发与自测
188
198
 
@@ -191,6 +201,7 @@ npm install --no-save jsdom react@18 react-dom@18
191
201
  node test/takeover.test.mjs # jsdom:渲染 + 打字路径(含"打字期零 DOM 写入"断言)
192
202
  node test/facts-probe.mjs <token> # 真实页面:DOM/CSS 结构与引擎能力
193
203
  node test/probe-live.mjs <token> # 真实页面:接管契约 + 诊断开关(CDP,390x844 移动视口)
204
+ node test/probe-send.mjs <token> # 真实页面:发送按钮的亮色状态 + 一次点击发送 + 运行中回合
194
205
  ```
195
206
 
196
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",
@@ -1072,7 +1113,12 @@ window.__ModuleLoader__.load({
1072
1113
  var editor = card === null ? null : card.querySelector("[data-composer-input]");
1073
1114
  if (editor === null) return;
1074
1115
  var placeholder = editor.getAttribute("data-placeholder");
1075
- if (placeholder !== null && area.placeholder !== placeholder) area.placeholder = placeholder;
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;
1076
1122
  /* The product's own gate (locked / inert / takeover states) rides this
1077
1123
  attribute; mirror it instead of re-deriving the policy. The takeover
1078
1124
  only ever writes "false" AND marks those writes, so an unmarked value
@@ -1560,6 +1606,18 @@ window.__ModuleLoader__.load({
1560
1606
  }
1561
1607
  return null;
1562
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
+ };
1563
1621
  var onPointerDown = function (event) {
1564
1622
  var area = areaRef.current;
1565
1623
  var wrap = wrapRef.current;
@@ -1579,17 +1637,24 @@ window.__ModuleLoader__.load({
1579
1637
  }
1580
1638
  return;
1581
1639
  }
1582
- /* Outside the field: a toolbar action whose gate may depend on the
1583
- draft (the send button is disabled while the machine draft is
1584
- empty). Commit first, then replay the tap on a button our commit
1585
- just enabled. */
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. */
1586
1650
  var button = buttonAt(x, y);
1587
- var wasDisabled = button !== null && button.disabled === true;
1588
- mirrorNow();
1589
- if (button !== null && wasDisabled && button.disabled === false) {
1651
+ if (submitAt(button) === true && pendingRef.current !== null) {
1590
1652
  event.preventDefault();
1591
- window.setTimeout(function () { button.click(); }, 0);
1653
+ mirrorNow();
1654
+ debug("carry tap -> " + (sendGesture() ? "submit" : "nothing"));
1655
+ return;
1592
1656
  }
1657
+ mirrorNow();
1593
1658
  };
1594
1659
  /* Engines without a working inert: if anything hands focus back to the
1595
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.2",
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",