dsh-mobile-ui 0.2.8 → 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,19 +271,25 @@ 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 行空白处的按钮代理**(点击转发给上游按钮,
@@ -342,6 +348,12 @@ window.__ModuleLoader__.load({
342
348
  JS 会强制展开(见 sync 里的 qaOpen 分支),否则用户看不到问题。 */
343
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}",
344
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}",
345
357
  /* 输入框聚焦反馈 */
346
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))}",
347
359
  /* 消息流操作按钮(复制/反馈/分享)触摸目标提升。
@@ -592,7 +604,7 @@ window.__ModuleLoader__.load({
592
604
  开着就什么都不做 —— 交给上游关掉,真正的 toggle。 */
593
605
  more.addEventListener("pointerdown", function () {
594
606
  try {
595
- more.__menuWasOpen = !!document.querySelector("[role=menu][class*=_list_1nxmc_]");
607
+ more.__menuWasOpen = !!document.querySelector("[class*=headerUtilities] [role=menu]");
596
608
  } catch (err) { more.__menuWasOpen = false; }
597
609
  }, true);
598
610
  more.addEventListener("click", function (e) {
@@ -630,8 +642,22 @@ window.__ModuleLoader__.load({
630
642
  openDrawer();
631
643
  } catch (err) { /* 静默 */ }
632
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
+ });
633
658
  box.appendChild(more);
634
659
  box.appendChild(fold);
660
+ box.appendChild(panelBtn);
635
661
  box.appendChild(menuBtn);
636
662
  tabs.appendChild(box);
637
663
  }
@@ -675,6 +701,62 @@ window.__ModuleLoader__.load({
675
701
  if (tabbar) tabbar.style.display = "";
676
702
  }
677
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(); }
678
760
  function sync() {
679
761
  try {
680
762
  if (mq.matches) {
@@ -734,13 +816,14 @@ window.__ModuleLoader__.load({
734
816
  top:42 全宽后,右上角的汉堡按钮正好压在菜单首行右侧 —— vision 复核截图时发现
735
817
  「第一行的耗时被按钮遮住」,故菜单开着时也隐藏它) */
736
818
  var popOpen = !!(document.querySelector(
737
- "[class*=QsffPG_menu],[role=menu][class*=_list_1nxmc_]"));
819
+ "[class*=QsffPG_menu],[class*=headerUtilities] [role=menu]"));
738
820
  if (tabbar) {
739
821
  tabbar.style.display = (drawerOpen || settingsOpen || qaOpen || popOpen)
740
822
  ? "none" : "";
741
823
  }
742
824
  /* tabs 行工具按钮(更多操作代理 + 折叠输入区)—— React 重渲染后重建 */
743
825
  ensureTabTools();
826
+ syncRightbarBtn();
744
827
  } else {
745
828
  document.body.classList.remove("dsh-mobile-ui");
746
829
  qaOpen = false;
@@ -752,6 +835,7 @@ window.__ModuleLoader__.load({
752
835
  var side2 = getSidebar();
753
836
  if (side2) side2.style.display = "";
754
837
  closeDrawer();
838
+ resetRightbarOverlay(); /* PC 端只清插件状态,绝不碰上游右侧栏 */
755
839
  if (tabbar) tabbar.style.display = "none";
756
840
  }
757
841
  } catch (e) { /* 同步失败静默 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-ui",
3
- "version": "0.2.8",
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()