dsh-mobile-flow 0.7.1 → 0.7.2

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
@@ -190,6 +190,26 @@ mount — completes with nothing focused, then `focus()` to bring the IME back,
190
190
  **long-press it for 600ms** to call out the **诊断** (diagnostics) button, long-press again to put it away. While
191
191
  diagnostics are on, both 诊断 and 复制日志 (copy log) show up by themselves.
192
192
 
193
+ **v0.7.2 (phone report: tapping the input box after "new session" did nothing)**: right after a new session is
194
+ created its **workspace may not be resolved yet** — in that window the product's card is not an input at all but the
195
+ workspace **picker trigger** (`aria-haspopup="menu"`, `contenteditable="false"`, no bound editor). The takeover used
196
+ to claim it anyway and **latched** that non-editability into the native textarea's `readOnly`: tapping the box raised
197
+ no keyboard and no caret, the card's own "open the picker" tap was swallowed, so the user could neither type nor pick
198
+ a workspace — and it did **not** recover once the workspace resolved (only a session switch, i.e. a remount, did).
199
+ That is exactly the reported "first new session dead, switch away and create another and it works" pattern.
200
+
201
+ Fixes:
202
+
203
+ - The takeover now **asks the product whether the composer is a real text input** before owning it, using two
204
+ product-owned attributes it never writes itself: `aria-haspopup="menu"` (picker trigger) and `aria-disabled="true"`
205
+ (blocked composer / removed session / offline parent). When it is not an input, the takeover stays completely out of
206
+ the way: no card marker, no seat, no swallowed tap — the product's own picker tap works.
207
+ - That question is answered **live** (`MutationObserver` on those two attributes): the moment the workspace resolves
208
+ and the composer becomes a real input, the takeover attaches — no latch.
209
+ - Belt and braces: the takeover only ever writes `contenteditable="false"`, so any observed `"true"` is the product
210
+ making its composer editable again — the field's `readOnly` is cleared then, so a latched field can never outlive
211
+ the state that caused it.
212
+
193
213
  **Escape hatch**: a small tool-row button (**输入法✓ / 输入法✗**, narrow viewports only) swaps back to the stock
194
214
  input box and remembers the choice — no device can be left stuck.
195
215
 
package/README.zh.md CHANGED
@@ -153,6 +153,14 @@ localStorage.removeItem("dsh-mobile-flow:growth"); // 恢复默认
153
153
 
154
154
  **v0.7.1**:工具行清爽化——日常只留 **「输入法✓/✗」** 一个按钮(逃生通道),**长按 600ms** 才把 **「诊断」** 按钮叫出来(再长按收起);诊断开着时「诊断」「复制日志」两个按钮自动出现,方便你取日志或关掉。
155
155
 
156
+ **v0.7.2(手机反馈:新建会话后点输入框没反应)**:新会话刚建出来、**workspace 还没解析出来**时,官方卡片其实是「选择工作区」的**选择器触发器**(`aria-haspopup="menu"`、`contenteditable="false"`、没有绑编辑器),根本不是输入框。旧版接管照样压上去,还把这份「不可编辑」**锁存**成原生 textarea 的 `readOnly` —— 于是:点输入框**不弹键盘、不出现光标**,同时把官方那记「打开选择器」的点击吞掉,用户既不能打字也打不开选择器;而且 workspace 解析好之后它**不会自愈**(只有换会话重新挂载才恢复),正好对上「第一次新建会话没反应、切走再新建就好」的现象(2026-09-19)。
157
+
158
+ 处理:
159
+
160
+ - 接管前先问产品一句「**你现在真是输入框吗**」——只看产品自己写、插件从不碰的两个属性:`aria-haspopup="menu"`(workspace 选择器触发器)与 `aria-disabled="true"`(被 blocked / removed / 离线父会话锁住)。不是输入框就**完全不接管**:不标记卡片、不渲染 seat、不吞点击,官方那记点击照常打开选择器。
161
+ - 这份判断**跟着产品实时更新**(`MutationObserver` 盯着这两个属性),workspace 一解析好、产品把 composer 变成真输入框,接管立刻接上——不再有「锁存」。
162
+ - 兜底自愈:插件只会把官方编辑器写成 `contenteditable="false"`,所以只要观测到 `"true"` 就一定是产品自己恢复可编辑 —— 此时同步把字段的 `readOnly` 解开(历史上被锁存的字段不再永久失灵)。
163
+
156
164
  **逃生开关**:输入框工具行新增小按钮 **「输入法✓ / 输入法✗」**(仅窄屏显示)——一键在原生输入框与官方输入框之间切换并记住选择;任何设备上都不会被卡死。
157
165
 
158
166
  **诊断(工具行「诊断」按钮,或 URL `?dsh-mobile-input=bench` / `,debug`)**:
package/lib/client.js CHANGED
@@ -723,6 +723,40 @@ window.__ModuleLoader__.load({
723
723
  }
724
724
  }
725
725
 
726
+ /**
727
+ * Whether the product's composer on this card is a LIVE TEXT INPUT.
728
+ *
729
+ * The resident composer card is reused for states that are not inputs at
730
+ * all: a blank-session hero whose workspace is not resolved yet renders the
731
+ * same surface as a workspace-PICKER trigger (tapping it opens the picker,
732
+ * `editor={null}`, `contenteditable="false"`), and a blocked/removed/offline
733
+ * session renders it disabled. The takeover must not own those surfaces: a
734
+ * read-only field that swallows the card's own tap is worse than no
735
+ * takeover — the user cannot type AND cannot open the picker.
736
+ *
737
+ * Two product-owned attributes carry that fact and the takeover never
738
+ * writes either (see the "no workspace yet" note on the editability refs):
739
+ * - `aria-haspopup="menu"` — the workspace-picker trigger state
740
+ * - `aria-disabled="true"` — the locked states (blocked composer, removed
741
+ * session, offline parent)
742
+ * `contenteditable` is deliberately NOT consulted here: the takeover forces
743
+ * it to "false" while it owns the surface, so reading it once at mount and
744
+ * latching the answer is what left a phone with a permanently read-only
745
+ * field (v0.7.2 phone report: "new session, tapping the input box does
746
+ * nothing").
747
+ *
748
+ * @param card - the resident composer card, or null.
749
+ * @returns true when the card currently hosts a real text input.
750
+ */
751
+ function composerIsTextInput(card) {
752
+ if (card === null || typeof card.querySelector !== "function") return false;
753
+ var editor = card.querySelector("[data-composer-input]");
754
+ if (editor === null) return false;
755
+ if (editor.getAttribute("aria-haspopup") === "menu") return false;
756
+ if (editor.getAttribute("aria-disabled") === "true") return false;
757
+ return true;
758
+ }
759
+
726
760
  /** Whether the takeover should own the surface on this viewport. */
727
761
  function takeoverLive(media) {
728
762
  var override = readOverride();
@@ -832,9 +866,16 @@ window.__ModuleLoader__.load({
832
866
  var sessionId = props.sessionId;
833
867
  var actions = props.inputActions;
834
868
  var phase = input === undefined ? "plain" : input.phase;
869
+ /* Whether the product's composer is a real text input right now. Tracked
870
+ live (see the mount effect below), never latched: a new session is
871
+ routinely created before its workspace resolves, and that hero composer
872
+ is a workspace-picker trigger, not an input. */
873
+ var readyPair = React.useState(false);
874
+ var composerReady = readyPair[0];
875
+ var setComposerReady = readyPair[1];
835
876
  /* Ownership is surface-level only: claims fall back to the stock editor
836
877
  (see the section comment). */
837
- var active = live && phase === "plain" && sessionId !== undefined;
878
+ var active = live && composerReady && phase === "plain" && sessionId !== undefined;
838
879
 
839
880
  var wrapRef = React.useRef(null);
840
881
  var areaRef = React.useRef(null);
@@ -855,9 +896,17 @@ window.__ModuleLoader__.load({
855
896
  /* Whether the takeover currently owns the surface (read by handlers that
856
897
  outlive a render). */
857
898
  var activeRef = React.useRef(false);
858
- /* The stock editor's own editability, captured before the takeover forces
859
- contenteditable="false" (the field mirrors THIS, not our own override). */
860
- var stockEditableRef = React.useRef(true);
899
+ /* The product's editability intent for the composer, kept across takeover
900
+ releases. The field mirrors THIS, never our own override: the takeover
901
+ forces contenteditable="false" while it owns the surface, so the
902
+ attribute cannot be read back as the product's answer. `forcedRef` marks
903
+ a "false" that is ours. (A latched read-only field used to survive the
904
+ composer becoming editable again — one phone report: a new session whose
905
+ workspace was not resolved yet left the input dead until the next
906
+ session switch.) */
907
+ var appEditableRef = React.useRef(true);
908
+ /* True while the "false" standing on the product's editor is OUR force. */
909
+ var forcedRef = React.useRef(false);
861
910
  /* Last geometry applied: style writes are skipped when nothing moved, so
862
911
  a strict engine's IME is not disturbed by pointless relayouts. */
863
912
  var geometryRef = React.useRef("");
@@ -875,6 +924,34 @@ window.__ModuleLoader__.load({
875
924
  return wrap === null ? null : wrap.closest("[data-composer-card]");
876
925
  };
877
926
 
927
+ /* Is the product's composer a text input right now? Re-answered on every
928
+ product-side change of the two attributes that decide it, whether the
929
+ takeover is active or not — that is what lets the surface be handed
930
+ back when a new session opens without a resolved workspace, and taken
931
+ over again the moment the composer becomes an input. */
932
+ React.useEffect(function () {
933
+ var card = cardOf();
934
+ var update = function () {
935
+ var next = composerIsTextInput(cardOf());
936
+ setComposerReady(function (previous) {
937
+ if (previous !== next) {
938
+ debug("composer " + (next ? "is a text input -> takeover may own it"
939
+ : "is NOT a text input (picker/locked) -> stock surface stays"));
940
+ }
941
+ return next;
942
+ });
943
+ };
944
+ update();
945
+ if (card === null || typeof window.MutationObserver !== "function") return undefined;
946
+ var observer = new window.MutationObserver(update);
947
+ observer.observe(card, {
948
+ attributes: true,
949
+ attributeFilter: ["aria-haspopup", "aria-disabled"],
950
+ subtree: true,
951
+ });
952
+ return function () { observer.disconnect(); };
953
+ }, []);
954
+
878
955
  /* The one invariant this whole file is built around (see the section
879
956
  comment): while the field has focus the plugin writes NOTHING to the
880
957
  DOM. Every measurement, resize and chrome sync below starts with this
@@ -997,15 +1074,25 @@ window.__ModuleLoader__.load({
997
1074
  var placeholder = editor.getAttribute("data-placeholder");
998
1075
  if (placeholder !== null && area.placeholder !== placeholder) area.placeholder = placeholder;
999
1076
  /* The product's own gate (locked / inert / takeover states) rides this
1000
- attribute; mirror it instead of re-deriving the policy. */
1001
- var editable = activeRef.current
1002
- ? stockEditableRef.current
1003
- : editor.getAttribute("contenteditable") !== "false";
1077
+ attribute; mirror it instead of re-deriving the policy. The takeover
1078
+ only ever writes "false" AND marks those writes, so an unmarked value
1079
+ is the product speaking: "true" means it made its composer editable
1080
+ again, which must clear any latched read-only state (see
1081
+ `composerIsTextInput`). */
1082
+ if (!forcedRef.current && editor.getAttribute("contenteditable") === "false") {
1083
+ appEditableRef.current = false;
1084
+ } else if (editor.getAttribute("contenteditable") !== "false") {
1085
+ if (appEditableRef.current === false) debug("stock editor editable again");
1086
+ appEditableRef.current = true;
1087
+ forcedRef.current = false;
1088
+ }
1089
+ var editable = appEditableRef.current;
1004
1090
  /* Keep the stock editor out of the browser's editable set for as long
1005
1091
  as the takeover owns the surface (React re-applies its own value only
1006
1092
  when the prop changes, so ours sticks until then). */
1007
1093
  if (activeRef.current && editor.getAttribute("contenteditable") !== "false") {
1008
1094
  editor.setAttribute("contenteditable", "false");
1095
+ forcedRef.current = true;
1009
1096
  debug("re-forced contenteditable=false");
1010
1097
  }
1011
1098
  if (area.readOnly === editable) area.readOnly = !editable;
@@ -1043,11 +1130,16 @@ window.__ModuleLoader__.load({
1043
1130
  var hadInert = editor.hasAttribute("inert");
1044
1131
  var hadAria = editor.getAttribute("aria-hidden");
1045
1132
  var hadEditable = editor.getAttribute("contenteditable");
1046
- stockEditableRef.current = hadEditable !== "false";
1133
+ /* Trust the product's value unless the "false" standing there is
1134
+ the one we wrote ourselves. */
1135
+ if (!forcedRef.current || hadEditable !== "false") {
1136
+ appEditableRef.current = hadEditable !== "false";
1137
+ }
1047
1138
  if (typeof editor.inert === "boolean") editor.inert = true;
1048
1139
  else editor.setAttribute("inert", "");
1049
1140
  editor.setAttribute("aria-hidden", "true");
1050
1141
  editor.setAttribute("contenteditable", "false");
1142
+ forcedRef.current = true;
1051
1143
  restore = function () {
1052
1144
  if (!hadInert) {
1053
1145
  if (typeof editor.inert === "boolean") editor.inert = false;
@@ -1055,8 +1147,17 @@ window.__ModuleLoader__.load({
1055
1147
  }
1056
1148
  if (hadAria === null) editor.removeAttribute("aria-hidden");
1057
1149
  else editor.setAttribute("aria-hidden", hadAria);
1058
- if (hadEditable === null) editor.removeAttribute("contenteditable");
1059
- else editor.setAttribute("contenteditable", hadEditable);
1150
+ /* Hand editability back to the product — but only while it still
1151
+ wants an input. When the takeover is released because the
1152
+ product locked the composer (picker trigger / blocked session),
1153
+ our own "false" IS the product's answer and must stand: writing
1154
+ the pre-takeover "true" back would re-enable a surface the
1155
+ product has just disabled (and hand the IME a hidden editable
1156
+ again — the exact bug the inert/aria-hidden pair exists for). */
1157
+ if (composerIsTextInput(card)) {
1158
+ editor.setAttribute("contenteditable", appEditableRef.current ? "true" : "false");
1159
+ forcedRef.current = false;
1160
+ }
1060
1161
  };
1061
1162
  }
1062
1163
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-flow",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
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.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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",