dsh-mobile-ui 0.1.2 → 0.2.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
@@ -43,7 +43,10 @@ Fast local iteration: copy to `~/.dsh/profiles/node_modules/dsh-mobile-ui/` and
43
43
  - **CSS layer**: injects `style[data-plugin-css=dsh-mobile-ui]`, all rules wrapped in `@media (max-width:768px)` — desktop loads none of it
44
44
  - **JS layer**: `matchMedia`-driven; on narrow screens the main grid goes single-column, the sidebar is hidden and becomes a fixed overlay drawer with scrim
45
45
  - **QA card** (`ask_user_question`, Mbwy4a component): JS detects the card and turns its composer seat into a full-height floating panel — no `:has()` dependency (works in WeChat/X5-style engines). The card's own `max-height` is lifted and its body stays the single scroll container, so long option lists scroll with a finger on every device; the footer button row wraps when narrow, so the submit button is always visible
46
- - **Regression probe**: `python3 tests/mobile-layout-probe.py` drives a real headless Chromium (upstream CSS + real DOM nesting + the actual plugin bundle) across viewports and option counts, asserting "option list scrolls / last option reachable after a real touch swipe / submit always visible / the fixed top-right menu button does not overlap the card". Exit code 1 on regression. Two rules it guards: never override the card body's `overflow` (that is exactly what broke scrolling before), and hide the floating menu button whenever the question card is open (it sits at `top:48px; right:12px`, right on top of the card title).
46
+ - **Composer action row = two lines** (2026-09-11, settled over two rounds of user feedback): line 1 holds the tools (`+` / attach / access mode) then the context ring and send pushed to its right edge; line 2 is the model trigger alone, spanning the full width. ⚠️ The *first* two-line attempt (tools alone on line 1, model + context + send on line 2) was wrong: it left **183px of dead space** at the end of line 1 (measured at 412px) and the user immediately spotted it as "an empty patch up there". Lifting the context ring and send onto line 1 fills it (183px → 8px). Implemented with `display:contents` on the trailing group so its children become direct flex items of the row, then `order` for visual sequence and `margin-left:auto` to push the last two right — **pure CSS, no DOM moves** (relocating React nodes throws `removeChild` on re-render). Two lines are *required*, not cosmetic: the full model name (`commandcode/deepseek/deepseek-v4.1-flash` = 40 chars ≈ 225px) physically cannot fit on one line inside a 390px viewport — the row has 332px of content width and the tools need 120px, leaving the model trigger ~61px (10/40 chars, measured). Wrapping lets it take 218px at the same viewport (38/40 chars) and the **full name from 400px up**, at the cost of row height 52 → 90px. The access-mode button always shows its shield icon plus its text (`@container (min-width: 170px)`, judged on the row's real width); a clipped model name always uses a real ellipsis.
47
+ - **Composer row visual consistency** (2026-09-11, from the user's "the heights don't match, it looks off" report): upstream ships six different control heights (add 28 / PermissionSelect 28 / model 28 / ContextMeter 28 / primary 34) and our own earlier patches had left a 36/40/44 mix with three font sizes (model 11px / access 13px / context 10px). Everything is now **44px tall with a 12px font** (which also satisfies the iOS HIG minimum touch size — measured: `+`/attach/access were only 36px), the primary button's upstream `translateY(-2px)` is cancelled (a single-line-era optical compensation that pushed its centre 2px below its row), and the access-mode button gets the same round solid background as `+`/attach (upstream renders it as a background-less compact trigger, which looked like a missing piece between two round buttons). **Invariants**: exactly two lines; within a row every button is the same height, the same font size, and their vertical centres spread ≤1.5px; and **no line may end in more than 60px of dead space** (the quantitative guard for that "empty patch"). ⚠️ One more subtlevty: the UA default for `<button>` is **`text-align:center`** — once the model name spans a full line, centring makes the text start at x=69px instead of aligning with the `+` button above, so `text-align:left` must be explicit (the old layout never exposed this because the label's width exactly equalled its text width). ⚠️ This surfaced a fourth word-root leak: the message flow contains **no** `[class*=tools]` at all (only `xzv4MW_actions` / `TS9iAW_actions`), so the old `[class*=scrollBody] [class*=tools] button{min-height:36px}` was really hitting the composer's `uV2eYG_tools` and crushing `+`/attach/access to 36px — the actual cause of "row 1 is 36px while row 2 is 40/44px". It is now scoped to `[class*=flowItem]`, so message-flow buttons stay 36px and composer controls 44px. Never fake a single line with "no-wrap + shrink": upstream group buttons carry a non-compressible `min-width: 44px`, so a narrow row cannot resolve by shrinking and simply **overlaps** (the original bug: at 390px the model trigger covered the context ring by 4.3px and the access-mode button covered it by 5.7px).
48
+ - **Regression probe**: `python3 tests/mobile-layout-probe.py` drives a real headless Chromium (upstream CSS + real DOM nesting + the actual plugin bundle) across viewports, asserting two families of invariants: for the QA card — "option list scrolls / last option reachable after a real touch swipe / submit always visible / the fixed top-right menu button does not overlap the card"; for the composer row — "no two controls overlap / nothing spills past the card's right edge / every control is hit-testable / the access-mode icon *and* its text are always visible / the model name keeps at least 120px so it never degrades back into a sliver / a clipped model name uses a real ellipsis / icon, text and chevron are vertically centred". Exit code 1 on regression.
49
+ - **⚠️ The most dangerous pitfall in this plugin: word-root selectors** (three separate silent breakages on 2026-09-11). Upstream's `_7KE1Ra_triggerLabel / triggerIcon / triggerEffort` all contain **"trigger"**, so a bare `[class*=trigger]` hits them too: with `display:flex` the `text-overflow:ellipsis` is ignored (flex containers ignore it) and the model name is **cut mid-character**; with `min-height:40px` the label is stretched to full row height and its text hugs the top, so the chevron *looks* like it dropped to another line. Always narrow to `button[class*=trigger]`, and keep ellipsis on `display:block`. Likewise `button[aria-label*=访问模式] span { display:none }` hides the **shield icon as well** (it is a span too), leaving a completely blank button — hide only `[class*=triggerLabel]` and keep the icon permanently visible. Two more rules it guards: never override the card body's `overflow` (that is exactly what broke scrolling before), and hide the floating menu button whenever the question card is open (it sits at `top:48px; right:12px`, right on top of the card title). Related: a `min-width` floor on the trailing group's content is harmful — `flex:1 1 0` + `min-width:0` + `justify-content:flex-end` spills children **leftward** over the tools group once the box is narrower than its content minimum (measured: 9px at 365px viewport with `min-width:96px`), so express "the model name should stay readable" some other way.
47
50
 
48
51
  ## Maintenance notes (important)
49
52
 
package/README.zh.md CHANGED
@@ -28,13 +28,14 @@ bash install.sh --uninstall # 卸载
28
28
  ```bash
29
29
  bash scripts/build.sh # 语法 + 契约预检(含 classic-script 校验)
30
30
  bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.tar.gz
31
- python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提问卡片可滚动/选项可达)
31
+ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提问卡片 + 输入区操作行)
32
32
  ```
33
33
 
34
- **提问卡片回归探针**(`tests/mobile-layout-probe.py`):用真实上游 CSS + 真实 DOM 嵌套 +
35
- 真实插件代码,在 headless Chromium 里按视口 × 选项数跑矩阵,断言「正文可滚 / 真实手指
36
- 滑动后最后一个选项可见 / 提交按钮始终可见 / 右上角菜单按钮不遮挡卡片」,回归时退出码 1。
37
- 它守的是两条硬纪律——
34
+ **回归探针**(`tests/mobile-layout-probe.py`):用真实上游 CSS + 真实 DOM 嵌套 +
35
+ 真实插件代码,在 headless Chromium 里按视口跑矩阵,回归时退出码 1。两组:
36
+
37
+ **① 提问卡片**(视口 × 选项数):断言「正文可滚 / 真实手指滑动后最后一个选项可见 /
38
+ 提交按钮始终可见 / 右上角菜单按钮不遮挡卡片」。它守的是两条硬纪律——
38
39
 
39
40
  1. **绝不要覆盖提问卡片正文(`Mbwy4a_body` / `data-question-scroll`)的 overflow**:
40
41
  上游卡片是 `max-height:min(60vh,520px)+overflow:hidden`,正文才是它唯一的滚动容器,
@@ -43,8 +44,38 @@ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提
43
44
  正好压在提问卡片标题右侧(截图实证),所以菜单按钮的显隐要按
44
45
  「抽屉 / 设置面板 / 提问卡片」三态判定,而不是只看抽屉。
45
46
 
46
- 探针已做**双向对照**验证:最老版本(滚动缺陷)与仅修滚动的版本都返回 1,
47
- 完整修复版返回 0 —— 即它抓得住这两类缺陷,不是"永远绿灯"的假探针。
47
+ **② 输入区操作行**(视口 × 会话页/首页 × 长/短模型名):断言「任意两个控件都不重叠 /
48
+ 不越出输入卡右边界 / 都点得到 / 权限图标与文字都常显 / 模型名够宽(≥120px)/
49
+ 被裁模型名走真省略号 / 图标文字箭头垂直居中对齐 / **同行控件等高同字号且中心离散
50
+ ≤1.5px**」。它守的是第三条硬纪律——
51
+
52
+ 3. **绝不能用"禁止换行 + 强行收缩"来硬塞单行**:上游组内按钮有
53
+ `min-width:44px` 不可压缩,`flex-wrap:nowrap` 下宽度不足时 flex 无法收缩化解,
54
+ 只能**溢出重叠**(2026-09-11 修复的真实故障:390px 会话页
55
+ 访问模式↔模型重叠 5.7px、模型↔上下文重叠 4.3px)。正确做法是恢复上游
56
+ `flex-wrap:wrap` 作兜底,并让 **trailing 组吃满余量、只留模型按钮一个可收缩项**。
57
+
58
+ **⚠️ `[class*=trigger]` / `[class*=访问模式] span` 这两类词根选择器是本插件最危险的坑**
59
+ (2026-09-11 一次性踩中三处,全部静默):
60
+
61
+ - 上游 `_7KE1Ra_triggerLabel / triggerIcon / triggerEffort` 类名里都含 **"trigger"**。
62
+ `[class*=trigger]{display:flex}` 会把 `text-overflow:ellipsis` 废掉(flex 容器忽略它)
63
+ → 模型名被**硬切出半个字符**;`[class*=trigger]{min-height:40px}` 还会把 label
64
+ 撑成整行高、文字贴顶 → 视觉上 chevron 像"掉到下一行"。
65
+ ⇒ 一律限定 `button[class*=trigger]`;省略号必须配 `display:block`。
66
+ - `button[aria-label*=访问模式] span{display:none}` 的 `span` 通配会连**盾牌图标**
67
+ (也是 span)一起隐藏 → 整个按钮**纯空白**。⇒ 只隐藏 `[class*=triggerLabel]`,
68
+ 图标必须常显(它是该按钮唯一不变的识别物)。
69
+
70
+ 另外两条同源教训:**trailing 的 `min-width` 不能给小**(`flex:1 1 0` + `min-width:0`
71
+ + `justify-content:flex-end` 时,盒宽小于内容最小宽会把子元素**向左溢出**压住工具组——
72
+ 实测 `min-width:96px` 时 365px 压 9px、410px 压 2px;而"模型名至少多宽"这种业务意图
73
+ 不该用 min-width 表达);**按容器查询而非视口猜**(操作行自带 `container-type:inline-size`,
74
+ `@container (min-width:324px)` 决定权限文字显隐,比按视口宽度判断稳)。
75
+
76
+ 探针已做**双向对照**验证:修复前的版本返回 1(抓到 4 个重叠/越界用例 +
77
+ "权限图标被隐藏"/"推理等级后缀未隐藏"),完整修复版返回 0 —— 即它抓得住这两类缺陷,
78
+ 不是"永远绿灯"的假探针。
48
79
 
49
80
  本地快速迭代:复制到 `~/.dsh/profiles/node_modules/dsh-mobile-ui/` 后刷新页面
50
81
  (客户端插件有 HMR 通道,改 client.js 后页面自动更新)。
@@ -61,6 +92,48 @@ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提
61
92
  卡片自身的 max-height 放开、正文保持为唯一滚动容器(卡片内 `data-question-scroll`),
62
93
  footer 钉在浮层底部。选择器优先用上游稳定属性(`data-question-key` /
63
94
  `data-question-scroll` / `data-composer-seat`),hash 类名只作兜底。
95
+ - **操作行 = 两行布局**(2026-09-11 按用户反馈两轮迭代定型):
96
+ - 第一行:工具组(`+` / 附件 / 访问模式)······ 上下文环 发送
97
+ - 第二行:模型选择器(**独占整行**)
98
+
99
+ 为什么必须两行:完整模型名(如 `commandcode/deepseek/deepseek-v4.1-flash`
100
+ = 40 字符 ≈ 225px)在单行里**物理上放不下**——390px 视口整行内容仅 332px、
101
+ 工具组最少 120px,单行最多只能给模型名约 61px(10/40 字符,实测)。
102
+ 模型独占整行后同一视口可达 236px,**340px 以上就完整显示**;
103
+ 代价是行高 52 → 98~102px。
104
+
105
+ ⚠️ **第一版两行排布(工具组独占第一行、模型+上下文+发送在第二行)是错的**:
106
+ 它在第一行右侧留出 **183px 空白**(412px 实测),用户一眼看出"上面空了一块"。
107
+ 正确做法是把上下文环与发送提到第一行右侧填满,模型独占第二行
108
+ (空块 183px → 8px)。实现用 `display:contents` 打散 `trailing`,
109
+ 让其子元素成为 row 的直接 flex 项,再用 `order` 指定视觉顺序、
110
+ `margin-left:auto` 把后两者推到最右 —— **纯 CSS,不移动 DOM**
111
+ (搬 React 节点会在重渲染时 removeChild 抛错)。
112
+ 权限按钮恒显盾牌图标 + 文字(`@container (min-width:170px)` 判据,第一行独占后
113
+ 320px 也放得下「完全权限」);被裁的模型名一律走**真省略号**。
114
+
115
+ - **操作行视觉统一**(2026-09-11 用户反馈"高度不一致、怪怪的"实测定案):
116
+ 上游六种控件高度本不相同(add 28 / PermissionSelect 28 / 模型 28 / ContextMeter 28
117
+ / primary 34,而我们此前的补丁又留下 36/40/44 混排),字号也散落三代
118
+ (模型 11px / 权限 13px / 上下文 10px)。现已全部统一为 **44px 高 + 12px 字号**
119
+ (顺带满足 iOS HIG 最小可点尺寸——实测原来 `+`/附件/权限 只有 36px 可点高度),
120
+ 并取消发送键上游的 `translateY(-2px)`(那是单行时代的视觉补偿,多行下让它的
121
+ 中心比同行低 2px),权限按钮补上与 `+`/附件 相同的圆形实底(上游它是无底色
122
+ 紧凑触发器,夹在两个圆形实底按钮中间显得"缺一块",是"怪"的主要来源)。
123
+ **判据**:恰为两行、同一行内所有按钮等高同字号、垂直中心离散 ≤1.5px、
124
+ **每行右侧空块 ≤60px**(后者正是"空了一块"的量化守卫)。
125
+
126
+ ⚠️ 另一个隐蔽坑:浏览器 UA 默认给 `<button>` 是 **`text-align:center`**。
127
+ 模型名独占整行后,居中会让文字从 69px 处才开始(实测 412px),
128
+ 与上一行的 `+` 按钮不对齐 —— 必须显式 `text-align:left`。
129
+ (旧排布里 label 宽度恰好等于文字宽度、没有富余空间居中,所以一直没暴露。)
130
+
131
+ ⚠️ 这条修复暴露了第四处**词根选择器泄漏**:消息流里**并不存在** `[class*=tools]`
132
+ (实测消息流只有 `xzv4MW_actions` / `TS9iAW_actions`),所以旧规则
133
+ `[class*=scrollBody] [class*=tools] button{min-height:36px}` 唯一命中的对象是
134
+ **输入框的 `uV2eYG_tools`**,把 `+`/附件/权限 压成 36px —— 这正是"第一行 36px、
135
+ 第二行 40/44px"的根源。现已限定到 `[class*=flowItem]` 内,消息流按钮保持
136
+ 36px、输入框控件 44px,两者互不干扰。
64
137
 
65
138
  ## 维护须知(重要)
66
139
 
package/client.js CHANGED
@@ -50,9 +50,15 @@ window.__ModuleLoader__.load({
50
50
  " body.dsh-mobile-ui [class*=composerSeat],body.dsh-mobile-ui [class*=composerStack]{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px)) !important}",
51
51
  /* 头部紧凑(允许换行,标题不再截断) */
52
52
  " body.dsh-mobile-ui [class*=header]{flex-wrap:wrap;row-gap:4px}",
53
- /* 触摸目标:composer 区按钮/触发器 ≥44px(iOS HIG) */
53
+ /* 触摸目标:composer 区按钮统一 44px(iOS HIG 最小可点尺寸)。
54
+ 上游本身高度不一(add 28 / PermissionSelect 28 / 模型 28 / ContextMeter 28
55
+ / primary 34),下面「操作行统一高度」块会全部拉到 44px,
56
+ 这里只负责给一个所有按钮都能继承的基线。
57
+ ⚠️ 必须限定 button:上游 _7KE1Ra_triggerLabel / triggerIcon / triggerEffort
58
+ 类名里也含 "trigger",裸 [class*=trigger] 会把 min-height 套到这些
59
+ span 上 → label 被撑成整行高、文字贴顶 → 视觉上 chevron 像"掉到下一行"
60
+ (2026-09-11 用户截图实证;与下面 访问模式/选择模型 的选择器收窄同源)。 */
54
61
  " body.dsh-mobile-ui [class*=composerSeat] button,body.dsh-mobile-ui [class*=composerStack] button{min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}",
55
- " body.dsh-mobile-ui [class*=composerSeat] [class*=trigger],body.dsh-mobile-ui [class*=composerStack] [class*=trigger]{min-height:40px}",
56
62
  /* 统计行(LLM 用时/首token)允许换行防截断(分段 nowrap 片段的父容器) */
57
63
  " body.dsh-mobile-ui [class*=composerStack] > *,body.dsh-mobile-ui [class*=composerSeat] > *{white-space:normal !important;overflow-wrap:anywhere !important}",
58
64
  /* 抽屉会话列表项触摸目标 44px */
@@ -99,18 +105,93 @@ window.__ModuleLoader__.load({
99
105
  " body.dsh-mobile-ui [class*=Mbwy4a_footer] [class*=progress]{flex:0 1 auto !important;min-width:0 !important}",
100
106
  " body.dsh-mobile-ui [class*=Mbwy4a_pager]{flex:0 1 auto !important;min-width:0 !important}",
101
107
  " body.dsh-mobile-ui [class*=Mbwy4a_footer] button[class*=_primary]{flex:0 0 auto !important}",
102
- /* 操作行单行紧凑:hero 4 元素 / 会话页 5 元素(含上下文按钮)一行放下。
108
+ /* 操作行紧凑:hero 4 元素 / 会话页 5 元素(含上下文按钮)一行放下。
103
109
  权限按钮移动端图标模式(44px 隐藏文字防撑宽),上下文紧凑 36px,
104
- 模型选择器 127px 居中显示(轻微截断可接受) */
105
- " body.dsh-mobile-ui [class*=uV2eYG_row]{flex-wrap:nowrap !important;gap:4px !important}",
106
- " body.dsh-mobile-ui [class*=uV2eYG_row] > *{flex:0 1 auto !important}",
107
- " body.dsh-mobile-ui [class*=uV2eYG_tools],body.dsh-mobile-ui [class*=uV2eYG_modes]{padding:0 !important;gap:4px !important}",
108
- " body.dsh-mobile-ui [class*=uV2eYG_row] [class*=trigger]{display:flex !important;align-items:center !important;line-height:1.2 !important}",
109
- " body.dsh-mobile-ui button[aria-label*=选择模型]{display:flex !important;align-items:center !important;justify-content:center !important;flex:0 0 auto !important;max-width:136px !important;font-size:11px !important;padding:0 6px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
110
- " body.dsh-mobile-ui button[aria-label*=访问模式]{width:44px !important;flex:0 0 44px !important;padding:0 !important;justify-content:center !important}",
111
- " body.dsh-mobile-ui button[aria-label*=访问模式] span,body.dsh-mobile-ui button[aria-label*=访问模式] [class*=label]{display:none !important}",
112
- " body.dsh-mobile-ui button[aria-label*=上下文]{width:36px !important;max-width:36px !important;min-width:36px !important;flex:0 0 36px !important;padding:0 !important;font-size:10px !important;justify-content:center !important;overflow:hidden !important}",
110
+ 模型选择器最长 136px、可伸缩、超出省略号截断。
111
+ ⚠️ 2026-09-11 修复「模型/上下文/控制按钮三者在会话页重叠」:
112
+ ① 旧规则 [class*=uV2eYG_row] [class*=trigger] 会误命中上游的
113
+ _7KE1Ra_triggerLabel / triggerIcon / triggerEffort(类名都含 "trigger"),
114
+ 用 display:flex !important 顶掉上游 @container(width<=360px) 的折叠,
115
+ 把模型按钮撑到 136px 且无法收缩 —— 选择器收窄为 button[class*=trigger];
116
+ ② 旧 row 强制 nowrap 且把 tools/trailing 都设成 flex:0 1 auto,
117
+ 但组内按钮有 44px min-width 不可压缩,宽度不足时不是换行而是**溢出**,
118
+ 于是模型按钮压住上下文环、访问模式按钮压住模型按钮(实测重叠 5.7px/4.3px)。
119
+ 改法:恢复上游 flex-wrap:wrap 作兜底(宁可换行绝不重叠),
120
+ trailing 吃满余量并右对齐,全行只留模型按钮一个可收缩项。 */
121
+ " body.dsh-mobile-ui [class*=uV2eYG_row]{flex-wrap:wrap !important;gap:8px !important;row-gap:6px !important}",
122
+ " body.dsh-mobile-ui [class*=uV2eYG_row] > *{flex:0 1 auto !important;min-width:0 !important}",
123
+ /* ── 移动端操作行排布(2026-09-11 v3,按用户反馈"上面空了一块"重排)────
124
+ 两行,但**不是**「工具组独占第一行 + 其余全在第二行」:
125
+ 第一行:工具组(+ / 附件 / 访问模式) ······ 上下文环 发送
126
+ 第二行:模型选择器(独占整行)
127
+ 为什么这样:旧排布(工具组独占第一行、模型+上下文+发送在第二行)
128
+ 实测在 412px 下第一行右侧留出 **183px 空白**——用户一眼看出"上面空了一块"。
129
+ 把上下文环与发送键提到第一行右侧正好填满(空块降到 8px),
130
+ 同时模型独占整行后名字可完整显示(412px 实测 40/40 字符,旧排布 37/40)。
131
+ 实现:display:contents 打散 trailing,让它三个子元素成为 row 的直接
132
+ flex 项,再用 order 指定视觉顺序、margin-left:auto 把后两者推到最右。
133
+ (纯 CSS,不移动 DOM —— 搬 React 节点会在重渲染时 removeChild 抛错。) */
134
+ " body.dsh-mobile-ui [class*=uV2eYG_trailing]{display:contents !important}",
135
+ " body.dsh-mobile-ui [class*=uV2eYG_tools]{order:1 !important;flex:0 0 auto !important;padding:0 !important;gap:8px !important}",
136
+ " body.dsh-mobile-ui [class*=uV2eYG_modes]{padding:0 !important;gap:8px !important}",
137
+ " body.dsh-mobile-ui [class*=JObwrW_root]{order:2 !important;flex:0 0 auto !important;margin-left:auto !important}",
138
+ " body.dsh-mobile-ui [class*=uV2eYG_primary]{order:3 !important;flex:0 0 auto !important}",
139
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button[class*=trigger]{display:flex !important;align-items:center !important;line-height:1.2 !important}",
140
+ /* 模型选择器:独占整行(flex:1 1 100%),故**不设宽度上限**;
141
+ min-width:0 保留(给内容设 min-width 地板会导致窄行时向左溢出压住工具组) */
142
+ " body.dsh-mobile-ui [class*=_7KE1Ra_root]{order:4 !important;flex:1 1 100% !important;min-width:0 !important;max-width:none !important;overflow:hidden !important}",
143
+ /* 模型按钮:内边距 6→4px。
144
+ ⚠️ text-align:left 必须显式写:浏览器的 UA 默认给 <button> 是 text-align:center,
145
+ 模型名独占整行后,居中会让文字从 69px 处开始(实测 412px),
146
+ 左对齐才与上一行的 + 按钮对齐(旧排布里 label 宽度恰好等于文字宽度,
147
+ 没有富余空间居中,所以这个坑一直没暴露)。 */
148
+ " body.dsh-mobile-ui button[aria-label*=选择模型]{display:flex !important;align-items:center !important;justify-content:flex-start !important;text-align:left !important;flex:1 1 auto !important;min-width:0 !important;max-width:100% !important;width:100% !important;font-size:11px !important;padding:0 4px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
149
+ /* 模型名:display:block + overflow:hidden + text-overflow:ellipsis 才有**真省略号**
150
+ (flex 容器忽略 text-overflow,会硬切出半个字符——用户截图里的"半截字母")。
151
+ flex:1 1 auto 让名字吃满、把 chevron 顶到最右(像正常下拉框)。
152
+ 垂直居中由父按钮的 align-items:center 负责。 */
153
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerLabel]{display:block !important;flex:1 1 auto !important;line-height:1.2 !important;min-width:0 !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
154
+ /* 推理等级(Max)移动端不显示:上游给它 flex-shrink:1000(先消失),
155
+ 上轮误改成 flex:0 1 auto 后它与模型名一起被压缩,只剩 8px 宽的
156
+ 半截字符(用户截图实证)。这里直接隐藏,信息仍可在模型菜单里看到。 */
157
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerEffort]{display:none !important}",
158
+ /* 模型图标移动端不显示:两行布局后模型名有整行宽度,文字信息量远大于图标
159
+ (上游在 @container(width<=360px) 时才折叠成图标,我们不再需要那条降级) */
160
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerIcon]{display:none !important}",
161
+ /* 权限(访问模式)按钮:恢复图标 + 文字。
162
+ ⚠️ 旧规则 button[aria-label*=访问模式] span{display:none} 用 span 通配,
163
+ 连上游的 triggerIcon(盾牌图标也是 span)一起隐藏 → 整个按钮纯空白
164
+ (2026-09-11 用户截图实证;此前被控件重叠掩盖)。现在:
165
+ 图标常显(这是按钮唯一不变的识别物);文字只在整行有余量时显示
166
+ (容器查询按行实际宽度判定,比按视口猜可靠),窄屏自动退回图标模式。
167
+ 宽度自适应 + 可收缩,避免任何情况下把行撑溢出。 */
168
+ " body.dsh-mobile-ui button[aria-label*=访问模式]{width:auto !important;min-width:44px !important;max-width:120px !important;flex:0 1 auto !important;padding:0 4px !important;gap:3px !important;justify-content:center !important}",
169
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerIcon]{display:inline-flex !important;flex:0 0 auto !important}",
170
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerLabel]{display:none !important;line-height:1.2 !important;min-width:0 !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
171
+ /* chevron 在移动端省略(该按钮语义已由盾牌图标 + 文字表达,省 18px 留给模型名) */
172
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=chevron]{display:none !important}",
173
+ " body.dsh-mobile-ui button[aria-label*=上下文]{width:44px !important;max-width:44px !important;min-width:44px !important;flex:0 0 44px !important;padding:0 !important;font-size:11px !important;justify-content:center !important;overflow:hidden !important}",
113
174
  " body.dsh-mobile-ui button[aria-label*=上下文] span,body.dsh-mobile-ui button[aria-label*=上下文] [class*=label]{max-width:30px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
175
+ /* ── 操作行「视觉统一」四件事(2026-09-11 用户反馈"高度不一致、怪怪的")──
176
+ 实测病灶(412px 会话页):
177
+ 第一行 36px(+ / 附件 / 权限),第二行 40px(模型 / 上下文)+ 44px(发送)
178
+ → 同一行内 36 vs 40 vs 44 三种高度;发送键还带上游 translateY(-2px)
179
+ (单行时代的视觉补偿),多行布局下让它的中心比同行低 2px。
180
+ 修法:全部拉到 44px(顺带满足 iOS HIG 最小可点尺寸,实测原来
181
+ `+`/附件/权限 只有 36px 可点高度、模型/上下文 40px,都偏小),
182
+ 取消发送键的 translateY,字号统一 12px(原模型 11 / 权限 13 / 上下文 10)。*/
183
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button{min-height:44px !important;height:44px !important}",
184
+ " body.dsh-mobile-ui [class*=uV2eYG_add]{width:44px !important;min-width:44px !important;max-width:44px !important}",
185
+ " body.dsh-mobile-ui [class*=_7KE1Ra_root]{height:44px !important}",
186
+ " body.dsh-mobile-ui [class*=JObwrW_root],body.dsh-mobile-ui [class*=JObwrW_trigger]{height:44px !important}",
187
+ " body.dsh-mobile-ui [class*=uV2eYG_primary]{transform:none !important}",
188
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button{font-size:12px !important}",
189
+ " body.dsh-mobile-ui [class*=uV2eYG_row] [class*=triggerLabel]{font-size:12px !important;line-height:1.2 !important}",
190
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerIcon] svg{width:16px !important;height:16px !important}",
191
+ /* 权限按钮与 + / 附件 同一视觉语言(上游它是无底色的紧凑触发器,
192
+ 夹在两个圆形实底按钮中间显得"缺一块",是"怪"的主要来源) */
193
+ " body.dsh-mobile-ui button[aria-label*=访问模式]{background:var(--dsw-specific-selector) !important;border-radius:999px !important}",
194
+ " body.dsh-mobile-ui button[aria-label*=访问模式]:active{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover)) !important}",
114
195
  /* 输入框圆角与内边距(视觉更圆润、输入更舒适;卡左右加宽) */
115
196
  " body.dsh-mobile-ui [class*=input]{border-radius:18px !important}",
116
197
  " body.dsh-mobile-ui [class*=uV2eYG_root]{padding-left:8px !important;padding-right:8px !important}",
@@ -130,20 +211,74 @@ window.__ModuleLoader__.load({
130
211
  " body.dsh-mobile-ui [class*=avatar]{width:28px !important;height:28px !important}",
131
212
  /* AI 正文与工具行视觉分隔 */
132
213
  " body.dsh-mobile-ui [class*=markdown]{margin-top:6px}",
133
- /* 头部标题允许换行(防截断) */
134
- " body.dsh-mobile-ui [class*=header] [class*=title],body.dsh-mobile-ui [class*=crumbs]{white-space:normal;overflow-wrap:anywhere}",
214
+ /* 头部标题允许换行(防截断)。
215
+ ⚠️ 2026-09-14 修复(第 5 处词根选择器泄漏):原规则同时给 [class*=crumbs] 设了
216
+ white-space:normal + overflow-wrap:anywhere —— **两者都是继承属性**,而上游
217
+ .wSkVaW_crumbs 自身是 nowrap(面包屑内所有元素都靠这条继承保持单行),
218
+ 于是 crumbs 内**没有自己 white-space 规则**的元素被连带改成逐字换行:
219
+ 子代理切换器 ZKlsPq_root 里的无类名文本 span("N 个子代理")实测
220
+ 从 80×28 变成 48×96 的一列竖排,header 被撑到 142px(用户截图实证)。
221
+ crumbs 里的标题按钮 .wSkVaW_crumb 自身就是 nowrap + ellipsis,
222
+ 容器规则对它的"防截断"毫无作用(实测 ws 仍为 nowrap)
223
+ ⇒ [class*=crumbs] 这半条只有副作用,删除;header 标题照旧。 */
224
+ " body.dsh-mobile-ui [class*=header] [class*=title]{white-space:normal;overflow-wrap:anywhere}",
225
+ /* ── 会话头部「面包屑 + 子代理切换器」在窄屏被裁(2026-09-14 修复)──────
226
+ 上游把「面包屑(会话标题 + 子代理切换器) + 4 个操作按钮」全塞在 titleRow 一行:
227
+ 390px 实测 titleRow 342px,titleCluster 只分到 214px,其中 headerActions
228
+ 固定占 68px ⇒ crumbs 只剩 136px,而内容需要 标题(最多 220) + 切换器(94) ≈ 318px。
229
+ 两者按 flex 比例收缩后总宽仍 189px > 136px,超出部分被 crumbs 的
230
+ overflow:hidden 裁掉 —— 手机上显示成「[方块] 8」,“个子代理”四个字全不见;
231
+ 更糟的是被裁区域上的点击落在 headerActions 的按钮上(实测 elementFromPoint
232
+ 命中的是“标准模式”),用户点切换器**打不开子代理列表**。
233
+ 修法三件事(缺一不可):
234
+ ① crumbs 内的切换器 flex:none —— 空间不足时让**标题**先收缩(标题自带
235
+ ellipsis),而不是两者一起被裁掉尾部;
236
+ ② titleCluster 独占整行(flex-basis:100%)+ titleRow 允许换行 —— 把 4 个
237
+ 操作按钮(终端/打开方式/更多/右侧边栏)挤到第二行,面包屑拿到整行宽;
238
+ ③ 实测结果:390px 标题 152px + 切换器 94px 完整可点、菜单能打开;
239
+ 360/320/414px 同样成立(标题 122/82/176px,切换器恒为 94px 完整);
240
+ header 76 → 106px(+30px,换信息完整与可点,hero 页无 titleRow 不受影响)。
241
+ ⚠️ 别改用 crumbs/titleCluster 的 min-width:max-content:实测会把宽度锁死成
242
+ 332px,320px 视口下切换器直接出屏(inView=false)。
243
+ ⚠️ 两个 hash 前缀(wSkVaW_ / ZKlsPq_)会随 dsh 升级漂移,失效是静默的
244
+ (退回“切换器被裁”),升级后跑 tests/mobile-layout-probe.py 的 subagent 段复核。 */
245
+ " body.dsh-mobile-ui [class*=wSkVaW_crumbSeg] [class*=ZKlsPq_root]{flex:0 0 auto !important}",
246
+ " body.dsh-mobile-ui [class*=wSkVaW_titleRow]{flex-wrap:wrap !important;row-gap:4px !important}",
247
+ " body.dsh-mobile-ui [class*=wSkVaW_titleCluster]{flex:1 1 100% !important}",
248
+ /* 极窄屏(≤360px)面包屑只留当前段:子代理会话页的面包屑是
249
+ 「父会话 / 子代理名 / N 个子代理」,320px 实测当前段标题被挤到 36px
250
+ (只剩两三个字符,探针会判 ❌),隐藏父级段与分隔符后回到 96px。
251
+ 手机上回父会话另有入口(子代理菜单),可读性优先级更高。 */
252
+ " @media (max-width:360px){",
253
+ " body.dsh-mobile-ui [class*=wSkVaW_crumbSeg] > [class*=wSkVaW_crumb]:not([class*=wSkVaW_crumbCurrent]),body.dsh-mobile-ui [class*=wSkVaW_crumbSeg] > [class*=wSkVaW_crumbSep]{display:none !important}",
254
+ " }",
135
255
  /* 输入框聚焦反馈 */
136
256
  " body.dsh-mobile-ui textarea:focus,body.dsh-mobile-ui [class*=input]:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-brand-primary,rgba(79,124,255,.45))}",
137
- /* 消息流操作按钮(复制/反馈/分享)触摸目标提升 */
138
- " body.dsh-mobile-ui [class*=scrollBody] [class*=actions] button,body.dsh-mobile-ui [class*=scrollBody] [class*=tools] button{min-width:36px;min-height:36px;display:inline-flex;align-items:center;justify-content:center}",
257
+ /* 消息流操作按钮(复制/反馈/分享)触摸目标提升。
258
+ ⚠️ 必须限定在 [class*=flowItem] 内:消息流里**并不存在** [class*=tools]
259
+ (实测消息流只有 xzv4MW_actions / TS9iAW_actions),所以裸
260
+ `[class*=scrollBody] [class*=tools] button` 唯一命中的对象是**输入框里的
261
+ uV2eYG_tools**,把 + / 附件 / 权限 三个按钮压成 36px ——
262
+ 这正是"第一行 36px、第二行 40/44px 高度不一致"的根源
263
+ (2026-09-11 用户反馈"选项卡和输入按钮高度不一致"实测定案)。 */
264
+ " body.dsh-mobile-ui [class*=scrollBody] [class*=flowItem] [class*=actions] button,body.dsh-mobile-ui [class*=scrollBody] [class*=flowItem] [class*=tools] button{min-width:36px;min-height:36px;display:inline-flex;align-items:center;justify-content:center}",
265
+ /* ── 按「行实际宽度」决定权限按钮是否显示文字(容器查询,不用视口猜)──
266
+ 操作行上游自带 container-type:inline-size,故这里以行宽为判据。
267
+ v3 排布下第一行是「工具组 ····· 上下文 发送」,要保住**两行**,
268
+ 权限文字的宽度预算必须让出来:实测 320px 视口(行宽 278px)带文字
269
+ 会挤成三行,故阈值取 290px —— 行宽不足时权限按钮退回纯图标,
270
+ 上下文+发送就能与工具组同行(该视口模型名反而能完整显示)。 */
271
+ " @container (min-width:290px){",
272
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerLabel]{display:block !important}",
273
+ " }",
139
274
  "}",
140
275
 
141
- /* 320px 小屏: 操作行进一步压缩防重叠 */
276
+ /* ≤360px 小屏:仅压缩间距。
277
+ ⚠️ 这里**不再**把模型按钮降级成图标——两行布局后模型独占第二行,
278
+ 320px 视口也有约 244px 可用(可显示 38/40 字符),降级反而丢信息。
279
+ 权限文字也无需降级:容器查询阈值已降到 170px,窄屏同样显示。 */
142
280
  "@media (max-width:360px){",
143
- " body.dsh-mobile-ui button[aria-label*=选择模型]{max-width:110px !important}",
144
281
  " body.dsh-mobile-ui [class*=uV2eYG_row]{gap:2px !important}",
145
- " body.dsh-mobile-ui [class*=uV2eYG_tools],body.dsh-mobile-ui [class*=uV2eYG_modes]{gap:2px !important}",
146
- " body.dsh-mobile-ui button[aria-label*=上下文]{margin-left:8px !important}",
147
282
  "}",
148
283
  /* 抽屉视觉:右侧圆角 + 更实背景 */
149
284
  "body.dsh-mobile-ui [class*=sidebarCol].dsh-mobile-drawer{border-radius:0 18px 18px 0;padding-bottom:64px !important;box-sizing:border-box !important}",
@@ -158,7 +293,7 @@ window.__ModuleLoader__.load({
158
293
  /* trailing 基础 gap 2px:hero(无上下文)模型右扩吃 gap,左缘保持不变 */
159
294
  "body.dsh-mobile-ui [class*=uV2eYG_trailing]{gap:2px !important}",
160
295
  /* 会话页(有上下文按钮):上下文 margin-left 右移 12px(只扩模型↔上下文间距) */
161
- "body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:12px !important}",
296
+ "body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:4px !important}",
162
297
  "body.dsh-mobile-ui [class*=VOzbGW_nav]{width:68px !important;flex:0 0 68px !important;min-width:68px !important;overflow:visible !important}",
163
298
  "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navList]{flex:1 1 auto !important;height:auto !important;max-height:none !important;overflow-y:auto !important}",
164
299
  "body.dsh-mobile-ui [class*=VOzbGW_nav] button,body.dsh-mobile-ui [class*=VOzbGW_nav] [role=button]{white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}",
@@ -220,6 +355,52 @@ window.__ModuleLoader__.load({
220
355
  var qaOpen = false; /* 提问卡片打开标志(卡片浮层打开时隐藏右上角菜单,防遮挡标题) */
221
356
  var savedGrid = null; /* 窄屏前的 frame 原始 grid(宽屏恢复用) */
222
357
 
358
+ /* ── 子代理切换器「点一下开、再点收回」补丁(2026-09-14)─────────────
359
+ 上游 dsh-client-ui-subagent 的切换器在**触摸设备上只能开、不能收**:
360
+ · trigger 的 onClick 是条件绑定 ——
361
+ onClick: openTitle === void 0 ? void 0 : () => {…},
362
+ 会话头部这个纯切换器没有 openTitle ⇒ 按钮**本体没有任何点击逻辑**;
363
+ · “打开”实际靠容器 rootRef 的 onMouseEnter → scheduleHoverOpen(150ms),
364
+ 手机 tap 会合成**一次** mouseenter,所以第一下能开;
365
+ · 再点同一处不再产生 mouseenter(指针从未离开),onClick 又是 undefined
366
+ ⇒ 既关不掉也开不回来,用户只能点空白或按 Esc 才收得回去
367
+ (用户报障:“点击箭头展开后再点击不能收回”)。
368
+ 上游自带两条键盘路径可复用(不改它的状态机,只喂官方事件):
369
+ · trigger onKeyDown 的 ArrowDown → changeOpen(true)(打开)
370
+ · rootRef onKeyDown → navigate 的 Escape → changeOpen(false, true)(关闭+回焦)
371
+ ⚠️ 只在移动端(mq.matches)补:桌面 hover 开合语义完整,不该被改写。
372
+ ⚠️ 展开状态每次从 DOM 的 aria-expanded 现读(React 渲染的实时值),不缓存。
373
+ ⚠️ 捕获阶段拦截并吞掉 click:否则会冒泡到 React root,触发上游(可能有 openTitle
374
+ 的变体)自己的 onClick 逻辑。 */
375
+ function onSwitcherToggle(event) {
376
+ try {
377
+ if (!mq.matches) return;
378
+ var target = event.target;
379
+ var btn = target && target.closest
380
+ ? target.closest("button[class*=ZKlsPq_trigger]") : null;
381
+ if (!btn) return;
382
+ var key = btn.getAttribute("aria-expanded") === "true" ? "Escape" : "ArrowDown";
383
+ event.stopPropagation();
384
+ if (event.stopImmediatePropagation) event.stopImmediatePropagation();
385
+ event.preventDefault();
386
+ var ev;
387
+ try {
388
+ ev = new KeyboardEvent("keydown", { key: key, bubbles: true, cancelable: true });
389
+ } catch (e) {
390
+ /* 旧内核(微信 X5 等)无 KeyboardEvent 构造器:退回 initEvent + 手赋 key */
391
+ ev = document.createEvent("Event");
392
+ ev.initEvent("keydown", true, true);
393
+ ev.key = key;
394
+ }
395
+ btn.dispatchEvent(ev);
396
+ } catch (e) { /* 补丁失败静默降级(点击仍走上游原逻辑) */ }
397
+ }
398
+ /* 幂等绑定:本函数只依赖 DOM 与 matchMedia,无闭包状态,重挂载无需解绑 */
399
+ if (!document.__dshMobileSwitcherToggle) {
400
+ document.__dshMobileSwitcherToggle = true;
401
+ document.addEventListener("click", onSwitcherToggle, true);
402
+ }
403
+
223
404
  /* 主布局 frame:display:grid 的顶层容器(CSS 生效前 3 列 / 生效后 1 列均可) */
224
405
  function findFrame() {
225
406
  var els = document.querySelectorAll("[class*=frame]");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-ui",
3
- "version": "0.1.2",
3
+ "version": "0.2.3",
4
4
  "description": "Mobile UI for the DeepSeek Harness Web GUI: responsive layout, overlay session drawer, 44px touch targets, safe-area support, reading enhancements (≤768px, zero desktop impact)",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,7 +10,9 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "dsh": {
13
- "bundle": { "patch": "./cordis.patch.yml" },
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
14
16
  "client": {
15
17
  "inject": [],
16
18
  "platform": "web",
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """dsh-mobile-ui 真实页面(live)交互探针:头部切换器几何 + 子代理菜单 toggle + 抽屉回归。
3
+
4
+ 和 `mobile-layout-probe.py`(静态复现页 + 真实上游 CSS)互补:那套测**几何**,
5
+ 这套测**交互**——2026-09-14 的两个问题都只在真实 dsh 页面上出现:
6
+
7
+ ① 头部子代理切换器被 crumbs 裁掉:宽度只露 27px(「[方块] 8」),
8
+ 且中心点的点击落在右侧 headerActions 的“标准模式”上 ⇒ 打不开子代理列表;
9
+ ② 上游切换器在**触摸设备上只能开不能收**:trigger 的 onClick 是条件绑定
10
+ (`onClick: openTitle === void 0 ? void 0 : …`,头部这个变体没有 openTitle
11
+ ⇒ 本体无点击逻辑),“打开”实际靠容器 onMouseEnter(150ms),
12
+ 而再点同一处不会再产生 mouseenter ⇒ 既关不掉也开不回来。
13
+ 插件用官方键盘路径补的 toggle:展开时点→Escape,收起时点→ArrowDown。
14
+
15
+ 用法(需要目标机 dsh web 在跑,且会话列表里有**含子代理**的会话):
16
+
17
+ python3 tests/live-probe.py
18
+ python3 tests/live-probe.py --base-url http://192.168.0.205:3080 --session miniqmt
19
+ python3 tests/live-probe.py --token-file ~/.dsh/lan-access-token --viewport 390x844
20
+
21
+ 退出码非 0 = 回归。桌面(>768px)语义不在本探针范围:上游 hover 开合,插件不介入。
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import pathlib
28
+ import sys
29
+
30
+ MENUS = "() => document.querySelectorAll('[class*=ZKlsPq_menu]').length"
31
+ ROWS = """() => [...document.querySelectorAll('[class*=ZKlsPq_row]')]
32
+ .map(e => { const r = e.getBoundingClientRect(); return [Math.round(r.x + 40), Math.round(r.y + 20)]; })"""
33
+ GEOM = """() => {
34
+ const sw = [...document.querySelectorAll('button[class*=ZKlsPq_trigger]')].pop();
35
+ const crumbs = document.querySelector('[class*=wSkVaW_crumbs]') || document.querySelector('[class*=crumbs]');
36
+ if (!sw || !crumbs) return { err: sw ? 'no crumbs' : 'no switcher (该会话没有子代理?)' };
37
+ const b = sw.getBoundingClientRect(), c = crumbs.getBoundingClientRect();
38
+ const t = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
39
+ return {
40
+ text: (sw.textContent || '').trim(), w: Math.round(b.width), h: Math.round(b.height),
41
+ clipped: b.right > c.right + 0.5, tall: b.height > 40,
42
+ clickable: !!(t && t.closest && t.closest('button[class*=ZKlsPq_trigger]')),
43
+ crumbsText: (crumbs.innerText || '').replace(/\\n/g, ' ').slice(0, 60),
44
+ };
45
+ }"""
46
+ DRAWER = """() => { const s = document.querySelector('[class*=sidebarCol]');
47
+ return s ? (getComputedStyle(s).display + ':' + Math.round(s.getBoundingClientRect().width)) : 'none'; }"""
48
+
49
+ # ⚠️ [role=treeitem] 里**混着工作区节点**(点它会切换工作区 → 整批换掉会话列表,
50
+ # 2026-09-14 实测按索引点会点到工作区);会话行才带 sessionRow 类名,必须用它过滤。
51
+ SESSION_ROWS_JS = "[...document.querySelectorAll('[role=treeitem][class*=sessionRow]')]"
52
+ SESSION_LABELS = """(needle) => %s
53
+ .map(r => (r.textContent || '').trim())
54
+ .filter(t => !needle || t.includes(needle))""" % SESSION_ROWS_JS
55
+ SESSION_CLICK = """(t) => { for (const r of %s)
56
+ if ((r.textContent || '').trim() === t) { r.click(); return true; } return false; }""" % SESSION_ROWS_JS
57
+
58
+
59
+ def run(args) -> int:
60
+ from playwright.sync_api import sync_playwright
61
+
62
+ token_file = pathlib.Path(args.token_file).expanduser()
63
+ if not token_file.exists():
64
+ print(f"找不到 token 文件:{token_file}")
65
+ return 2
66
+ token = token_file.read_text(encoding="utf-8").strip()
67
+ w, h = (int(x) for x in args.viewport.lower().split("x"))
68
+ base = args.base_url.rstrip("/")
69
+ fails: list[str] = []
70
+
71
+ with sync_playwright() as p:
72
+ browser = p.chromium.launch()
73
+ ctx = browser.new_context(viewport={"width": w, "height": h}, device_scale_factor=2,
74
+ is_mobile=True, has_touch=True)
75
+ page = ctx.new_page()
76
+ page.goto(f"{base}/?token={token}", wait_until="domcontentloaded", timeout=30000)
77
+ page.wait_for_timeout(3000)
78
+
79
+ # ① 逐个试开会话,直到找到一个**有子代理**的(列表里多数会话没有切换器)
80
+ page.touchscreen.tap(w - 33, 69)
81
+ page.wait_for_timeout(1300)
82
+ labels = page.evaluate(SESSION_LABELS, args.session or "")
83
+ page.touchscreen.tap(w - 30, 500) # 关抽屉
84
+ page.wait_for_timeout(1000)
85
+ if not labels:
86
+ print(f"❌ 会话列表里没有可打开的会话(--session={args.session!r})")
87
+ return 2
88
+ geom, opened = {"err": "未找到含子代理的会话"}, None
89
+ for i in range(args.session_tries):
90
+ page.touchscreen.tap(w - 33, 69) # 开抽屉
91
+ page.wait_for_timeout(1200)
92
+ # ⚠️ 每次都要重新读列表:点开其它工作区的会话会切换工作区,会话列表随之整批变化
93
+ # (按索引点会点错行 —— 必须"读当下这一行的文本 → 再按该文本点")
94
+ rows_now = page.evaluate(SESSION_LABELS, args.session or "")
95
+ if i >= len(rows_now):
96
+ break
97
+ label = rows_now[i]
98
+ clicked = page.evaluate(SESSION_CLICK, label)
99
+ if not clicked:
100
+ continue
101
+ page.wait_for_timeout(1500)
102
+ page.touchscreen.tap(w - 30, 500) # 点 scrim 关抽屉(点会话项不会自动关)
103
+ page.wait_for_timeout(800)
104
+ # 会话历史可能很大(实测 1.1MB,首次渲染要数秒)⇒ 轮询等切换器出现,别固定睡眠
105
+ geom, opened = {"err": "未找到含子代理的会话"}, label
106
+ for _ in range(12):
107
+ geom = page.evaluate(GEOM)
108
+ if not geom.get("err"):
109
+ break
110
+ page.wait_for_timeout(1000)
111
+ if not geom.get("err"):
112
+ break
113
+ print(f" 跳过(无子代理切换器):{label[:40]}")
114
+ if opened:
115
+ print(f"打开会话:{opened[:40]}")
116
+
117
+ # ② 头部切换器几何
118
+ print(f"切换器:{json.dumps(geom, ensure_ascii=False)}")
119
+ if geom.get("err"):
120
+ fails.append(geom["err"])
121
+ else:
122
+ if geom["clipped"]:
123
+ fails.append(f"切换器被 crumbs 裁掉(宽仅 {geom['w']}px,right 超出 crumbs)")
124
+ if geom["tall"]:
125
+ fails.append(f"切换器非单行(高 {geom['h']}px > 40px)")
126
+ if not geom["clickable"]:
127
+ fails.append("切换器中心点不到它(被其它元素覆盖)")
128
+
129
+ # ③ toggle:连点 4 次应为 开/关/开/关
130
+ if not geom.get("err"):
131
+ sw = page.evaluate("""() => { const b = [...document.querySelectorAll('button[class*=ZKlsPq_trigger]')].pop();
132
+ const r = b.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }""")
133
+ seq = []
134
+ for _ in range(4):
135
+ page.touchscreen.tap(sw["x"], sw["y"])
136
+ page.wait_for_timeout(800)
137
+ seq.append(page.evaluate(MENUS))
138
+ print(f"toggle 序列:{seq}(期望 [1,0,1,0])")
139
+ if seq != [1, 0, 1, 0]:
140
+ fails.append(f"点击无法临时开合:{seq}(期望 [1,0,1,0])")
141
+
142
+ # ④ 菜单行可进入子代理(面包屑应变成两段)
143
+ page.touchscreen.tap(sw["x"], sw["y"])
144
+ page.wait_for_timeout(800)
145
+ rows = page.evaluate(ROWS)
146
+ if not rows:
147
+ fails.append("菜单里没有 ZKlsPq_row(上游结构可能已变)")
148
+ else:
149
+ page.touchscreen.tap(rows[0][0], rows[0][1])
150
+ page.wait_for_timeout(3000)
151
+ after = page.evaluate("""() => { const c = document.querySelector('[class*=crumbs]');
152
+ return c ? (c.innerText || '').replace(/\\n/g, ' ').slice(0, 60) : ''; }""")
153
+ print(f"点子代理行后面包屑:{after}")
154
+ if "/" not in after or len(after) <= len((geom.get("crumbsText") or "")):
155
+ fails.append(f"点子代理行没有进入子代理会话(面包屑未变化:{after!r})")
156
+
157
+ # ⑤ 抽屉回归(补丁不得吞掉其它点击)
158
+ page.touchscreen.tap(w - 33, 69)
159
+ page.wait_for_timeout(1200)
160
+ drawer_open = page.evaluate(DRAWER)
161
+ page.touchscreen.tap(w - 30, 500)
162
+ page.wait_for_timeout(1000)
163
+ drawer_closed = page.evaluate(DRAWER)
164
+ print(f"抽屉:开={drawer_open} 关={drawer_closed}")
165
+ if drawer_open.startswith("none") or not drawer_closed.startswith("none"):
166
+ fails.append(f"抽屉开合回归(开={drawer_open} 关={drawer_closed})")
167
+
168
+ browser.close()
169
+
170
+ print()
171
+ if fails:
172
+ print(f"❌ 真实页面回归:{len(fails)} 项不达标")
173
+ for f in fails:
174
+ print(f" - {f}")
175
+ return 1
176
+ print("✅ 真实页面交互全部通过(切换器完整可点、点击可开可收、菜单可进入子代理、抽屉正常)")
177
+ return 0
178
+
179
+
180
+ def main() -> int:
181
+ ap = argparse.ArgumentParser(description="dsh-mobile-ui 真实页面交互探针")
182
+ ap.add_argument("--base-url", default="http://127.0.0.1:3080", help="dsh web 地址")
183
+ ap.add_argument("--token-file", default="~/.dsh/lan-access-token", help="门卫/访问令牌文件")
184
+ ap.add_argument("--session", default=None, help="会话标题片段(默认按列表顺序逐个试)")
185
+ ap.add_argument("--session-tries", type=int, default=6, help="最多试开几个会话以找到含子代理的")
186
+ ap.add_argument("--viewport", default="390x844", help="WxH(默认 390x844,手机档)")
187
+ return run(ap.parse_args())
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())