dsh-mobile-ui 0.2.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/client.js CHANGED
@@ -211,8 +211,47 @@ window.__ModuleLoader__.load({
211
211
  " body.dsh-mobile-ui [class*=avatar]{width:28px !important;height:28px !important}",
212
212
  /* AI 正文与工具行视觉分隔 */
213
213
  " body.dsh-mobile-ui [class*=markdown]{margin-top:6px}",
214
- /* 头部标题允许换行(防截断) */
215
- " 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
+ " }",
216
255
  /* 输入框聚焦反馈 */
217
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))}",
218
257
  /* 消息流操作按钮(复制/反馈/分享)触摸目标提升。
@@ -316,6 +355,52 @@ window.__ModuleLoader__.load({
316
355
  var qaOpen = false; /* 提问卡片打开标志(卡片浮层打开时隐藏右上角菜单,防遮挡标题) */
317
356
  var savedGrid = null; /* 窄屏前的 frame 原始 grid(宽屏恢复用) */
318
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
+
319
404
  /* 主布局 frame:display:grid 的顶层容器(CSS 生效前 3 列 / 生效后 1 列均可) */
320
405
  function findFrame() {
321
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.2.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())
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """dsh-mobile-ui 移动端布局探针:两处可读性/几何回归测试。
2
+ """dsh-mobile-ui 移动端布局探针:三处可读性/几何回归测试(提问卡片 · 输入区操作行 · 会话头部)。
3
3
 
4
4
  ## 一、提问卡片(ask_user_question)
5
5
 
@@ -28,6 +28,23 @@
28
28
  多一个上下文按钮就重叠),肉眼审查 CSS 极难穷举,所以固化成几何测量:
29
29
  真实上游 CSS + 真实 DOM 嵌套 + 真实插件代码,断言「任意两个控件都不重叠」。
30
30
 
31
+ ## 三、会话头部(面包屑 + 子代理切换器)
32
+
33
+ crumbs 是 `overflow:hidden` 的窄条,里面塞着「会话标题 + 子代理切换器」,插件任何作用在
34
+ header/title/crumbs 上的规则都会在这里静默改变结果(2026-09-14 用户报「子代理 UI 文字显示
35
+ 不全 / 上下排列」,一次踩中两个坑):
36
+
37
+ white-space/overflow-wrap 是**继承属性**,crumbs 上游是 nowrap
38
+ 旧插件规则 [class*=crumbs]{white-space:normal;overflow-wrap:anywhere}
39
+ ⇒ 切换器里那个**无类名**文本 span 继承成 normal → 逐字换行(实测 28px → 96px 竖排一列)
40
+
41
+ 上游把标题+切换器+4 个操作按钮塞在一行,390px 下 crumbs 只分到 136px
42
+ ⇒ 标题(最多 220) + 切换器(94) 按比例收缩后被 crumbs 裁掉尾部
43
+ ⇒ 手机上是「[方块] 8」,"个子代理"看不见;被裁区上的点击还落在 headerActions 上
44
+ (实测 elementFromPoint 命中"标准模式")⇒ 点切换器**打不开子代理列表**
45
+
46
+ 断言:切换器完整落在 crumbs 内、单行、中心点真能点到它、与操作区不重叠、按钮不出屏、标题不被压没。
47
+
31
48
  ## 用法
32
49
 
33
50
  python3 tests/mobile-layout-probe.py # 默认测当前安装副本
@@ -35,6 +52,7 @@
35
52
  python3 tests/mobile-layout-probe.py --viewport 320x568 --options 12
36
53
  python3 tests/mobile-layout-probe.py --only composer # 只跑操作行
37
54
  python3 tests/mobile-layout-probe.py --only qa # 只跑提问卡片
55
+ python3 tests/mobile-layout-probe.py --only header # 只跑会话头部(子代理切换器)
38
56
 
39
57
  依赖:playwright(python)+ 已安装的 dsh(用于抽取上游 CSS)。退出码非 0 表示回归。
40
58
  """
@@ -60,6 +78,8 @@ CONV_CSS_RE = r'const css\$4 = ("(?:[^"\\]|\\.)*");'
60
78
  QQ_CSS_RE = r'const css = ("(?:[^"\\]|\\.)*");'
61
79
  # 输入区(InputBar)上游 CSS:uV2eYG 词根
62
80
  INPUTBAR_CSS_RE = r'const css\$1 = ("(?:[^"\\]|\\.)*");'
81
+ # 子代理切换器上游 CSS:ZKlsPq 词根(在 dsh-client-ui-subagent 里是 css$1)
82
+ SUBAGENT_CSS_RE = r'const css\$1 = ("(?:[^"\\]|\\.)*");'
63
83
 
64
84
 
65
85
  def extract_css(path: pathlib.Path, pattern: str, what: str) -> str:
@@ -460,6 +480,170 @@ def probe_composer(plugin, viewport, with_context, with_effort, workdir,
460
480
  }
461
481
 
462
482
 
483
+ HEADER_MEASURE_JS = """() => {
484
+ const R = e => { if (!e) return null; const r = e.getBoundingClientRect();
485
+ return {x: r.x, y: r.y, w: r.width, h: r.height, right: r.right}; };
486
+ const sw = [...document.querySelectorAll('button[class*=ZKlsPq_trigger]')].pop();
487
+ const crumbs = document.querySelector('[class*=wSkVaW_crumbs]');
488
+ // 当前段优先:窄屏下父级段会被插件隐藏(display:none → 宽 0),取它会把标题误判成"被压没"
489
+ const title = document.querySelector('button[class*=wSkVaW_crumbCurrent]')
490
+ || document.querySelector('button[class*=wSkVaW_crumb]');
491
+ const actions = document.querySelector('[class*=wSkVaW_headerActions]');
492
+ const box = sw ? R(sw) : null;
493
+ const hit = [];
494
+ if (box) {
495
+ let e = document.elementFromPoint(box.x + box.w / 2, box.y + box.h / 2);
496
+ while (e && e !== document.body) { hit.push(String(e.className)); if (e === sw) break; e = e.parentElement; }
497
+ }
498
+ const btns = [...document.querySelectorAll('[class*=wSkVaW_titleRow] button')]
499
+ .map(b => Object.assign({label: (b.getAttribute('aria-label') || (b.textContent || '').trim()).slice(0, 14)}, R(b)));
500
+ const cr = R(crumbs), ac = R(actions);
501
+ return {
502
+ switcher: box, crumbs: cr, title: R(title), actions: ac, hitChain: hit,
503
+ // 切换器必须完整落在 crumbs 内(crumbs 是 overflow:hidden —— 超出即被裁成半截)
504
+ clipped: !!(box && cr && box.right > cr.right + 0.5),
505
+ // 中心点必须真的能点到切换器本身(曾因被 headerActions 覆盖而点到"标准模式")
506
+ clickable: hit.some(c => c.indexOf('ZKlsPq_trigger') >= 0),
507
+ // 切换器与右侧操作区不得重叠
508
+ overlap: !!(box && ac && box.x < ac.right && ac.x < box.right),
509
+ // 切换器必须单行:white-space/overflow-wrap 是**继承属性**,crumbs 上游是 nowrap,
510
+ // 插件任何把 normal/anywhere 设到 header/title/crumbs 容器上的规则都会让无类名文本
511
+ // span 逐字换行 —— 实测高度 28 → 96px(用户看到的"竖排一列")。
512
+ tall: !!(box && box.h > 40),
513
+ spill: btns.filter(b => b.right > window.innerWidth + 0.5).map(b => b.label),
514
+ offscreenSwitcher: !!(box && box.w > 0 && (box.x < 0 || box.right > window.innerWidth + 0.5)),
515
+ // 标题至少要留得下可辨认的字符(方案里标题让位给切换器,但不能被压成 0)
516
+ titleW: title ? Math.round(title.getBoundingClientRect().width) : 0,
517
+ btnCount: btns.length,
518
+ };
519
+ }"""
520
+
521
+
522
+ def build_header_page(plugin: pathlib.Path, segments: int = 1) -> str:
523
+ """复现页:真实上游会话头部 CSS + 真实 DOM 嵌套(header > titleRow > titleCluster > crumbs)。
524
+
525
+ segments=1 会话页(面包屑只有当前会话标题);
526
+ segments=2 子代理会话页(父会话 / 子代理名 —— 面包屑更长,空间更紧)。
527
+
528
+ 为什么需要这组:crumbs 是 overflow:hidden 的窄条,里面塞着「会话标题 + 子代理切换器」,
529
+ 插件任何作用在 header/title/crumbs 上的 white-space(继承属性)或宽度规则都会在这里
530
+ 静默改变结果 —— 2026-09-14 实际出过两次:
531
+ ① white-space:normal + overflow-wrap:anywhere 被 crumbs 继承 ⇒ 切换器文字逐字换行成 96px 竖排;
532
+ ② crumbs 被压到 136px,标题+切换器按比例收缩后尾部被裁 ⇒ 手机上是「[方块] 8」,
533
+ 且点击落在右侧 headerActions 上(打不开子代理列表)。
534
+ """
535
+ conv_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", CONV_CSS_RE, "ConversationRoot")
536
+ sub_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-subagent/lib/client.js", SUBAGENT_CSS_RE, "SubagentSwitcher")
537
+
538
+ long_title = "miniqmt月底要禁用了,可以通过改造大qmt来平替原先在trade pc上的quant qmt proxy服务吗"
539
+ first_seg = (
540
+ f'<button type="button" class="wSkVaW_crumb">{long_title[:14]}</button>'
541
+ f'<span class="wSkVaW_crumbSep">/</span>'
542
+ if segments > 1 else ""
543
+ )
544
+ current = long_title if segments == 1 else "Proxy改造点研究"
545
+
546
+ return f"""<!doctype html>
547
+ <html lang="zh-CN"><head><meta charset="utf-8">
548
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
549
+ <title>header-probe</title>
550
+ <style>
551
+ :root{{
552
+ --dsw-alias-bg-base:#16171b; --dsw-alias-bg-layer-1:#1e1f24; --dsw-specific-menu:#2a2b31;
553
+ --dsw-specific-selector:#33343a; --dsw-alias-label-primary:rgba(255,255,255,.9);
554
+ --dsw-alias-label-secondary:rgba(255,255,255,.7); --dsw-alias-label-tertiary:rgba(255,255,255,.5);
555
+ --dsw-alias-label-caption:rgba(255,255,255,.4); --dsw-alias-label-dimmed:rgba(255,255,255,.3);
556
+ --dsw-alias-border-l2:rgba(255,255,255,.14); --dsw-alias-border-l3:rgba(255,255,255,.2);
557
+ --dsw-alias-interactive-bg-hover:rgba(255,255,255,.08);
558
+ --dsw-alias-state-error-primary:#ff6b6b; --dsw-alias-brand-primary:#4f7cff;
559
+ --dsh-conversation-column-width:100%;
560
+ }}
561
+ html,body{{margin:0;padding:0;height:100%;background:var(--dsw-alias-bg-base);color:#eee;
562
+ font-family:-apple-system,"PingFang SC",system-ui,sans-serif}}
563
+ {conv_css}
564
+ {sub_css}
565
+ #app{{height:100%;display:grid;grid-template-columns:260px minmax(0,1fr) 0px}}
566
+ .sidebarCol{{background:#1a1b1f}}
567
+ .wSkVaW_body{{flex:1 1 auto}}
568
+ .hdrBtn{{height:28px;padding:0;border:none;border-radius:8px;background:rgba(255,255,255,.06);color:inherit}}
569
+ </style></head>
570
+ <body>
571
+ <div id="app" class="frame">
572
+ <div class="sidebarCol" data-testid="sidebar">sidebar</div>
573
+ <div class="wSkVaW_root">
574
+ <header class="wSkVaW_header">
575
+ <div class="wSkVaW_titleRow">
576
+ <div class="wSkVaW_titleCluster">
577
+ <nav class="wSkVaW_crumbs" aria-label="会话层级">
578
+ <span class="wSkVaW_crumbSeg">
579
+ {first_seg}
580
+ <button type="button" class="wSkVaW_crumb wSkVaW_crumbCurrent">{current}</button>
581
+ <div style="display:contents">
582
+ <div class="ZKlsPq_root">
583
+ <span class="ZKlsPq_separator">/</span>
584
+ <button type="button" class="ZKlsPq_trigger" aria-haspopup="tree" aria-expanded="false"
585
+ aria-label="8 个子代理,正在运行">
586
+ <span class="ZKlsPq_activitySlot"></span>
587
+ <span>8 个子代理</span>
588
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
589
+ <path d="M3 5l4 4 4-4" fill="none" stroke="currentColor"></path></svg>
590
+ </button>
591
+ </div>
592
+ </div>
593
+ </span>
594
+ </nav>
595
+ <div class="wSkVaW_headerActions">
596
+ <button type="button" class="hdrBtn" style="width:68px" aria-label="标准模式">标准模式</button>
597
+ </div>
598
+ </div>
599
+ <div class="wSkVaW_headerUtilities">
600
+ <button type="button" class="hdrBtn" style="width:52px" aria-label="在 GNOME Terminal 中打开工作目录">T</button>
601
+ <button type="button" class="hdrBtn" style="width:28px" aria-label="更多操作">M</button>
602
+ </div>
603
+ <div class="wSkVaW_headerCorner">
604
+ <button type="button" class="hdrBtn" style="width:28px" aria-label="打开右侧边栏">C</button>
605
+ </div>
606
+ </div>
607
+ <div class="wSkVaW_tabs">
608
+ <button type="button" class="wSkVaW_tab wSkVaW_tabActive">对话</button>
609
+ <button type="button" class="wSkVaW_tab">轨迹</button>
610
+ </div>
611
+ </header>
612
+ <div class="wSkVaW_body"></div>
613
+ </div>
614
+ </div>
615
+ <script>window.__ModuleLoader__={{mode:"queue",load:function(reg){{window.__reg=reg}}}};</script>
616
+ </body></html>"""
617
+
618
+
619
+ def probe_header(plugin, viewport, segments, workdir):
620
+ """会话头部几何:切换器必须完整、可点、不与操作区重叠。"""
621
+ from playwright.sync_api import sync_playwright
622
+
623
+ w, h = viewport
624
+ html = build_header_page(plugin, segments)
625
+ page_path = workdir / f"probe-header-{segments}-{w}x{h}.html"
626
+ page_path.write_text(html, encoding="utf-8")
627
+
628
+ with sync_playwright() as p:
629
+ browser = p.chromium.launch()
630
+ ctx = browser.new_context(
631
+ viewport={"width": w, "height": h}, device_scale_factor=2, is_mobile=True, has_touch=True
632
+ )
633
+ page = ctx.new_page()
634
+ page.goto(page_path.as_uri())
635
+ apply_plugin(page, plugin)
636
+ page.wait_for_timeout(400)
637
+ m = page.evaluate(HEADER_MEASURE_JS)
638
+ browser.close()
639
+
640
+ m.update({
641
+ "viewport": f"{w}x{h}",
642
+ "variant": "会话页" if segments == 1 else "子代理会话页",
643
+ })
644
+ return m
645
+
646
+
463
647
  def probe(plugin, viewport, options, detail_paras, workdir):
464
648
  from playwright.sync_api import sync_playwright
465
649
 
@@ -525,7 +709,7 @@ def main() -> int:
525
709
  ap.add_argument("--viewport", action="append", default=None, help="WxH,可重复(默认覆盖常见机型)")
526
710
  ap.add_argument("--options", type=int, action="append", default=None, help="选项数量,可重复")
527
711
  ap.add_argument("--detail-paras", type=int, default=2, help="detail 长文本段落数")
528
- ap.add_argument("--only", choices=["qa", "composer"], default=None, help="只跑其中一组")
712
+ ap.add_argument("--only", choices=["qa", "composer", "header"], default=None, help="只跑其中一组")
529
713
  ap.add_argument("--json", action="store_true", help="输出 JSON")
530
714
  args = ap.parse_args()
531
715
 
@@ -536,7 +720,7 @@ def main() -> int:
536
720
  viewports = [tuple(int(x) for x in v.lower().split("x")) for v in (args.viewport or ["320x568", "360x640", "390x844", "414x896"])]
537
721
  options_list = args.options or [1, 3, 6, 12]
538
722
 
539
- qa_results, composer_results = [], []
723
+ qa_results, composer_results, header_results = [], [], []
540
724
  with tempfile.TemporaryDirectory(prefix="dsh-mobile-probe-") as tmp:
541
725
  wd = pathlib.Path(tmp)
542
726
  if args.only in (None, "qa"):
@@ -552,6 +736,11 @@ def main() -> int:
552
736
  for vp in viewports[:2]:
553
737
  composer_results.append(probe_composer(
554
738
  plugin, vp, True, True, wd, model_label="DeepSeek V4.1 Flash"))
739
+ if args.only in (None, "header"):
740
+ # 会话头部:面包屑里的子代理切换器必须完整可见且可点(两种面包屑长度)
741
+ for vp in viewports:
742
+ for seg in (1, 2):
743
+ header_results.append(probe_header(plugin, vp, seg, wd))
555
744
 
556
745
  qa_failures = [
557
746
  r for r in qa_results
@@ -590,13 +779,20 @@ def main() -> int:
590
779
  # 行内垂直中心离散 ≤1.5px(发送键上游 translateY(-2px) 曾致偏移)
591
780
  or any(s > 1.5 for s in (r.get("lineSpreads") or [0]))
592
781
  ]
782
+ # 会话头部:切换器被裁 / 点不到 / 与操作区重叠 / 按钮出屏 / 标题被压没
783
+ header_failures = [
784
+ r for r in header_results
785
+ if not r.get("switcher") or r.get("clipped") or not r.get("clickable")
786
+ or r.get("overlap") or r.get("spill") or r.get("offscreenSwitcher") or r.get("tall")
787
+ or (r.get("titleW") or 0) < 60
788
+ ]
593
789
 
594
790
  if args.json:
595
791
  print(json.dumps({
596
- "ok": not qa_failures and not composer_failures,
597
- "qa": qa_results, "composer": composer_results,
792
+ "ok": not qa_failures and not composer_failures and not header_failures,
793
+ "qa": qa_results, "composer": composer_results, "header": header_results,
598
794
  }, ensure_ascii=False, indent=2))
599
- return 1 if (qa_failures or composer_failures) else 0
795
+ return 1 if (qa_failures or composer_failures or header_failures) else 0
600
796
 
601
797
  print(f"被测插件: {plugin}")
602
798
 
@@ -629,8 +825,24 @@ def main() -> int:
629
825
  f"{'✓' if r.get('accessIconShown') else '✗':<10} {perm_txt:<10} {ell:<9} "
630
826
  f"{'✓' if not r.get('misaligned') else '✗':<6}")
631
827
 
828
+ if header_results:
829
+ print()
830
+ print("── 会话头部(面包屑 + 子代理切换器)──")
831
+ print(f"{'视口':<10} {'形态':<14} {'切换器完整':<11} {'单行':<6} {'可点':<6} {'不重叠':<7} "
832
+ f"{'不出屏':<7} {'标题宽':<7} {'切换器宽':<8}")
833
+ print("-" * 82)
834
+ for r in header_results:
835
+ sw = r.get("switcher") or {}
836
+ print(f"{r['viewport']:<10} {r['variant']:<14} "
837
+ f"{'✓' if not r.get('clipped') else '✗':<12} "
838
+ f"{'✓' if not r.get('tall') else '✗':<7} "
839
+ f"{'✓' if r.get('clickable') else '✗':<7} "
840
+ f"{'✓' if not r.get('overlap') else '✗':<8} "
841
+ f"{'✓' if not r.get('spill') else '✗':<8} "
842
+ f"{r.get('titleW', 0):<8} {round(sw.get('w', 0)):<8}")
843
+
632
844
  print()
633
- if qa_failures or composer_failures:
845
+ if qa_failures or composer_failures or header_failures:
634
846
  if qa_failures:
635
847
  print(f"❌ 提问卡片回归:{len(qa_failures)} 个用例不达标(选项被裁 / 滚动失效 / 提交按钮不可见)")
636
848
  for r in qa_failures:
@@ -674,6 +886,29 @@ def main() -> int:
674
886
  if r.get("count", 0) < 2:
675
887
  why.append(f"控件数异常={r.get('count')}")
676
888
  print(f" - {r['viewport']} {r.get('variant')}: " + ";".join(why or ["未知"]))
889
+ if header_failures:
890
+ print(f"❌ 会话头部回归:{len(header_failures)} 个用例不达标(子代理切换器被裁/点不到)")
891
+ for r in header_failures:
892
+ why = []
893
+ if not r.get("switcher"):
894
+ why.append("页面里找不到 ZKlsPq_trigger(上游类名或结构已变,选择器需复核)")
895
+ if r.get("clipped"):
896
+ why.append(f"切换器被 crumbs 裁掉( crumbs 右 {round((r.get('crumbs') or {}).get('right', 0))}"
897
+ f" < 切换器右 {round((r.get('switcher') or {}).get('right', 0))})")
898
+ if not r.get("clickable"):
899
+ why.append(f"切换器中心点不到它(命中链={r.get('hitChain')})")
900
+ if r.get("tall"):
901
+ why.append(f"切换器不是单行(高 {round((r.get('switcher') or {}).get('h', 0))}px > 40px,"
902
+ f"white-space 被继承污染 → 文字逐字换行)")
903
+ if r.get("overlap"):
904
+ why.append("切换器与 headerActions 重叠")
905
+ if r.get("spill"):
906
+ why.append(f"按钮越出视口={r['spill']}")
907
+ if r.get("offscreenSwitcher"):
908
+ why.append("切换器整体出屏")
909
+ if (r.get("titleW") or 0) < 60:
910
+ why.append(f"会话标题被压没({r.get('titleW')}px < 60px)")
911
+ print(f" - {r['viewport']} {r.get('variant')}: " + ";".join(why or ["未知"]))
677
912
  return 1
678
913
 
679
914
  parts = []
@@ -682,6 +917,9 @@ def main() -> int:
682
917
  if composer_results:
683
918
  parts.append(f"操作行 {len(composer_results)} 个(互不重叠/不越界/可点击、"
684
919
  f"权限图标常显、被裁模型名走省略号、图标文字垂直对齐)")
920
+ if header_results:
921
+ parts.append(f"会话头部 {len(header_results)} 个(子代理切换器完整可见且单行、"
922
+ f"中心点可点、与操作区不重叠、按钮不出屏、标题不被压没)")
685
923
  print("✅ 全部通过:" + ";".join(parts))
686
924
  return 0
687
925