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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- """dsh-mobile-ui 移动端布局探针:提问卡片(ask_user_question)可读性回归测试。
2
+ """dsh-mobile-ui 移动端布局探针:三处可读性/几何回归测试(提问卡片 · 输入区操作行 · 会话头部)。
3
3
 
4
- ## 为什么需要这个探针
4
+ ## 一、提问卡片(ask_user_question)
5
5
 
6
6
  提问卡片曾在手机上出现「选项看不全 + 手指滑动也没反应」的故障(2026-09-11 定位修复)。
7
7
  根因不是样式不生效,而是**覆盖了上游唯一的滚动容器**:
@@ -13,15 +13,46 @@
13
13
  ② body 不再是滚动容器,③ 覆盖层里也没有可滚内容
14
14
  ⇒ 手指滑动零反应,后面的选项永久不可见
15
15
 
16
- 这类故障**静默且只在内容溢出时出现**(1~3 个选项时一切正常),靠肉眼审查 CSS 极难发现,
17
- 所以固化成几何测量:在真实上游 CSS + 真实 DOM 嵌套 + 真实插件代码下,
18
- 用 Playwright 断言「正文可滚 / 滚到底末项可见 / 真实触摸滑动后末项可见 / 提交按钮始终可见」。
16
+ ## 二、输入区操作行(composer row)
17
+
18
+ 操作行曾出现「模型选择器压住上下文环 / 访问模式按钮压住模型选择器」(2026-09-11 定位修复)。
19
+ 根因是**两条插件规则互相配合出的溢出**:
20
+
21
+ 旧插件规则 [class*=uV2eYG_row]{flex-wrap:nowrap} ← 禁止换行
22
+ 旧插件规则 [class*=uV2eYG_row] > *{flex:0 1 auto} ← 组可收缩
23
+ 上游组内按钮 min-width:44px ← 但按钮不可压缩
24
+ ⇒ 行宽不足时 flex 无法通过收缩解决,又禁止换行 ⇒ 各控件**溢出重叠**
25
+ (实测 390px 会话页:访问模式↔模型重叠 5.7px、模型↔上下文重叠 4.3px)
26
+
27
+ 这类故障同样**静默且只在特定宽度/控件数下出现**(hero 页 4 控件正常、会话页
28
+ 多一个上下文按钮就重叠),肉眼审查 CSS 极难穷举,所以固化成几何测量:
29
+ 真实上游 CSS + 真实 DOM 嵌套 + 真实插件代码,断言「任意两个控件都不重叠」。
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 内、单行、中心点真能点到它、与操作区不重叠、按钮不出屏、标题不被压没。
19
47
 
20
48
  ## 用法
21
49
 
22
50
  python3 tests/mobile-layout-probe.py # 默认测当前安装副本
23
51
  python3 tests/mobile-layout-probe.py --plugin ./client.js
24
52
  python3 tests/mobile-layout-probe.py --viewport 320x568 --options 12
53
+ python3 tests/mobile-layout-probe.py --only composer # 只跑操作行
54
+ python3 tests/mobile-layout-probe.py --only qa # 只跑提问卡片
55
+ python3 tests/mobile-layout-probe.py --only header # 只跑会话头部(子代理切换器)
25
56
 
26
57
  依赖:playwright(python)+ 已安装的 dsh(用于抽取上游 CSS)。退出码非 0 表示回归。
27
58
  """
@@ -45,6 +76,10 @@ DEFAULT_PLUGIN = pathlib.Path.home() / ".dsh/plugins/dsh-mobile-ui/client.js"
45
76
 
46
77
  CONV_CSS_RE = r'const css\$4 = ("(?:[^"\\]|\\.)*");'
47
78
  QQ_CSS_RE = r'const css = ("(?:[^"\\]|\\.)*");'
79
+ # 输入区(InputBar)上游 CSS:uV2eYG 词根
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 = ("(?:[^"\\]|\\.)*");'
48
83
 
49
84
 
50
85
  def extract_css(path: pathlib.Path, pattern: str, what: str) -> str:
@@ -197,6 +232,418 @@ def apply_plugin(page, plugin: pathlib.Path) -> None:
197
232
  )
198
233
 
199
234
 
235
+ def build_composer_page(plugin: pathlib.Path, with_context: bool, with_effort: bool,
236
+ model_label: str = "commandcode/deepseek/deepseek-v4.1-flash") -> str:
237
+ """复现页:真实上游 InputBar CSS + 真实 DOM 嵌套(composerSeat > stack > root > card > row > tools/trailing)。
238
+
239
+ with_context: 会话页有「上下文已用」按钮(hero 页没有)——正是多这一个控件才触发重叠。
240
+ with_effort: 模型名带推理等级后缀(triggerEffort),会额外占宽。
241
+ model_label: 模型显示名。默认用超长 provider/model(最坏情况,考验省略号);
242
+ 短名(目录内模型的友好名)用于对照正常观感。
243
+ """
244
+ conv_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", CONV_CSS_RE, "ConversationRoot")
245
+ input_css = extract_css(
246
+ DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", INPUTBAR_CSS_RE, "InputBar")
247
+
248
+ effort = '<span class="_7KE1Ra_triggerEffort">Max</span>' if with_effort else ""
249
+ ctx_btn = (
250
+ '<span class="JObwrW_root"><button type="button" class="JObwrW_trigger" '
251
+ 'aria-label="上下文已用 18%" aria-haspopup="dialog"><svg viewBox="0 0 14 14" width="14" height="14">'
252
+ '<circle class="JObwrW_track" cx="7" cy="7" r="5"></circle>'
253
+ '<circle class="JObwrW_fill" cx="7" cy="7" r="5" stroke-dasharray="5.6 31.4" '
254
+ 'transform="rotate(-90 7 7)"></circle></svg></button></span>'
255
+ if with_context else ""
256
+ )
257
+
258
+ return f"""<!doctype html>
259
+ <html lang="zh-CN"><head><meta charset="utf-8">
260
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
261
+ <title>composer-row-probe</title>
262
+ <style>
263
+ :root{{
264
+ --dsw-alias-bg-base:#16171b; --dsw-alias-bg-layer-1:#1e1f24; --dsw-specific-input-major:#26272d;
265
+ --dsw-specific-menu:#2a2b31; --dsw-specific-selector:#33343a;
266
+ --dsw-alias-label-primary:rgba(255,255,255,.9); --dsw-alias-label-secondary:rgba(255,255,255,.7);
267
+ --dsw-alias-label-tertiary:rgba(255,255,255,.5); --dsw-alias-label-caption:rgba(255,255,255,.4);
268
+ --dsw-alias-border-l2:rgba(255,255,255,.14); --dsw-alias-border-l3:rgba(255,255,255,.2);
269
+ --dsw-alias-interactive-bg-hover:rgba(255,255,255,.08);
270
+ --dsh-composer-side-clearance:12px; --dsh-composer-card-max-width:720px;
271
+ --dsh-composer-text-max-height:200px; --dsh-content-font-size:14px;
272
+ --dsh-conversation-column-width:100%;
273
+ }}
274
+ html,body{{margin:0;padding:0;height:100%;background:var(--dsw-alias-bg-base);color:#eee;
275
+ font-family:-apple-system,"PingFang SC",system-ui,sans-serif}}
276
+ {conv_css}
277
+ {input_css}
278
+ #app{{height:100%;display:grid;grid-template-columns:260px minmax(0,1fr) 0px}}
279
+ .sidebarCol{{background:#1a1b1f}} .detailsCol{{background:#1a1b1f}}
280
+ </style></head>
281
+ <body>
282
+ <div id="app" class="frame">
283
+ <div class="sidebarCol" data-testid="sidebar">sidebar</div>
284
+ <div class="wSkVaW_root" data-phase="active">
285
+ <div class="wSkVaW_header" style="padding:8px 12px">会话标题</div>
286
+ <div class="wSkVaW_body">
287
+ <div class="wSkVaW_scrollBody" data-conversation-scroll>
288
+ <div data-slot="conversation.session"><div class="wSkVaW_viewArea">&nbsp;</div></div>
289
+ <div class="wSkVaW_composerSeat" data-composer-seat>
290
+ <div class="wSkVaW_composerStack">
291
+ <div class="uV2eYG_root">
292
+ <div class="uV2eYG_card">
293
+ <div class="uV2eYG_scroll"><div class="uV2eYG_grow">
294
+ <div class="uV2eYG_input" contenteditable="true" aria-label="发消息"></div>
295
+ </div></div>
296
+ <div class="uV2eYG_row">
297
+ <div class="uV2eYG_tools">
298
+ <button type="button" class="uV2eYG_add" aria-label="指令">
299
+ <svg viewBox="0 0 16 16" width="14" height="14"><path d="M8 3v10M3 8h10" stroke="currentColor" fill="none"/></svg></button>
300
+ <button type="button" class="uV2eYG_add" aria-label="添加附件">
301
+ <svg viewBox="0 0 16 16" width="14" height="14"><path d="M5 8h6" stroke="currentColor" fill="none"/></svg></button>
302
+ <div class="uV2eYG_modes">
303
+ <button type="button" class="Sh0Q9G_trigger" aria-label="访问模式,当前:完全权限"
304
+ ><span class="Sh0Q9G_triggerIcon"><svg viewBox="0 0 16 16" width="14" height="14"><path d="M8 1l6 3v4c0 4-3 6-6 7-3-1-6-3-6-7V4z" fill="none" stroke="currentColor"/></svg></span><span class="Sh0Q9G_triggerLabel">完全权限</span></button>
305
+ </div>
306
+ </div>
307
+ <div class="uV2eYG_trailing">
308
+ <div class="_7KE1Ra_root">
309
+ <button type="button" class="_7KE1Ra_trigger" aria-label="选择模型,当前 {model_label}" aria-haspopup="menu">
310
+ <svg class="_7KE1Ra_triggerIcon" viewBox="0 0 16 16" width="16" height="16"><path d="M2 8h12" stroke="currentColor" fill="none"/></svg>
311
+ <span class="_7KE1Ra_triggerLabel">{model_label}</span>
312
+ {effort}
313
+ <svg class="_7KE1Ra_chevron" viewBox="0 0 14 14" width="14" height="14"><path d="M4 6l3 3 3-3" stroke="currentColor" fill="none"/></svg>
314
+ </button>
315
+ </div>
316
+ {ctx_btn}
317
+ <button type="button" class="uV2eYG_primary" aria-label="发送消息">
318
+ <svg viewBox="0 0 16 16" width="16" height="16"><path d="M8 13V3M4 7l4-4 4 4" stroke="currentColor" fill="none"/></svg></button>
319
+ </div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </div>
328
+ <div class="detailsCol" data-testid="details">details</div>
329
+ </div>
330
+ <script>window.__ModuleLoader__={{mode:"queue",load:function(reg){{window.__reg=reg}}}};</script>
331
+ <script src="file://{plugin}"></script>
332
+ <script>window.__plug=window.__reg;window.__plug.factory(function(){{}});</script>
333
+ </body></html>"""
334
+
335
+
336
+ COMPOSER_MEASURE_JS = r"""
337
+ () => {
338
+ const row = document.querySelector('[class*=uV2eYG_row]');
339
+ if (!row) return {err: 'no composer row'};
340
+ const btns = [...row.querySelectorAll('button')].map(b => {
341
+ const r = b.getBoundingClientRect();
342
+ return {aria: (b.getAttribute('aria-label')||'').slice(0, 24),
343
+ x: +r.x.toFixed(1), y: +r.y.toFixed(1), right: +r.right.toFixed(1),
344
+ bottom: +r.bottom.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1)};
345
+ });
346
+ // 任意两个控件重叠 = 回归(正是 2026-09-11 的故障形态)
347
+ const overlaps = [];
348
+ for (let i = 0; i < btns.length; i++) for (let j = i + 1; j < btns.length; j++) {
349
+ const a = btns[i], b = btns[j];
350
+ const ox = Math.min(a.right, b.right) - Math.max(a.x, b.x);
351
+ const oy = Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y);
352
+ if (ox > 0.5 && oy > 0.5) overlaps.push({a: a.aria, b: b.aria, ox: +ox.toFixed(1)});
353
+ }
354
+ const card = document.querySelector('[class*=uV2eYG_card]');
355
+ const cardRight = card ? +card.getBoundingClientRect().right.toFixed(1) : null;
356
+ // 控件越出输入卡右边界 = 溢出(窄屏下被裁掉,点不到)
357
+ const spill = cardRight === null ? [] : btns
358
+ .filter(b => b.right > cardRight + 0.5)
359
+ .map(b => ({aria: b.aria, over: +(b.right - cardRight).toFixed(1)}));
360
+ const rowBox = row.getBoundingClientRect();
361
+
362
+ // ── 子元素可见性断言(2026-09-11 两个静默故障的守卫)──
363
+ const shown = (sel) => { const el = document.querySelector(sel);
364
+ return el ? getComputedStyle(el).display !== 'none' : null; };
365
+ const accessIconShown = shown('button[aria-label*=访问模式] [class*=triggerIcon]');
366
+ // 权限文字只在行够宽时出现;出现时必须真的可见(不能是 0 宽空壳)
367
+ const accessLabelEl = document.querySelector('button[aria-label*=访问模式] [class*=triggerLabel]');
368
+ const accessLabelShown = accessLabelEl ? getComputedStyle(accessLabelEl).display !== 'none' : false;
369
+ const accessLabelW = accessLabelEl && accessLabelShown
370
+ ? +accessLabelEl.getBoundingClientRect().width.toFixed(1) : 0;
371
+ // 推理等级后缀移动端必须隐藏(否则被压成 8px 宽的半截字符)
372
+ const effortShown = shown('button[aria-label*=选择模型] [class*=triggerEffort]');
373
+
374
+ // 省略号是否真的生效:被裁的 label 必须 overflow:hidden + text-overflow:ellipsis
375
+ // (flex 容器会忽略 text-overflow → 硬切出半个字符,正是用户看到的"半截字母")
376
+ const label = document.querySelector('button[aria-label*=选择模型] [class*=_7KE1Ra_triggerLabel]');
377
+ let ellipsisOk = true, modelLabelClipped = false, modelLabelW = null;
378
+ if (label) {
379
+ const lcs = getComputedStyle(label);
380
+ const shown = lcs.display !== 'none';
381
+ modelLabelW = shown ? +label.getBoundingClientRect().width.toFixed(1) : 0;
382
+ modelLabelClipped = label.scrollWidth > label.clientWidth + 1;
383
+ if (modelLabelClipped) {
384
+ ellipsisOk = lcs.overflow === 'hidden' && lcs.textOverflow === 'ellipsis'
385
+ && lcs.display !== 'flex';
386
+ }
387
+ }
388
+
389
+ // ── 每行右侧空块宽度(2026-09-11 用户反馈"上面空了一块"的守卫)──
390
+ // 旧排布把工具组独占第一行,右侧留白 183px(412px 实测)→ 一眼看出"空了一块"。
391
+ // 判据:每行最右控件的右缘到卡片右缘的空白不得超过 60px(约一个按钮宽)。
392
+ // ── 视觉一致性(2026-09-11 用户反馈"高度不一致、怪怪的"的守卫)──
393
+ // 同一操作行内所有按钮必须等高、同字号,且行内垂直中心离散 ≤1.5px
394
+ const heights = [...new Set(btns.map(b => Math.round(b.h)))];
395
+ const fonts = [...new Set([...row.querySelectorAll('button')]
396
+ .map(b => getComputedStyle(b).fontSize))];
397
+ // 按 top 分簇(不能用中心 y:控件 44px 高、两行中心仅差 50px,
398
+ // 阈值稍大就会把两行并成一簇。等高后同行 top 必然相同,故对 top 聚类最稳)
399
+ const tops = btns.map(b => b.y).sort((a, b) => a - b);
400
+ const clusters = [];
401
+ for (const ty of tops) {
402
+ if (!clusters.length || ty - clusters[clusters.length - 1][0] > 6) clusters.push([ty]);
403
+ else clusters[clusters.length - 1].push(ty);
404
+ }
405
+ const lineSpreads = clusters.map(c => +(Math.max(...c) - Math.min(...c)).toFixed(1));
406
+ // 每行右侧空块(行最右控件右缘 → 卡片右缘)
407
+ // ⚠️ clusters 的每个元素是「该行 top 的数组」,取 [0] 才是行 top
408
+ //(曾误把整个数组当数值比较,导致空块恒算出 0、守卫静默失效)
409
+ const lineRightGaps = cardRight === null ? [] : clusters.map(cluster => {
410
+ const lineTop = cluster[0];
411
+ const rowBtns = btns.filter(b => Math.abs(b.y - lineTop) <= 6);
412
+ return rowBtns.length ? +(cardRight - Math.max(...rowBtns.map(b => b.right))).toFixed(1) : 0;
413
+ });
414
+
415
+ // 垂直对齐:label / chevron / 图标中心相对按钮中心的偏差 ≤1.5px
416
+ const cy = (el) => { const b = el.getBoundingClientRect(); return b.y + b.height / 2; };
417
+ const misaligned = [];
418
+ for (const [name, btnSel, innerSel] of [
419
+ ['模型名', 'button[aria-label*=选择模型]', '[class*=_7KE1Ra_triggerLabel]'],
420
+ ['模型箭头', 'button[aria-label*=选择模型]', '[class*=_7KE1Ra_chevron]'],
421
+ ['权限图标', 'button[aria-label*=访问模式]', '[class*=triggerIcon]']]) {
422
+ const btn = document.querySelector(btnSel), el = document.querySelector(innerSel);
423
+ if (!btn || !el || getComputedStyle(el).display === 'none') continue;
424
+ const dev = Math.abs(cy(el) - cy(btn));
425
+ if (dev > 1.5) misaligned.push({name, dev: +dev.toFixed(1)});
426
+ }
427
+
428
+ return {
429
+ rowH: +rowBox.height.toFixed(1), rowW: +rowBox.width.toFixed(1),
430
+ count: btns.length,
431
+ minW: btns.length ? Math.min(...btns.map(b => b.w)) : 0,
432
+ overlaps, spill,
433
+ accessIconShown, accessLabelShown, accessLabelW, effortShown,
434
+ modelLabelClipped, ellipsisOk, misaligned, modelLabelW,
435
+ heights, fonts, lineSpreads, lineRightGaps,
436
+ // 控件是否都可点(中心点命中自己)
437
+ unclickable: btns.filter(b => {
438
+ const cx = b.x + b.w / 2, cy2 = b.y + b.h / 2;
439
+ const hit = document.elementFromPoint(cx, cy2);
440
+ const el = [...row.querySelectorAll('button')].find(x => {
441
+ const r = x.getBoundingClientRect();
442
+ return Math.abs(r.x - b.x) < .5 && Math.abs(r.width - b.w) < .5; });
443
+ return !hit || !el || !(hit === el || el.contains(hit) || hit.contains(el));
444
+ }).map(b => b.aria),
445
+ };
446
+ }
447
+ """
448
+
449
+
450
+ def probe_composer(plugin, viewport, with_context, with_effort, workdir,
451
+ model_label="commandcode/deepseek/deepseek-v4.1-flash"):
452
+ """操作行几何探针:断言控件互不重叠、不越界、可点击,且关键子元素该显示的都在。"""
453
+ from playwright.sync_api import sync_playwright
454
+
455
+ w, h = viewport
456
+ html = build_composer_page(plugin, with_context, with_effort, model_label)
457
+ tag = (f"composer-{'sess' if with_context else 'hero'}-{'eff' if with_effort else 'noeff'}"
458
+ f"-{'long' if len(model_label) > 20 else 'short'}-{w}x{h}")
459
+ page_path = workdir / f"{tag}.html"
460
+ page_path.write_text(html, encoding="utf-8")
461
+
462
+ with sync_playwright() as p:
463
+ browser = p.chromium.launch()
464
+ ctx = browser.new_context(
465
+ viewport={"width": w, "height": h}, device_scale_factor=2, is_mobile=True, has_touch=True
466
+ )
467
+ page = ctx.new_page()
468
+ page.goto(page_path.as_uri())
469
+ apply_plugin(page, plugin)
470
+ page.wait_for_timeout(500)
471
+ m = page.evaluate(COMPOSER_MEASURE_JS)
472
+ browser.close()
473
+
474
+ return {
475
+ "viewport": f"{w}x{h}",
476
+ "variant": ("会话页(含上下文)" if with_context else "首页(hero)")
477
+ + ("/长模型名" if len(model_label) > 20 else "/短模型名"),
478
+ "effort": with_effort,
479
+ **m,
480
+ }
481
+
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
+
200
647
  def probe(plugin, viewport, options, detail_paras, workdir):
201
648
  from playwright.sync_api import sync_playwright
202
649
 
@@ -257,11 +704,12 @@ def probe(plugin, viewport, options, detail_paras, workdir):
257
704
 
258
705
 
259
706
  def main() -> int:
260
- ap = argparse.ArgumentParser(description="dsh-mobile-ui 提问卡片移动端布局探针")
707
+ ap = argparse.ArgumentParser(description="dsh-mobile-ui 移动端布局探针(提问卡片 + 输入区操作行)")
261
708
  ap.add_argument("--plugin", default=str(DEFAULT_PLUGIN), help="被测 client.js 路径")
262
709
  ap.add_argument("--viewport", action="append", default=None, help="WxH,可重复(默认覆盖常见机型)")
263
710
  ap.add_argument("--options", type=int, action="append", default=None, help="选项数量,可重复")
264
711
  ap.add_argument("--detail-paras", type=int, default=2, help="detail 长文本段落数")
712
+ ap.add_argument("--only", choices=["qa", "composer", "header"], default=None, help="只跑其中一组")
265
713
  ap.add_argument("--json", action="store_true", help="输出 JSON")
266
714
  args = ap.parse_args()
267
715
 
@@ -272,39 +720,207 @@ def main() -> int:
272
720
  viewports = [tuple(int(x) for x in v.lower().split("x")) for v in (args.viewport or ["320x568", "360x640", "390x844", "414x896"])]
273
721
  options_list = args.options or [1, 3, 6, 12]
274
722
 
275
- results = []
723
+ qa_results, composer_results, header_results = [], [], []
276
724
  with tempfile.TemporaryDirectory(prefix="dsh-mobile-probe-") as tmp:
277
- for vp in viewports:
278
- for n in options_list:
279
- results.append(probe(plugin, vp, n, args.detail_paras, pathlib.Path(tmp)))
280
-
281
- failures = [
282
- r for r in results
725
+ wd = pathlib.Path(tmp)
726
+ if args.only in (None, "qa"):
727
+ for vp in viewports:
728
+ for n in options_list:
729
+ qa_results.append(probe(plugin, vp, n, args.detail_paras, wd))
730
+ if args.only in (None, "composer"):
731
+ # 会话页(含上下文按钮)+ 首页 hero;模型名分超长(provider/model 兜底)与短名两种,
732
+ # 都要过:带推理等级后缀更宽,长名考验省略号。
733
+ for vp in viewports:
734
+ for with_ctx in (True, False):
735
+ composer_results.append(probe_composer(plugin, vp, with_ctx, True, wd))
736
+ for vp in viewports[:2]:
737
+ composer_results.append(probe_composer(
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))
744
+
745
+ qa_failures = [
746
+ r for r in qa_results
283
747
  if not r["last_visible_after_swipe"] or not r["submit_visible"]
284
748
  or not r["menu_btn_hidden_while_qa"] or (r["needs_scroll"] and not r["scrollable"])
285
749
  ]
750
+ composer_failures = [
751
+ r for r in composer_results
752
+ if r.get("err") or r.get("overlaps") or r.get("spill") or r.get("unclickable")
753
+ or r.get("count", 0) < 2
754
+ # 权限图标必须常显(曾被 span 通配规则连图标一起隐藏 → 整个按钮空白)
755
+ or not r.get("accessIconShown")
756
+ # 权限按钮:**图标**必须常显(曾因 span 通配规则连图标一起隐藏 → 按钮纯空白);
757
+ # **文字**按行宽自适应:v3 排布下第一行是「工具组 ···· 上下文 发送」,
758
+ # 行宽不足(<290px,如 320px 视口)时主动退回纯图标以保住两行,
759
+ # 故此处只要求"若显示则必须有真实宽度",不强制常显。
760
+ or not r.get("accessIconShown")
761
+ or (r.get("accessLabelShown") and (r.get("accessLabelW") or 0) < 20)
762
+ # 推理等级后缀必须隐藏(否则被压成半截字符)
763
+ or r.get("effortShown")
764
+ # 被裁的模型名必须走真省略号,不能硬切半个字符
765
+ or (r.get("modelLabelClipped") and not r.get("ellipsisOk"))
766
+ # 模型名必须够宽(两行布局后独占一行:320px 约 148px、390px 约 218px,
767
+ # 而单行时代仅 40px → 只能看 7/40 字符)。下限取 120px 防退化成窄条,
768
+ # 又不锁死具体像素(布局微调不该误报)。
769
+ or 0 < (r.get("modelLabelW") or 0) < 120
770
+ # 图标/文字/箭头必须垂直居中对齐
771
+ or r.get("misaligned")
772
+ # 行数必须恰为 2(v3 排布:工具组+上下文+发送 / 模型独占第二行)
773
+ or len(r.get("lineSpreads") or []) != 2
774
+ # 每行右侧不得留出大空块(旧排布第一行空 183px → 用户看出"空了一块")
775
+ or any(g > 60 for g in (r.get("lineRightGaps") or []))
776
+ # 控件必须等高、同字号(曾 36/40/44 三种高度混排 → 视觉"怪")
777
+ or len(r.get("heights") or [1]) != 1
778
+ or len(r.get("fonts") or [1]) != 1
779
+ # 行内垂直中心离散 ≤1.5px(发送键上游 translateY(-2px) 曾致偏移)
780
+ or any(s > 1.5 for s in (r.get("lineSpreads") or [0]))
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
+ ]
286
789
 
287
790
  if args.json:
288
- # 纯 JSON 输出(供 CI/脚本消费);结论用退出码表达
289
- print(json.dumps({"ok": not failures, "results": results}, ensure_ascii=False, indent=2))
290
- return 1 if failures else 0
291
- else:
292
- print(f"被测插件: {plugin}")
791
+ print(json.dumps({
792
+ "ok": not qa_failures and not composer_failures and not header_failures,
793
+ "qa": qa_results, "composer": composer_results, "header": header_results,
794
+ }, ensure_ascii=False, indent=2))
795
+ return 1 if (qa_failures or composer_failures or header_failures) else 0
796
+
797
+ print(f"被测插件: {plugin}")
798
+
799
+ if qa_results:
800
+ print()
801
+ print("── 提问卡片(ask_user_question)──")
293
802
  print(f"{'视口':<10} {'选项数':<7} {'内容溢出':<9} {'正文可滚':<9} {'滑动后末项可见':<15} {'提交可见':<9} {'菜单不遮挡':<10}")
294
803
  print("-" * 78)
295
- for r in results:
804
+ for r in qa_results:
296
805
  print(f"{r['viewport']:<10} {r['options']:<8} {'是' if r['needs_scroll'] else '否':<10} "
297
806
  f"{'✓' if r['scrollable'] else ('—' if not r['needs_scroll'] else '✗'):<10} "
298
807
  f"{'✓' if r['last_visible_after_swipe'] else '✗':<16} {'✓' if r['submit_visible'] else '✗':<10} "
299
808
  f"{'✓' if r['menu_btn_hidden_while_qa'] else '✗':<10}")
300
809
 
810
+ if composer_results:
811
+ print()
812
+ print("── 输入区操作行(composer row)──")
813
+ print(f"{'视口':<10} {'形态':<24} {'控件':<5} {'重叠':<6} {'越界':<6} {'可点':<6} "
814
+ f"{'权限图标':<9} {'权限文字':<9} {'省略号':<8} {'对齐':<6}")
815
+ print("-" * 96)
816
+ for r in composer_results:
817
+ if r.get("err"):
818
+ print(f"{r['viewport']:<10} {r['variant']:<24} ERR {r['err']}")
819
+ continue
820
+ ell = "—" if not r.get("modelLabelClipped") else ("✓" if r.get("ellipsisOk") else "✗")
821
+ perm_txt = ("✓" if r.get("accessLabelShown") else "—")
822
+ print(f"{r['viewport']:<10} {r['variant']:<24} {r['count']:<6} "
823
+ f"{'✓' if not r['overlaps'] else '✗':<7} {'✓' if not r['spill'] else '✗':<7} "
824
+ f"{'✓' if not r['unclickable'] else '✗':<7} "
825
+ f"{'✓' if r.get('accessIconShown') else '✗':<10} {perm_txt:<10} {ell:<9} "
826
+ f"{'✓' if not r.get('misaligned') else '✗':<6}")
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
+
301
844
  print()
302
- if failures:
303
- print(f"❌ 回归:{len(failures)} 个用例不达标(选项被裁 / 滚动失效 / 提交按钮不可见)")
304
- for r in failures:
305
- print(f" - {r['viewport']} options={r['options']}: {r['detail']['before']}")
845
+ if qa_failures or composer_failures or header_failures:
846
+ if qa_failures:
847
+ print(f"❌ 提问卡片回归:{len(qa_failures)} 个用例不达标(选项被裁 / 滚动失效 / 提交按钮不可见)")
848
+ for r in qa_failures:
849
+ print(f" - {r['viewport']} options={r['options']}: {r['detail']['before']}")
850
+ if composer_failures:
851
+ print(f"❌ 操作行回归:{len(composer_failures)} 个用例不达标")
852
+ for r in composer_failures:
853
+ # 逐条列出真实原因,别只说"重叠/越界"(2026-09-11 曾因此误判)
854
+ why = []
855
+ if r.get("err"):
856
+ why.append(f"err={r['err']}")
857
+ if r.get("overlaps"):
858
+ why.append(f"控件重叠={r['overlaps']}")
859
+ if r.get("spill"):
860
+ why.append(f"越出卡片={r['spill']}")
861
+ if r.get("unclickable"):
862
+ why.append(f"点不到={r['unclickable']}")
863
+ if not r.get("accessIconShown"):
864
+ why.append("权限图标被隐藏(按钮会变空白)")
865
+ if r.get("accessLabelShown") and (r.get("accessLabelW") or 0) < 20:
866
+ why.append(f"权限文字宽度异常={r.get('accessLabelW')}")
867
+ if r.get("effortShown"):
868
+ why.append("推理等级后缀未隐藏(会被压成半截字符)")
869
+ if r.get("modelLabelClipped") and not r.get("ellipsisOk"):
870
+ why.append("模型名被硬切而非省略号")
871
+ mw = r.get("modelLabelW") or 0
872
+ if 0 < mw < 120:
873
+ why.append(f"模型名被压得过窄({mw}px < 120px)")
874
+ if r.get("misaligned"):
875
+ why.append(f"垂直未对齐={r['misaligned']}")
876
+ if len(r.get("heights") or [1]) != 1:
877
+ why.append(f"控件高度不一致={r.get('heights')}")
878
+ if len(r.get("fonts") or [1]) != 1:
879
+ why.append(f"字号不一致={r.get('fonts')}")
880
+ if len(r.get("lineSpreads") or []) != 2:
881
+ why.append(f"行数不为 2(实际 {len(r.get('lineSpreads') or [])})")
882
+ if any(g > 60 for g in (r.get("lineRightGaps") or [])):
883
+ why.append(f"行右侧留有大空块={r.get('lineRightGaps')}px")
884
+ if any(s > 1.5 for s in (r.get("lineSpreads") or [0])):
885
+ why.append(f"行内中心离散过大={r.get('lineSpreads')}")
886
+ if r.get("count", 0) < 2:
887
+ why.append(f"控件数异常={r.get('count')}")
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 ["未知"]))
306
912
  return 1
307
- print(f"✅ 全部 {len(results)} 个用例通过:选项可滚可见、提交按钮始终可见、菜单按钮不遮挡卡片")
913
+
914
+ parts = []
915
+ if qa_results:
916
+ parts.append(f"提问卡片 {len(qa_results)} 个(选项可滚可见、提交始终可见、菜单不遮挡)")
917
+ if composer_results:
918
+ parts.append(f"操作行 {len(composer_results)} 个(互不重叠/不越界/可点击、"
919
+ f"权限图标常显、被裁模型名走省略号、图标文字垂直对齐)")
920
+ if header_results:
921
+ parts.append(f"会话头部 {len(header_results)} 个(子代理切换器完整可见且单行、"
922
+ f"中心点可点、与操作区不重叠、按钮不出屏、标题不被压没)")
923
+ print("✅ 全部通过:" + ";".join(parts))
308
924
  return 0
309
925
 
310
926