dsh-mobile-ui 0.2.6 → 0.2.9

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
@@ -271,31 +271,44 @@ window.__ModuleLoader__.load({
271
271
  而容器在 header 里的横坐标随「有无子代理/后台任务」变化 —— 候选方案 right:0 在容器靠右时
272
272
  可用、靠左时左溢出;fixed 与容器位置无关(实测两种场景都在视口内)。
273
273
  ⚠️ hash 前缀(QsffPG_ / _1nxmc_)会随 dsh 升级漂移、失效是静默的(退回溢出),
274
- 升级后跑 tests/live-probe.py 复核(已覆盖后台任务菜单)。 */
274
+ 升级后跑 tests/live-probe.py 复核(已覆盖后台任务菜单)。
275
+ 🔴 2026-09-28 实证(dsh 0.1.7):_list_1nxmc_ → _list_gzo7u_,且上游改用
276
+ `--dsh-menu-anchor` CSS 变量定位 ⇒ 旧选择器整条失配、菜单左溢 61px
277
+ (用户报「三个点显示不全」)。已改用**语义锚点**
278
+ `[class*=headerUtilities] [role=menu]` —— 只依赖 headerUtilities 这个语义后缀、
279
+ 不含 hash 前缀;0.1.5 与 0.1.7 双版本实测均生效
280
+ (对比脚本 tests/try-menu-fix.py:注入前后 rect 从 [-61,…] 回到 [8,…])。 */
275
281
  /* ⚠️ popover 的 top 必须落在**触发按钮下方**:菜单弹出后若盖住按钮本身,
276
282
  用户再点一下(想关掉/重开)时点击落在菜单上 —— 体感就是「点了没反应」
277
283
  (2026-09-14 用户报「三个点按钮点击没反应」:实测命中率只有 2/6,
278
284
  菜单 [8,76,374,42] 正好压在 tabs 行的按钮 80..106 上)。
279
285
  · QsffPG:触发按钮在 header 第二行 42..70 ⇒ top 74
280
286
  · _1nxmc_(「更多操作」):触发按钮在 tabs 行 80..106 ⇒ top 122 */
281
- " body.dsh-mobile-ui [class*=QsffPG_menu],body.dsh-mobile-ui [role=menu][class*=_list_1nxmc_]{position:fixed !important;left:8px !important;right:8px !important;width:auto !important;min-width:0 !important;max-width:none !important;max-height:calc(100vh - 150px) !important;overflow:auto !important;top:calc(var(--dsh-mobile-popover-top,74px) + env(safe-area-inset-top,0px)) !important}",
282
- " body.dsh-mobile-ui [role=menu][class*=_list_1nxmc_]{--dsh-mobile-popover-top:122px}",
287
+ " body.dsh-mobile-ui [class*=QsffPG_menu],body.dsh-mobile-ui [class*=headerUtilities] [role=menu]{position:fixed !important;left:8px !important;right:8px !important;width:auto !important;min-width:0 !important;max-width:none !important;max-height:calc(100vh - 150px) !important;overflow:auto !important;top:calc(var(--dsh-mobile-popover-top,74px) + env(safe-area-inset-top,0px)) !important}",
288
+ " body.dsh-mobile-ui [class*=headerUtilities] [role=menu]{--dsh-mobile-popover-top:122px}",
283
289
  /* ── 手机端 header 瘦身 + 正文占比(2026-09-14,用户反馈「标题栏三行有点乱 / 正文显得窄」)──
284
290
  实测 390px:header 138px(titleRow 三行)+ 输入区 272px ⇒ 正文只剩 434px(占屏 51%)。
285
291
  三行里第三行是「终端打开 / 选择打开方式 / 更多操作 / 打开右侧边栏」——前两个点了是在
286
- **服务器上**开终端(手机上什么也看不到)、右侧边栏早被本插件隐藏(点了空白),
292
+ **服务器上**开终端(手机上什么也看不到)、右侧边栏自 2026-09-28 起改由工具组 ▥ 以浮层打开(此前被本插件隐藏、点了空白),
287
293
  只有「更多操作」(下载 Session 日志)有用。处理:
288
294
  ① 整行隐藏(行空掉后 header 少 32px);
289
295
  ② 「更多操作」改由**插件注入到 tabs 行空白处的按钮代理**(点击转发给上游按钮,
290
296
  上游按钮仍留在 DOM 里只是不可见,程序化 click 照常触发 React 逻辑);
291
297
  ③ tabs 行右侧本来空着 246px,顺带放「折叠输入框」开关。
292
298
  隐藏后 titleRow 只剩 titleCluster 一行组(crumbs 行 + headerActions 行),header ≈ 105px。 */
293
- /* ⚠️ headerUtilities 用「移出文档流但保留坐标」而不是 display:none:
299
+ /* ⚠️⚠️ headerUtilities 用「移出文档流但保留坐标」而不是 display:none:
294
300
  上游「更多操作」按钮的菜单位置由它自己的 getBoundingClientRect 算出,display:none 时
295
301
  该矩形全 0 ⇒ 菜单渲染成 0×0 根本看不见(2026-09-14 实测踩到)。
296
- 改成 absolute + opacity:0 + pointer-events:none:不占布局高度(header 照样瘦身)、
297
- 矩形照常可测、人也点不到(点击由 tabs 行的代理按钮转发)。 */
298
- " body.dsh-mobile-ui [class*=wSkVaW_headerUtilities]{position:absolute !important;top:74px !important;left:40px !important;opacity:0 !important;pointer-events:none !important}",
302
+ ⚠️⚠️ 更不能再用 opacity:0(0.2.5–0.2.7 的写法,错):**opacity 是「组不透明度」,祖先
303
+ 为 0 会让整棵子树(含弹层)一起透明** —— 实测「更多操作」菜单就挂在这棵子树里
304
+ (祖先链:menu → span._root_1nxmc_ → div → headerUtilities),于是菜单**弹出来了、几何也正确**
305
+ (实测 rect=[8,122,396,42]、含「下载 Session 日志」),却**完全看不见**,还因继承
306
+ pointer-events:none 而**点不动** ⇒ 用户第三次反馈「点三个点还是没反应」的真根因
307
+ (2026-09-15 定案;前两轮修的把手死区/竞态都是真问题,但不是这一条)。
308
+ ✅ 正解 = visibility:hidden:同样不占布局、getBoundingClientRect 照常可测,
309
+ 且 **visibility 可被后代用 visible 覆盖**(opacity 不行)⇒ 弹层照常显示、照常可点。 */
310
+ " body.dsh-mobile-ui [class*=wSkVaW_headerUtilities]{position:absolute !important;top:74px !important;left:40px !important;visibility:hidden !important}",
311
+ " body.dsh-mobile-ui [class*=wSkVaW_headerUtilities] [role=menu],body.dsh-mobile-ui [class*=wSkVaW_headerUtilities] [class*=_list_1nxmc_],body.dsh-mobile-ui [class*=wSkVaW_headerUtilities] [class*=_portal_],body.dsh-mobile-ui [class*=wSkVaW_headerUtilities] [class*=QsffPG_menu]{visibility:visible !important;pointer-events:auto !important}",
299
312
  " body.dsh-mobile-ui [class*=wSkVaW_headerCorner]{display:none !important}",
300
313
  /* 抽屉入口(原右上角悬浮汉堡 top:48/right:12,42px)在 header 从 138 降到 107 后
301
314
  正好压在第二行(后台任务)与 tabs 行上 —— vision 复核截图时抓到。
@@ -316,12 +329,31 @@ window.__ModuleLoader__.load({
316
329
  " body.dsh-mobile-ui #dsh-mobile-tab-tools button{width:36px !important;height:34px !important;min-width:36px !important;display:inline-flex !important;align-items:center !important;justify-content:center !important;padding:0 !important;border:none !important;border-radius:8px !important;background:transparent !important;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.7)) !important;cursor:pointer !important}",
317
330
  " body.dsh-mobile-ui #dsh-mobile-tab-tools button:active{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08)) !important}",
318
331
  " body.dsh-mobile-ui #dsh-mobile-tab-tools svg{width:18px !important;height:18px !important;fill:none !important;stroke:currentColor !important;stroke-width:1.8 !important;stroke-linecap:round !important;stroke-linejoin:round !important}",
332
+ /* ⚠️⚠️ 隐形死区:右侧栏 resize 把手的残留(2026-09-14 定案,用户报「三个点没反应」的真根因)
333
+ 上游 pI_x6G_handle 是 rightbarCol 的拖拽热区:position:absolute;top:0;bottom:0;
334
+ width:8px;margin-left:-4px;z-index:11;pointer-events:auto,left 是**固定像素**(实测 276px)。
335
+ 折叠右侧栏时 rightbarCol 收缩成 height:0(实测 rect=[0,915,412,0]),
336
+ **但把手不跟着消失**,仍以 8px 宽、**贯穿整个视口高度**(0→915)钉在 x=276..284。
337
+ 后果:那一整列 8px 宽的 tap 全被吃掉(手机上没有 col-resize,用户只会觉得「点了没反应」),
338
+ 正文区的按钮/输入框也一并被挡。实测(同一份 client.js,仅视口不同):
339
+ 412px:⋯ 按钮 260..296 的中心 278 落在 276..284 内 ⇒ elementFromPoint=handle,tap 开合命中 0/5
340
+ 390/435/460px:按钮恰好错开这条带 ⇒ 5/5(所以这个 bug「挑宽度」,极难靠换机型复现)
341
+ 移动端不需要 col-resize ⇒ 直接移除;同时给工具组抬 z-index 做第二道保险
342
+ (把手 z-index:11,工具组 z-index:30,即便将来又冒出别的覆盖层也压不住按钮)。 */
343
+ " body.dsh-mobile-ui [class*=pI_x6G_handle]{display:none !important}",
344
+ " body.dsh-mobile-ui #dsh-mobile-tab-tools{position:relative !important;z-index:30 !important}",
319
345
  /* ── 折叠输入区(阅读模式):省下 composerSeat 的 ~272px,全部让给正文 ──
320
346
  触发方式:tabs 行的键盘按钮(状态存 localStorage,刷新后保持)。
321
347
  ⚠️ 提问卡片(ask_user_question)就渲染在 composerSeat 里 ⇒ 折叠状态下若检测到卡片,
322
348
  JS 会强制展开(见 sync 里的 qaOpen 分支),否则用户看不到问题。 */
323
349
  " body.dsh-mobile-ui.dsh-mobile-composer-hidden [class*=composerSeat],body.dsh-mobile-ui.dsh-mobile-composer-hidden [class*=composerStack],body.dsh-mobile-ui.dsh-mobile-composer-hidden [class*=uV2eYG_root]{display:none !important}",
324
350
  " body.dsh-mobile-ui #dsh-mobile-tab-tools button.dsh-mobile-on{background:var(--dsw-specific-selector,rgba(255,255,255,.12)) !important;color:var(--dsw-alias-label-primary,rgba(255,255,255,.9)) !important}",
351
+ /* ── 右侧边栏(工作区文件/终端)以浮层呈现(2026-09-28 新增,用户要「打开工作区文件的按钮」)──
352
+ 上游的「打开右侧边栏」按钮与 rightbarCol 都在,但本插件为拿正文宽度把 frame 改成单列
353
+ (minmax(0,1fr))⇒ rightbarCol 落到第二行且 height:0(实测 rect=[0,839,412,0]),
354
+ 点了也是空白。故给入口按钮 + 这条规则:打开时把 rightbarCol 提成覆盖整屏的浮层
355
+ (不挤压正文),关闭仍走上游自带的「收起右侧边栏」(面板右上角,实测 rect=[378,10,28,28])。 */
356
+ " body.dsh-mobile-ui.dsh-mobile-rightbar [class*=rightbarCol]{position:fixed !important;inset:0 !important;width:auto !important;height:auto !important;z-index:2147482950 !important;display:block !important;background:var(--dsw-alias-bg-layer-1,#16171b) !important}",
325
357
  /* 输入框聚焦反馈 */
326
358
  " 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))}",
327
359
  /* 消息流操作按钮(复制/反馈/分享)触摸目标提升。
@@ -572,7 +604,7 @@ window.__ModuleLoader__.load({
572
604
  开着就什么都不做 —— 交给上游关掉,真正的 toggle。 */
573
605
  more.addEventListener("pointerdown", function () {
574
606
  try {
575
- more.__menuWasOpen = !!document.querySelector("[role=menu][class*=_list_1nxmc_]");
607
+ more.__menuWasOpen = !!document.querySelector("[class*=headerUtilities] [role=menu]");
576
608
  } catch (err) { more.__menuWasOpen = false; }
577
609
  }, true);
578
610
  more.addEventListener("click", function (e) {
@@ -610,8 +642,22 @@ window.__ModuleLoader__.load({
610
642
  openDrawer();
611
643
  } catch (err) { /* 静默 */ }
612
644
  });
645
+ /* 右侧边栏(工作区文件/终端)入口 —— 见 CSS 段说明 */
646
+ var panelBtn = document.createElement("button");
647
+ panelBtn.type = "button";
648
+ panelBtn.id = "dsh-mobile-rightbar-toggle";
649
+ panelBtn.setAttribute("aria-label", "工作区文件");
650
+ panelBtn.innerHTML = "<svg viewBox='0 0 24 24'><rect x='3' y='4' width='18' height='16' rx='2'/><path d='M14 4v16'/></svg>";
651
+ panelBtn.addEventListener("click", function (e) {
652
+ try {
653
+ e.preventDefault();
654
+ e.stopPropagation();
655
+ toggleRightbar();
656
+ } catch (err) { /* 静默 */ }
657
+ });
613
658
  box.appendChild(more);
614
659
  box.appendChild(fold);
660
+ box.appendChild(panelBtn);
615
661
  box.appendChild(menuBtn);
616
662
  tabs.appendChild(box);
617
663
  }
@@ -655,6 +701,62 @@ window.__ModuleLoader__.load({
655
701
  if (tabbar) tabbar.style.display = "";
656
702
  }
657
703
 
704
+ /* ── 右侧边栏(工作区文件/终端)开关(2026-09-28 新增)──────────────────────────
705
+ 用户要「打开工作区文件的按钮」。上游按钮与面板都在,但本插件为拿正文宽度把 frame 改成单列
706
+ ⇒ rightbarCol 落到第二行且 height:0(实测 rect=[0,839,412,0]),点了也是空白。
707
+ 做法:代理上游「打开右侧边栏」→ 给 body 加 class → CSS 把 rightbarCol 提成覆盖整屏的浮层
708
+ (不挤压正文);关闭走上游自带的「收起右侧边栏」(面板右上角)。
709
+ ⚠️ 上游按钮 label 随状态变(打开 ⇄ 收起),且 hash 类名会漂移 ⇒ 一律按 aria-label 精确选。 */
710
+ var rightbarOpenedAt = 0; /* 打开时刻,用于避开 React 尚未更新 label 的窗口 */
711
+ function rightbarOpen() {
712
+ try { return document.body.classList.contains("dsh-mobile-rightbar"); } catch (e) { return false; }
713
+ }
714
+ function syncRightbarBtn() {
715
+ try {
716
+ /* 双向同步(2026-09-28 实测补):用户可能直接点面板内上游自带的
717
+ 「收起右侧边栏」关闭 —— 那时我们的浮层 class 必须跟着清掉,否则
718
+ class 残留会让面板继续以 fixed 全屏显示(表现为「关不掉」)。
719
+ 判据用上游按钮 label:面板开着时是「收起右侧边栏」、收起后回到
720
+ 「打开右侧边栏」。⚠️ 刚打开 1.2s 内不判定(React 还没换 label,会误清)。 */
721
+ if (mq.matches && rightbarOpen() && Date.now() - rightbarOpenedAt > 1200
722
+ && document.querySelector('button[aria-label="打开右侧边栏"]')) {
723
+ document.body.classList.remove("dsh-mobile-rightbar");
724
+ }
725
+ var b = document.getElementById("dsh-mobile-rightbar-toggle");
726
+ if (b) b.classList.toggle("dsh-mobile-on", rightbarOpen());
727
+ } catch (e) { /* 静默 */ }
728
+ }
729
+ function openRightbar() {
730
+ /* 浮层只是**移动端**的替代方案:PC 用上游原生三列布局,插件完全不介入 */
731
+ if (!mq.matches) return;
732
+ try {
733
+ var up = document.querySelector('button[aria-label="打开右侧边栏"]');
734
+ if (up) up.click();
735
+ document.body.classList.add("dsh-mobile-rightbar");
736
+ rightbarOpenedAt = Date.now();
737
+ /* 面板在被 display 期间没算过尺寸 ⇒ 触发一次 resize 让它重新布局 */
738
+ setTimeout(function () { try { window.dispatchEvent(new Event("resize")); } catch (e) {} }, 350);
739
+ } catch (e) { /* 静默 */ }
740
+ syncRightbarBtn();
741
+ }
742
+ function closeRightbar() {
743
+ /* ⚠️ 非移动端绝不能点上游按钮(PC 的右侧栏是上游默认可见状态)——只清自己的 class */
744
+ if (!mq.matches) { resetRightbarOverlay(); return; }
745
+ try {
746
+ var up = document.querySelector('button[aria-label="收起右侧边栏"]');
747
+ if (up) up.click();
748
+ document.body.classList.remove("dsh-mobile-rightbar");
749
+ } catch (e) { /* 静默 */ }
750
+ syncRightbarBtn();
751
+ }
752
+ /* 只清插件自己的浮层状态,**绝不触碰上游按钮** —— 用于「非移动端」与「离开移动端」。
753
+ ⚠️ 2026-09-28 事故:非移动端分支原先误调 closeRightbar(),而它会在 PC 上点上游
754
+ 「收起右侧边栏」⇒ 每次 sync(MutationObserver 触发)都把用户本来开着的右侧栏收起,
755
+ 表现为「PC 端不停打开/关闭右侧栏」。 */
756
+ function resetRightbarOverlay() {
757
+ try { document.body.classList.remove("dsh-mobile-rightbar"); } catch (e) { /* 静默 */ }
758
+ }
759
+ function toggleRightbar() { if (rightbarOpen()) closeRightbar(); else openRightbar(); }
658
760
  function sync() {
659
761
  try {
660
762
  if (mq.matches) {
@@ -714,13 +816,14 @@ window.__ModuleLoader__.load({
714
816
  top:42 全宽后,右上角的汉堡按钮正好压在菜单首行右侧 —— vision 复核截图时发现
715
817
  「第一行的耗时被按钮遮住」,故菜单开着时也隐藏它) */
716
818
  var popOpen = !!(document.querySelector(
717
- "[class*=QsffPG_menu],[role=menu][class*=_list_1nxmc_]"));
819
+ "[class*=QsffPG_menu],[class*=headerUtilities] [role=menu]"));
718
820
  if (tabbar) {
719
821
  tabbar.style.display = (drawerOpen || settingsOpen || qaOpen || popOpen)
720
822
  ? "none" : "";
721
823
  }
722
824
  /* tabs 行工具按钮(更多操作代理 + 折叠输入区)—— React 重渲染后重建 */
723
825
  ensureTabTools();
826
+ syncRightbarBtn();
724
827
  } else {
725
828
  document.body.classList.remove("dsh-mobile-ui");
726
829
  qaOpen = false;
@@ -732,6 +835,7 @@ window.__ModuleLoader__.load({
732
835
  var side2 = getSidebar();
733
836
  if (side2) side2.style.display = "";
734
837
  closeDrawer();
838
+ resetRightbarOverlay(); /* PC 端只清插件状态,绝不碰上游右侧栏 */
735
839
  if (tabbar) tabbar.style.display = "none";
736
840
  }
737
841
  } catch (e) { /* 同步失败静默 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-ui",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
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",
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """打开一个真实会话,dump 会话头部(header)里所有按钮的几何/可见性,定位 ⋯ 与 ☰ 的问题。
3
+
4
+ 用法: python3 dump-header.py <base-url> <token-file> [--title 片段] [--width 390]
5
+ """
6
+ import sys
7
+
8
+ from playwright.sync_api import sync_playwright
9
+
10
+ BASE = sys.argv[1]
11
+ TOKF = sys.argv[2]
12
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
13
+ WIDTH = int(sys.argv[sys.argv.index("--width") + 1]) if "--width" in sys.argv else 390
14
+ TAG = sys.argv[sys.argv.index("--tag") + 1] if "--tag" in sys.argv else "dump"
15
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
16
+
17
+ DUMP_JS = """
18
+ () => {
19
+ const out = [];
20
+ const seen = new Set();
21
+ document.querySelectorAll("button, [role=button], a[class*=btn]").forEach((el) => {
22
+ const r = el.getBoundingClientRect();
23
+ if (r.width === 0 && r.height === 0) return;
24
+ if (r.top < -5 || r.top > 260) return; // 只看贴顶的头部区域(排除滚出屏的内容)
25
+ if (r.left < -5 || r.left > window.innerWidth + 5) return;
26
+ const cs = getComputedStyle(el);
27
+ let op = 1, vis = "visible", pe = "auto", node = el;
28
+ while (node && node !== document.documentElement) {
29
+ const s = getComputedStyle(node);
30
+ op *= parseFloat(s.opacity || "1");
31
+ if (s.visibility === "hidden") vis = "hidden";
32
+ if (s.pointerEvents === "none") pe = "none";
33
+ node = node.parentElement;
34
+ }
35
+ const key = (el.id || "") + "|" + (el.className || "").toString().slice(0, 40) + "|" + Math.round(r.left) + "," + Math.round(r.top);
36
+ if (seen.has(key)) return;
37
+ seen.add(key);
38
+ out.push({
39
+ id: el.id || null,
40
+ cls: (el.className || "").toString().slice(0, 70),
41
+ label: el.getAttribute("aria-label") || (el.innerText || "").trim().slice(0, 14) || null,
42
+ rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)],
43
+ op: +op.toFixed(2), vis, pe,
44
+ clipped: el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
45
+ scrollW: el.scrollWidth, clientW: el.clientWidth,
46
+ });
47
+ });
48
+ const tabs = document.querySelector('[class*=tabs]');
49
+ const header = document.querySelector('[class*=titleRow]') || document.querySelector('[class*=wSkVaW_header]');
50
+ return {
51
+ buttons: out,
52
+ tabsFound: !!tabs,
53
+ tabsCls: tabs ? tabs.className.toString().slice(0, 90) : null,
54
+ headerFound: !!header,
55
+ headerCls: header ? header.className.toString().slice(0, 90) : null,
56
+ headerRect: header ? (() => { const r = header.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; })() : null,
57
+ toolGroup: !!document.querySelector("#dsh-mobile-tab-tools"),
58
+ };
59
+ }
60
+ """
61
+
62
+ with sync_playwright() as p:
63
+ b = p.chromium.launch()
64
+ ctx = b.new_context(viewport={"width": WIDTH, "height": 900}, is_mobile=True, has_touch=True, device_scale_factor=2)
65
+ page = ctx.new_page()
66
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
67
+ page.wait_for_timeout(6000)
68
+ print(f"base={BASE} width={WIDTH}")
69
+
70
+ # 打开抽屉 → 选一个真实会话(跳过空的新会话)
71
+ page.click("#dsh-mobile-menu-btn", timeout=8000)
72
+ page.wait_for_timeout(1500)
73
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
74
+ picked, title = None, None
75
+ for row in rows[:12]:
76
+ txt = (row.inner_text() or "").strip().replace("\n", " ")
77
+ if TITLE:
78
+ if TITLE in txt:
79
+ picked, title = row, txt[:34]
80
+ break
81
+ elif "新会话" not in txt and txt:
82
+ picked, title = row, txt[:34]
83
+ break
84
+ if picked is None:
85
+ print("找不到目标会话"); sys.exit(2)
86
+ picked.click()
87
+ page.wait_for_timeout(1500)
88
+ scrim = page.query_selector("[class*=scrim]")
89
+ if scrim:
90
+ try:
91
+ scrim.click(timeout=3000)
92
+ except Exception:
93
+ page.keyboard.press("Escape")
94
+ # 等会话内容 + 插件 sync(文档:历史可能很大,改用轮询)
95
+ for i in range(30):
96
+ page.wait_for_timeout(1000)
97
+ if page.evaluate("() => !!document.querySelector('#dsh-mobile-tab-tools') || !!document.querySelector('[class*=tabs]')"):
98
+ break
99
+ page.wait_for_timeout(2500)
100
+ print(f"会话:{title}\n")
101
+ d = page.evaluate(DUMP_JS)
102
+ print(f"tabs 行: {d['tabsFound']} cls={d['tabsCls']}")
103
+ print(f"header : {d['headerFound']} cls={d['headerCls']} rect={d['headerRect']}")
104
+ print(f"插件工具组 #dsh-mobile-tab-tools: {d['toolGroup']}")
105
+ print("\n--- 头部区域按钮 ---")
106
+ for x in d["buttons"]:
107
+ flags = []
108
+ if x["op"] < 0.9:
109
+ flags.append(f"op={x['op']}")
110
+ if x["vis"] != "visible":
111
+ flags.append("visibility:hidden")
112
+ if x["pe"] == "none":
113
+ flags.append("pointer-events:none")
114
+ if x["clipped"]:
115
+ flags.append(f"内容溢出 scrollW={x['scrollW']}>clientW={x['clientW']}")
116
+ print(f" id={x['id']} cls={x['cls'][:46]} label={x['label']} rect={x['rect']} " + ("; ".join(flags) if flags else ""))
117
+ page.screenshot(path=f"/tmp/mob-{TAG}-{WIDTH}.png")
118
+ print(f"\n截图: /tmp/mob-{TAG}-{WIDTH}.png")
119
+ ctx.close()
120
+ b.close()
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """点开 ⋯ 后 dump 菜单的真实结构/类名/定位,用于对比插件选择器为何失配。"""
3
+ import sys
4
+
5
+ from playwright.sync_api import sync_playwright
6
+
7
+ BASE, TOKF = sys.argv[1], sys.argv[2]
8
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
9
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
10
+
11
+ DUMP = """
12
+ () => {
13
+ const menus = [...document.querySelectorAll('[role=menu], [role=listbox]')].filter((m) => {
14
+ const r = m.getBoundingClientRect();
15
+ return r.width > 40 && r.height > 20;
16
+ });
17
+ if (!menus.length) return { none: true };
18
+ const m = menus[menus.length - 1];
19
+ const r = m.getBoundingClientRect();
20
+ const cs = getComputedStyle(m);
21
+ const chain = [];
22
+ let node = m.parentElement;
23
+ while (node && node !== document.body && chain.length < 6) {
24
+ const c = getComputedStyle(node);
25
+ const rr = node.getBoundingClientRect();
26
+ chain.push({
27
+ tag: node.tagName, cls: (node.className || "").toString().slice(0, 80),
28
+ pos: c.position, left: c.left, right: c.right, width: c.width,
29
+ transform: c.transform === "none" ? null : c.transform,
30
+ rect: [rr.left, rr.top, rr.width, rr.height].map(Math.round),
31
+ });
32
+ node = node.parentElement;
33
+ }
34
+ return {
35
+ cls: (m.className || "").toString(),
36
+ role: m.getAttribute("role"),
37
+ rect: [r.left, r.top, r.width, r.height].map(Math.round),
38
+ computed: { position: cs.position, left: cs.left, right: cs.right, top: cs.top,
39
+ width: cs.width, minWidth: cs.minWidth, maxWidth: cs.maxWidth,
40
+ transform: cs.transform, inset: cs.inset },
41
+ inlineStyle: m.getAttribute("style"),
42
+ firstItemCls: m.querySelector("[role=menuitem], button, a")?.className?.toString()?.slice(0, 60) || null,
43
+ htmlHead: m.outerHTML.slice(0, 260),
44
+ chain,
45
+ };
46
+ }
47
+ """
48
+
49
+ with sync_playwright() as p:
50
+ b = p.chromium.launch()
51
+ ctx = b.new_context(**p.devices.get("Pixel 7", p.devices["Pixel 5"]))
52
+ page = ctx.new_page()
53
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
54
+ page.wait_for_timeout(6000)
55
+ page.touchscreen.tap(*page.evaluate("() => { const r = document.querySelector('#dsh-mobile-menu-btn').getBoundingClientRect(); return [r.left+r.width/2, r.top+r.height/2]; }"))
56
+ page.wait_for_timeout(1500)
57
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
58
+ picked = t = None
59
+ for row in rows[:12]:
60
+ s = (row.inner_text() or "").strip().replace("\n", " ")
61
+ if TITLE and TITLE in s:
62
+ picked, t = row, s[:26]; break
63
+ if not TITLE and s and "新会话" not in s:
64
+ picked, t = row, s[:26]; break
65
+ picked.click()
66
+ page.wait_for_timeout(1500)
67
+ vw = page.viewport_size["width"]
68
+ for _ in range(6):
69
+ st = page.evaluate("() => ({s: !!document.querySelector('.dsh-mobile-scrim'), d: !!document.querySelector('.dsh-mobile-drawer')})")
70
+ if not st["s"] and not st["d"]:
71
+ break
72
+ page.touchscreen.tap(vw - 30, 500)
73
+ page.wait_for_timeout(800)
74
+ for _ in range(20):
75
+ page.wait_for_timeout(800)
76
+ if page.evaluate("() => !!document.querySelector('#dsh-mobile-more-proxy')"):
77
+ break
78
+ print(f"base={BASE} 会话={t} 视口宽={vw}\n")
79
+
80
+ c = page.evaluate("""() => { const el = document.querySelector('#dsh-mobile-more-proxy');
81
+ const r = el.getBoundingClientRect(); return [r.left+r.width/2, r.top+r.height/2]; }""")
82
+ page.touchscreen.tap(*c)
83
+ page.wait_for_timeout(1600)
84
+ d = page.evaluate(DUMP)
85
+ if d.get("none"):
86
+ print("没有菜单"); sys.exit(2)
87
+ print(f"菜单 cls = {d['cls']}")
88
+ print(f"菜单 role= {d['role']} rect={d['rect']}")
89
+ print(f"computed = {d['computed']}")
90
+ print(f"inline = {d['inlineStyle']}")
91
+ print(f"首条目 cls= {d['firstItemCls']}")
92
+ print(f"htmlHead = {d['htmlHead']}")
93
+ print("\n祖先链(自内向外):")
94
+ for x in d["chain"]:
95
+ print(f" {x['tag']:6s} cls={x['cls'][:56]:56s} pos={x['pos']:9s} L={x['left']:>8s} R={x['right']:>8s} w={x['width']:>8s} tf={x['transform']} rect={x['rect']}")
96
+ ctx.close(); b.close()
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """诊断右侧边栏(工作区文件面板):frame grid / detailsCol / 相关按钮。
3
+
4
+ 用法: python3 dump-rightbar.py <base-url> <token-file> [--title 片段]
5
+ """
6
+ import sys
7
+
8
+ from playwright.sync_api import sync_playwright
9
+
10
+ BASE, TOKF = sys.argv[1], sys.argv[2]
11
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
12
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
13
+
14
+ JS = """
15
+ () => {
16
+ const rect = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; };
17
+ const out = { grid: null, cols: [], buttons: [] };
18
+
19
+ // 主布局 frame(display:grid 且子元素 ≥3)
20
+ const frames = [...document.querySelectorAll("div")].filter((e) => {
21
+ const cs = getComputedStyle(e);
22
+ return cs.display === "grid" && e.children.length >= 3;
23
+ });
24
+ if (frames.length) {
25
+ const f = frames[0], cs = getComputedStyle(f);
26
+ out.grid = { cls: (f.className || "").toString().slice(0, 70), cols: cs.gridTemplateColumns,
27
+ rect: rect(f), inlineCols: f.style.gridTemplateColumns || null, vw: window.innerWidth };
28
+ for (const ch of f.children) {
29
+ const cs = getComputedStyle(ch);
30
+ out.cols.push({ cls: (ch.className || "").toString().slice(0, 70), display: cs.display,
31
+ pos: cs.position, vis: cs.visibility, rect: rect(ch),
32
+ overflow: cs.overflow, text: (ch.innerText || "").trim().slice(0, 40) });
33
+ }
34
+ }
35
+
36
+ // 含关键词的按钮(不论可见性)
37
+ document.querySelectorAll("button, [role=button]").forEach((el) => {
38
+ const label = ((el.getAttribute("aria-label") || "") + " " + (el.title || "") + " " + (el.innerText || "").slice(0, 14)).trim();
39
+ if (/侧边栏|右侧|文件|预览|面板|详情|工作区|sidebar|panel|detail|file/i.test(label)) {
40
+ const cs = getComputedStyle(el);
41
+ out.buttons.push({ label: label.slice(0, 44), cls: (el.className || "").toString().slice(0, 54),
42
+ display: cs.display, vis: cs.visibility, rect: rect(el) });
43
+ }
44
+ });
45
+ return out;
46
+ }
47
+ """
48
+
49
+ with sync_playwright() as p:
50
+ b = p.chromium.launch()
51
+ ctx = b.new_context(**p.devices.get("Pixel 7", p.devices["Pixel 5"]))
52
+ page = ctx.new_page()
53
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
54
+ page.wait_for_timeout(6000)
55
+ page.touchscreen.tap(*page.evaluate("() => { const r = document.querySelector('#dsh-mobile-menu-btn').getBoundingClientRect(); return [r.left+r.width/2, r.top+r.height/2]; }"))
56
+ page.wait_for_timeout(1500)
57
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
58
+ picked = t = None
59
+ for row in rows[:12]:
60
+ s = (row.inner_text() or "").strip().replace("\n", " ")
61
+ if TITLE and TITLE in s:
62
+ picked, t = row, s[:26]; break
63
+ if not TITLE and s and "新会话" not in s:
64
+ picked, t = row, s[:26]; break
65
+ picked.click()
66
+ page.wait_for_timeout(1500)
67
+ vw = page.viewport_size["width"]
68
+ for _ in range(6):
69
+ st = page.evaluate("() => ({s: !!document.querySelector('.dsh-mobile-scrim'), d: !!document.querySelector('.dsh-mobile-drawer')})")
70
+ if not st["s"] and not st["d"]:
71
+ break
72
+ page.touchscreen.tap(vw - 30, 500)
73
+ page.wait_for_timeout(800)
74
+ for _ in range(20):
75
+ page.wait_for_timeout(800)
76
+ if page.evaluate("() => !!document.querySelector('#dsh-mobile-tab-tools')"):
77
+ break
78
+ print(f"base={BASE} 会话={t} 视口={page.viewport_size}\n")
79
+ d = page.evaluate(JS)
80
+
81
+ if d["grid"]:
82
+ g = d["grid"]
83
+ print(f"frame grid: cls={g['cls']}")
84
+ print(f" cols(computed) = {g['cols']}")
85
+ print(f" cols(inline) = {g['inlineCols']} ← 插件若改单列会写在这里")
86
+ print(f" rect={g['rect']}\n ── 三列 ──")
87
+ for c in d["cols"]:
88
+ print(f" cls={c['cls'][:52]:52s} display={c['display']:12s} pos={c['pos']:9s} vis={c['vis']:8s} rect={c['rect']} text={c['text']!r}")
89
+ else:
90
+ print("(没找到 display:grid 且 ≥3 子元素的 frame)")
91
+
92
+ print(f"\n── 含侧栏/文件关键词的按钮({len(d['buttons'])} 个)──")
93
+ for x in d["buttons"]:
94
+ print(f" label={x['label']!r:46s} display={x['display']:10s} vis={x['vis']:8s} rect={x['rect']} cls={x['cls'][:40]}")
95
+ ctx.close(); b.close()