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 +20 -0
- package/README.zh.md +8 -0
- package/lib/client.js +112 -11
- package/package.json +2 -2
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
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
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.
|
|
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",
|