dsh-sidebar-row-menu 1.9.1 → 1.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.3 — 2026-11-06
4
+
5
+ ### 中文摘要
6
+
7
+ - **二级菜单贴着屏幕右边缘时不再飞出去。** 之前子菜单按估算宽度(220)算位置,
8
+ 贴右边缘向左翻转时会和菜单之间空出一大截、还竖直跑到触发行上方。现在子菜单
9
+ 先测真实尺寸再定位:翻转后横向留 6px 间距、纵向和触发行齐平,箭头方向跟着翻转;
10
+ 底边 / 顶边超出屏幕时会被夹回视口内。主菜单定位也改成量真实宽高(原来是按入场
11
+ 动画中途的偏小尺寸夹取,贴右边缘会溢出几个像素),窗口 resize 或菜单行数变化会
12
+ 重新定位。
13
+
14
+ ### Changed
15
+
16
+ - **Submenus no longer fly off when opened against the right screen edge.** The
17
+ previous code estimated the submenu width (220px), so a left-flip near the edge left
18
+ a large gap from the menu and landed above the trigger row. The submenu is now
19
+ measured and then positioned: 6px horizontal gap when flipped, vertically aligned
20
+ with the trigger row, and its chevron mirrors to match. Panels are clamped back into
21
+ the viewport at the top and bottom edges. Menu surfaces also measure their real size
22
+ (the old code measured mid-animation, which let them overflow by a few pixels at the
23
+ right edge), and reposition on window resize or row-count changes.
24
+
25
+ ## 1.9.2 — 2026-11-06
26
+
27
+ ### 中文摘要
28
+
29
+ - **会话栏左上角的状态组件整体不进正文右键菜单。** 1.9.1 只隐藏了「本会话费用明细」,
30
+ 但 DSH 内置的后台任务计数(有任务运行时才出现)同样挂在会话栏左上角,现在按槽位
31
+ 统一处理:`conversation.session.header.actions` 里的组件一律视为状态入口,默认隐藏,
32
+ 不逐个认标签。设置 →「行菜单」的开关改名为「显示左上角状态组件」,打开后费用明细、
33
+ 后台任务计数等一起恢复;存储字段不变,旧配置照常生效。
34
+
35
+ ### Changed
36
+
37
+ - **Status widgets in the top-left corner of the session header are excluded from
38
+ the conversation-body menu as a group.** 1.9.1 only hid "Session cost", but DSH's
39
+ built-in background-job counter (shown while jobs run) lives in the same corner.
40
+ The rule is positional: everything injected into
41
+ `conversation.session.header.actions` counts as a status widget and is hidden by
42
+ default, with no per-label matching. The Settings → row menu switch is renamed to
43
+ "Show top-left status widgets"; turning it on restores cost details, job counters
44
+ and anything else added to that slot. The stored field is unchanged, so existing
45
+ settings keep working.
46
+
3
47
  ## 1.9.1 — 2026-11-06
4
48
 
5
49
  ### 中文摘要
package/README.en.md CHANGED
@@ -140,8 +140,11 @@ the original button's icon:
140
140
  - a "More" style overflow trigger is pinned to the last row (can be switched off in
141
141
  Settings); buttons the organizer tucked into "More actions" do not show up on their
142
142
  own;
143
- - cost / usage entries such as "Session cost" are hidden by default (they are text-only
144
- and open a stats panel); turn on "Show cost details" in Settings to list them;
143
+ - the status widgets in the top-left corner of the session header (the
144
+ `conversation.session.header.actions` slot: "Session cost", the background-job
145
+ counter, …) are left out as a whole; turn on "Show top-left status widgets" in
146
+ Settings to list them. The rule looks at the slot, not at labels, so status widgets
147
+ that DSH or other plugins add there later stay hidden by default too;
145
148
  - selecting an ordinary row is a real click: the menu closes, then a `click` is
146
149
  dispatched to the original header button, so "Find", "Toggle bottom panel" and
147
150
  friends still open their native popovers;
@@ -168,7 +171,7 @@ Settings → "Row menu" toggles every new entry:
168
171
  | Also show in the "…" menu → session | Adds session right-click entries one by one to the native "…" menu: Open with, Delete session, Delete all archived sessions |
169
172
  | Enable blank-space context menu | Master switch for the conversation-body right-click menu (blank space, thinking blocks, replies; on by default); when off, that area keeps the browser/host native menu |
170
173
  | Pin "More" to the far right | On by default; keeps a "More actions" style overflow trigger as the last row regardless of its current toolbar position |
171
- | Show cost details | Off by default; also lists session cost / usage entries such as "Session cost" in the conversation-body menu |
174
+ | Show top-left status widgets | Off by default; also lists everything in the header's `conversation.session.header.actions` slot (Session cost, background-job counter, …) in the conversation-body menu |
172
175
 
173
176
  The "Also show in the "…" menu" switches are all off by default; until one is
174
177
  enabled the "…" menu is exactly DSH's own.
package/README.md CHANGED
@@ -113,8 +113,9 @@ DSH 0.2.2 把侧栏右键改成数据驱动:工作区菜单由宿主统一渲
113
113
  都与 DSH 原生右键菜单对齐;
114
114
  - 「更多操作」一类的溢出入口固定放在最后一项(可在设置里关掉);被 organizer 收进
115
115
  「更多操作」的按钮不会单独出现;
116
- - 「本会话费用明细」这类费用 / 用量统计入口默认不列进菜单(它们只有文字,点开是统计
117
- 面板),设置里打开「显示费用明细」后才出现;
116
+ - 会话栏左上角的状态组件(`conversation.session.header.actions` 槽位:费用明细、
117
+ 后台任务计数等)整体不列进菜单,设置里打开「显示左上角状态组件」后才出现。这条规则
118
+ 按槽位判断、不认识具体标签,所以宿主或插件以后往这个槽位加的状态组件也默认隐藏;
118
119
  - 点普通条目就是点原按钮:先关菜单,再把 `click` 派发给宿主工具条里的那个按钮,所以
119
120
  「查找」「展开底部面板」等弹出的仍是原生面板;
120
121
  - 有选中文字时让位给浏览器的选区菜单;消息里的输入控件和链接也保留原生菜单;工具卡片、
@@ -137,7 +138,7 @@ DSH 0.2.2 把侧栏右键改成数据驱动:工作区菜单由宿主统一渲
137
138
  | 同时放进「…」菜单 → 会话 | 逐项把会话行右键条目加进原生「…」菜单:打开方式、永久删除会话、删除所有已归档会话 |
138
139
  | 启用空白处右键菜单 | 会话正文(空白处、思考过程、回复)的右键菜单总开关(默认开启);关闭后该区域恢复浏览器 / 宿主的原生菜单 |
139
140
  | 「更多」固定在最右 | 默认开启;把「更多操作」一类的溢出入口固定在菜单最后一项,不受工具条当前顺序影响 |
140
- | 显示费用明细 | 默认关闭;打开后「本会话费用明细」这类费用 / 用量统计入口也会列进正文右键菜单 |
141
+ | 显示左上角状态组件 | 默认关闭;打开后会话栏左上角 `conversation.session.header.actions` 槽位里的组件(费用明细、后台任务计数等)也会列进正文右键菜单 |
141
142
 
142
143
  「同时放进「…」菜单」下的开关默认全部关闭,`…` 菜单在未开启时与 DSH 完全一致。
143
144
 
package/lib/client.js CHANGED
@@ -248,8 +248,8 @@ window.__ModuleLoader__.load({
248
248
  'settings.blankMenu.enabled.hint': '在会话正文的空白处、思考过程或回复上右键,弹出与右上角工具条一致的纵向菜单;取不到按钮时不接管原生菜单。',
249
249
  'settings.blankMenu.keepMoreLast.label': '「更多」固定在最右',
250
250
  'settings.blankMenu.keepMoreLast.hint': '把「更多操作」一类的入口固定在菜单最后一项,不受工具条当前顺序影响。',
251
- 'settings.blankMenu.showCostMeter.label': '显示费用明细',
252
- 'settings.blankMenu.showCostMeter.hint': '把「本会话费用明细」这类统计入口也列进菜单;默认隐藏,需要时再打开。',
251
+ 'settings.blankMenu.showCostMeter.label': '显示左上角状态组件',
252
+ 'settings.blankMenu.showCostMeter.hint': '把会话栏左上角的状态组件(费用明细、后台任务计数等)也列进菜单;默认整体隐藏,需要时再打开。',
253
253
  'delete.title': '永久删除会话?',
254
254
  'delete.description': '会话「{name}」的日志将从磁盘永久删除,此操作无法撤销。',
255
255
  'delete.untitled': '未命名会话',
@@ -346,8 +346,8 @@ window.__ModuleLoader__.load({
346
346
  'settings.blankMenu.enabled.hint': 'Right-click blank space, thinking blocks or replies in the conversation body to open a vertical menu mirroring the top-right toolbar. When no button can be found the native menu is left alone.',
347
347
  'settings.blankMenu.keepMoreLast.label': 'Pin "More" to the far right',
348
348
  'settings.blankMenu.keepMoreLast.hint': 'Always put the "More" style entry last, regardless of its current position in the toolbar.',
349
- 'settings.blankMenu.showCostMeter.label': 'Show cost details',
350
- 'settings.blankMenu.showCostMeter.hint': 'Also list session cost/usage entries such as "Session cost". Hidden by default; turn it on when you want them.',
349
+ 'settings.blankMenu.showCostMeter.label': 'Show top-left status widgets',
350
+ 'settings.blankMenu.showCostMeter.hint': 'Also list the status widgets at the top-left of the session header (cost details, background job counter, …). Hidden as a whole by default; turn it on when you want them.',
351
351
  'delete.title': 'Delete this session permanently?',
352
352
  'delete.description': 'The log of "{name}" will be removed from disk. This cannot be undone.',
353
353
  'delete.untitled': 'Untitled session',
@@ -1296,18 +1296,135 @@ window.__ModuleLoader__.load({
1296
1296
 
1297
1297
  /* ---------------------------------------------------------- 打开方式子菜单 */
1298
1298
 
1299
- /** 面板贴边时的位置:右侧放不下就翻到左边,并在视口内夹取。 */
1300
- function submenuGeometry(anchor, size) {
1299
+ /** 视口可用尺寸:滚动条会占掉 innerWidth/innerHeight 的一部分,贴边要贴着可视区算。 */
1300
+ function viewportBounds() {
1301
+ const root = typeof document === 'undefined' ? null : document.documentElement;
1302
+ const width = root !== null && root !== undefined && root.clientWidth > 0 ? root.clientWidth : window.innerWidth;
1303
+ const height = root !== null && root !== undefined && root.clientHeight > 0 ? root.clientHeight : window.innerHeight;
1304
+ return { width: Number(width) || 0, height: Number(height) || 0 };
1305
+ }
1306
+
1307
+ /**
1308
+ * 元素真实占位尺寸。必须用 offsetWidth/offsetHeight:面板入场动画带 transform 缩放,
1309
+ * 动画期间 getBoundingClientRect 量出来偏小(实测 181 vs 198),按它夹取会溢出视口。
1310
+ */
1311
+ function layoutSize(node) {
1312
+ if (node === null || node === undefined) return null;
1313
+ const width = Number(node.offsetWidth) || 0;
1314
+ const height = Number(node.offsetHeight) || 0;
1315
+ if (width > 0 && height > 0) return { width, height };
1316
+ if (typeof node.getBoundingClientRect !== 'function') return null;
1317
+ const rect = node.getBoundingClientRect();
1318
+ if (!(rect.width > 0) || !(rect.height > 0)) return null;
1319
+ return { width: rect.width, height: rect.height };
1320
+ }
1321
+
1322
+ /** 菜单面板定位:按布局尺寸夹进视口;尺寸后续变化(异步条目、字体)时再夹一次。 */
1323
+ function useMenuPos(hostRef, x, y, deps) {
1324
+ const [pos, setPos] = useState(null);
1325
+ const place = () => {
1326
+ const host = hostRef.current;
1327
+ if (host === null || host === undefined) return;
1328
+ const size = layoutSize(host);
1329
+ if (size === null) return;
1330
+ const view = viewportBounds();
1331
+ const margin = 8;
1332
+ const left = Math.max(margin, Math.min(x, view.width - size.width - margin));
1333
+ const top = Math.max(margin, Math.min(y, view.height - size.height - margin));
1334
+ setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
1335
+ };
1336
+ useLayoutEffect(place, [x, y, ...deps]);
1337
+ useEffect(() => {
1338
+ if (typeof ResizeObserver !== 'function') return undefined;
1339
+ const host = hostRef.current;
1340
+ if (host === null || host === undefined) return undefined;
1341
+ const observer = new ResizeObserver(place);
1342
+ observer.observe(host);
1343
+ return () => observer.disconnect();
1344
+ }, [x, y]);
1345
+ return pos;
1346
+ }
1347
+
1348
+ /**
1349
+ * 二级菜单位置:横向贴住菜单面板外沿,纵向和触发它的那一行对齐;
1350
+ * 右侧放不下就翻到左边,仍然紧贴面板,再整体夹进视口。
1351
+ */
1352
+ function submenuGeometry(anchor, size, row = anchor) {
1301
1353
  const margin = 12;
1302
1354
  const gap = 6;
1355
+ const view = viewportBounds();
1303
1356
  const width = Math.max(160, size?.width ?? 180);
1304
1357
  const height = Math.max(0, size?.height ?? 0);
1358
+ const rightRoom = view.width - margin - (anchor.right + gap);
1359
+ const leftRoom = anchor.left - gap - margin;
1360
+ let side = 'right';
1305
1361
  let left = anchor.right + gap;
1306
- if (left + width > window.innerWidth - margin) left = anchor.left - width - gap;
1307
- left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
1308
- let top = anchor.bottom - height - 4;
1309
- top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
1310
- return { left, top };
1362
+ if (rightRoom < width && leftRoom > rightRoom) {
1363
+ side = 'left';
1364
+ left = anchor.left - width - gap;
1365
+ }
1366
+ left = Math.max(margin, Math.min(left, view.width - width - margin));
1367
+ let top = row.top;
1368
+ top = Math.max(margin, Math.min(top, view.height - height - margin));
1369
+ return { left, top, side };
1370
+ }
1371
+
1372
+ /**
1373
+ * 二级菜单定位:面板先以隐藏状态挂载,量到真实尺寸后再贴边,
1374
+ * 因此顶到屏幕右侧时会精确翻到另一侧并紧贴菜单,不再按估算宽度留出空隙。
1375
+ */
1376
+ function useSubmenuPos(open, hostRef, deps) {
1377
+ const [box, setBox] = useState(null);
1378
+ const place = () => {
1379
+ const host = hostRef.current;
1380
+ if (host === null || host === undefined) return;
1381
+ const panel = typeof host.querySelector === 'function' ? host.querySelector('.dsrm-submenu') : null;
1382
+ const size = layoutSize(panel);
1383
+ if (size === null) return;
1384
+ const row = host.getBoundingClientRect();
1385
+ // 横向以菜单面板外沿为锚:自绘菜单是 .dsrm-menu,会话 / 工作区行注入到宿主菜单里,
1386
+ // 那时要找宿主的 [role="menu"] 面板(行本身比面板内缩 4px,贴它会偏进面板里)。
1387
+ const menu = typeof host.closest === 'function'
1388
+ ? (host.closest('.dsrm-menu') ?? host.closest('[role="menu"]'))
1389
+ : null;
1390
+ const anchor = menu !== null && menu !== undefined && menu !== host ? menu.getBoundingClientRect() : row;
1391
+ const next = submenuGeometry(anchor, size, row);
1392
+ setBox((prev) => (prev !== null
1393
+ && prev.left === next.left
1394
+ && prev.top === next.top
1395
+ && prev.side === next.side ? prev : next));
1396
+ };
1397
+ useLayoutEffect(() => {
1398
+ if (!open) {
1399
+ setBox(null);
1400
+ return;
1401
+ }
1402
+ place();
1403
+ }, [open, ...deps]);
1404
+ useEffect(() => {
1405
+ if (!open) return undefined;
1406
+ window.addEventListener('resize', place);
1407
+ const host = hostRef.current;
1408
+ const panel = host !== null && host !== undefined && typeof host.querySelector === 'function'
1409
+ ? host.querySelector('.dsrm-submenu')
1410
+ : null;
1411
+ const observer = typeof ResizeObserver === 'function' && panel !== null && panel !== undefined
1412
+ ? new ResizeObserver(place)
1413
+ : null;
1414
+ if (observer !== null) observer.observe(panel);
1415
+ return () => {
1416
+ window.removeEventListener('resize', place);
1417
+ if (observer !== null) observer.disconnect();
1418
+ };
1419
+ }, [open, ...deps]);
1420
+ return box;
1421
+ }
1422
+
1423
+ /** 面板翻到左侧时箭头跟着翻转,指向菜单所在方向。 */
1424
+ function chevronClass(box) {
1425
+ return box?.side === 'left'
1426
+ ? 'dsrm-submenu-chevron dsrm-submenu-chevron-flipped'
1427
+ : 'dsrm-submenu-chevron';
1311
1428
  }
1312
1429
 
1313
1430
  /** slot 注入的 useMenuOpenState 在不同版本里可能不是数组,宽松取关闭函数。 */
@@ -1326,7 +1443,6 @@ window.__ModuleLoader__.load({
1326
1443
  const apps = useApps();
1327
1444
  usePinned();
1328
1445
  const [open, setOpen] = useState(false);
1329
- const [box, setBox] = useState(null);
1330
1446
  const hostRef = useRef(null);
1331
1447
  const closeTimer = useRef(0);
1332
1448
 
@@ -1352,17 +1468,8 @@ window.__ModuleLoader__.load({
1352
1468
  };
1353
1469
  }, [open]);
1354
1470
 
1355
- // MenuSurface 是函数组件,不保证转发 ref,因此按条目数估算面板高度;
1356
- // 列表本身有 max-height 和滚动,估算只影响贴底时的翻转位置。
1357
- useEffect(() => {
1358
- if (!open) return;
1359
- const host = hostRef.current;
1360
- if (host === null) return;
1361
- const pinnedIds = new Set(effectivePinned(apps.ids));
1362
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1363
- const height = Math.min(380, Math.max(40, count * 34 + 8));
1364
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
1365
- }, [open, apps.status, apps.ids]);
1471
+ // 面板先隐藏挂载再量真实尺寸,所以这里不再按条目数估算宽高。
1472
+ const box = useSubmenuPos(open, hostRef, [apps.status, apps.ids]);
1366
1473
 
1367
1474
  // 会话行菜单的 pointerover/out 在 document 捕获阶段被「移出关闭」守卫截断,
1368
1475
  // React 的 onPointerEnter 收不到。把悬浮展开挂到 window 捕获阶段,抢在守卫前。
@@ -1388,12 +1495,6 @@ window.__ModuleLoader__.load({
1388
1495
 
1389
1496
  const openNow = () => {
1390
1497
  window.clearTimeout(closeTimer.current);
1391
- if (!open && hostRef.current !== null) {
1392
- const pinnedIds = new Set(effectivePinned(apps.ids));
1393
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1394
- const height = Math.min(380, Math.max(40, count * 34 + 8));
1395
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
1396
- }
1397
1498
  setOpen(true);
1398
1499
  ensureApps();
1399
1500
  };
@@ -1459,7 +1560,7 @@ window.__ModuleLoader__.load({
1459
1560
  },
1460
1561
  jsx(IconRightUpOutlineRegular, { size: 14 }),
1461
1562
  h('span', { className: 'dsrm-submenu-label' }, t('openWith')),
1462
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
1563
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
1463
1564
  ),
1464
1565
  open ? h(MenuSurface, {
1465
1566
  role: 'group',
@@ -1808,7 +1909,7 @@ window.__ModuleLoader__.load({
1808
1909
  usePinned();
1809
1910
  useChildren();
1810
1911
  const hostRef = useRef(null);
1811
- const [pos, setPos] = useState(null);
1912
+ const pos = useMenuPos(hostRef, x, y, [apps.status]);
1812
1913
 
1813
1914
  const ellipsisMode = mode === 'ellipsis';
1814
1915
  /** 右键菜单永远显示;「…」菜单只在对应开关打开时显示(内置项除外)。 */
@@ -1836,16 +1937,7 @@ window.__ModuleLoader__.load({
1836
1937
  };
1837
1938
  }, [onClose]);
1838
1939
 
1839
- // 置顶应用是异步取到的,行数会变;渲染后量一次面板高度再夹进视口。
1840
- useLayoutEffect(() => {
1841
- const host = hostRef.current;
1842
- if (host === null) return;
1843
- const rect = host.getBoundingClientRect();
1844
- const margin = 8;
1845
- const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
1846
- const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
1847
- setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
1848
- }, [x, y, apps.status]);
1940
+ // 置顶应用是异步取到的,行数会变;尺寸变化由 useMenuPos 里的 ResizeObserver 兜住。
1849
1941
 
1850
1942
  const group = props.group ?? {};
1851
1943
  const workspaceId = typeof group.workspaceId === 'string' ? group.workspaceId : '';
@@ -2220,16 +2312,6 @@ window.__ModuleLoader__.load({
2220
2312
  return candidates.some((candidate) => /更多打开方式|more open/i.test(headerButtonLabel(candidate)));
2221
2313
  }
2222
2314
 
2223
- /**
2224
- * 工具条上的「本会话费用明细」一类统计入口(dsh-cost-meter 等)默认不列进菜单;
2225
- * 这类按钮只有文字没有图标,点开是费用/后台任务面板。
2226
- */
2227
- function isCostMeterButton(button, label) {
2228
- const className = typeof button?.className === 'string' ? button.className : '';
2229
- if (/cm-stat|cm-btn/i.test(className)) return true;
2230
- return /费用明细|费用|cost|usage/i.test(label);
2231
- }
2232
-
2233
2315
  /**
2234
2316
  * 收集工具条上的按钮,按屏幕从左到右排序;「更多」按开关压到末尾。
2235
2317
  * 只统计真实可见、可点、有标签的节点:隐藏槽位、被 organizer 收进「…」的
@@ -2275,7 +2357,9 @@ window.__ModuleLoader__.load({
2275
2357
  more,
2276
2358
  menu: more ? findHeaderMenuController(button) : null,
2277
2359
  openWith,
2278
- cost: isCostMeterButton(button, label),
2360
+ // 会话栏左上角(header actions 槽位)放的是费用 / 后台任务这类状态组件,
2361
+ // 不逐个认标签,整个槽位统一按状态入口处理,默认不列进菜单。
2362
+ stats: slot.section === 'actions',
2279
2363
  section: slot.section,
2280
2364
  left: rect.left,
2281
2365
  top: rect.top,
@@ -2365,7 +2449,6 @@ window.__ModuleLoader__.load({
2365
2449
  useChildren();
2366
2450
  usePinned();
2367
2451
  const [open, setOpen] = useState(false);
2368
- const [box, setBox] = useState(null);
2369
2452
  const hostRef = useRef(null);
2370
2453
  const closeTimer = useRef(0);
2371
2454
 
@@ -2387,27 +2470,13 @@ window.__ModuleLoader__.load({
2387
2470
  };
2388
2471
  }, [open]);
2389
2472
 
2390
- // MenuSurface 不保证转发 ref,按条目数估算高度即可(列表本身会滚动)。
2391
- useEffect(() => {
2392
- if (!open) return;
2393
- const host = hostRef.current;
2394
- if (host === null) return;
2395
- const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2396
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2397
- const height = Math.min(380, Math.max(40, count * 34 + 8));
2398
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2399
- }, [open, apps.status, apps.ids]);
2473
+ // 面板先隐藏挂载再量真实尺寸,位置按真实宽高算。
2474
+ const box = useSubmenuPos(open, hostRef, [apps.status, apps.ids]);
2400
2475
 
2401
2476
  useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2402
2477
 
2403
2478
  const openNow = () => {
2404
2479
  window.clearTimeout(closeTimer.current);
2405
- if (!open && hostRef.current !== null) {
2406
- const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2407
- const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2408
- const height = Math.min(380, Math.max(40, count * 34 + 8));
2409
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2410
- }
2411
2480
  setOpen(true);
2412
2481
  ensureApps();
2413
2482
  };
@@ -2466,7 +2535,7 @@ window.__ModuleLoader__.load({
2466
2535
  icon: jsx(IconRightUpOutlineRegular, { size: 14 }),
2467
2536
  children: h('span', { className: 'dsrm-host-label' },
2468
2537
  h('span', { className: 'dsrm-host-label-text' }, t('openWith')),
2469
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2538
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
2470
2539
  ),
2471
2540
  }),
2472
2541
  open ? h(MenuSurface, {
@@ -2514,7 +2583,6 @@ window.__ModuleLoader__.load({
2514
2583
  */
2515
2584
  function BlankMoreRows({ label, icon, controller, separatorBefore, onClose, children }) {
2516
2585
  const [open, setOpen] = useState(false);
2517
- const [box, setBox] = useState(null);
2518
2586
  const hostRef = useRef(null);
2519
2587
  const closeTimer = useRef(0);
2520
2588
  const entries = orderMoreItems(controller.items);
@@ -2537,22 +2605,13 @@ window.__ModuleLoader__.load({
2537
2605
  };
2538
2606
  }, [open]);
2539
2607
 
2540
- useEffect(() => {
2541
- if (!open) return;
2542
- const host = hostRef.current;
2543
- if (host === null) return;
2544
- const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2545
- setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2546
- }, [open, controller, entries.length]);
2608
+ // 面板先隐藏挂载再量真实尺寸,位置按真实宽高算。
2609
+ const box = useSubmenuPos(open, hostRef, [entries.length]);
2547
2610
 
2548
2611
  useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2549
2612
 
2550
2613
  const openNow = () => {
2551
2614
  window.clearTimeout(closeTimer.current);
2552
- if (!open && hostRef.current !== null) {
2553
- const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2554
- setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2555
- }
2556
2615
  setOpen(true);
2557
2616
  };
2558
2617
  const closeSoon = () => {
@@ -2586,7 +2645,7 @@ window.__ModuleLoader__.load({
2586
2645
  icon: headerIconNode(icon),
2587
2646
  children: h('span', { className: 'dsrm-host-label' },
2588
2647
  h('span', { className: 'dsrm-host-label-text' }, children ?? label),
2589
- jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2648
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: chevronClass(box) }),
2590
2649
  ),
2591
2650
  }),
2592
2651
  open ? h(MenuSurface, {
@@ -2618,7 +2677,7 @@ window.__ModuleLoader__.load({
2618
2677
  */
2619
2678
  function HeaderToolbarMenu({ x, y, items, t, sessionId, onClose }) {
2620
2679
  const hostRef = useRef(null);
2621
- const [pos, setPos] = useState(null);
2680
+ const pos = useMenuPos(hostRef, x, y, []);
2622
2681
  const itemsConfig = useItems();
2623
2682
  const blankMenu = useBlankMenu();
2624
2683
  const apps = useApps();
@@ -2642,15 +2701,7 @@ window.__ModuleLoader__.load({
2642
2701
  };
2643
2702
  }, [onClose]);
2644
2703
 
2645
- useLayoutEffect(() => {
2646
- const host = hostRef.current;
2647
- if (host === null) return;
2648
- const rect = host.getBoundingClientRect();
2649
- const margin = 8;
2650
- const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
2651
- const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
2652
- setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
2653
- }, [x, y, items]);
2704
+ // 位置由 useMenuPos 统一夹取:菜单行数随工具条内容变化,尺寸变了再夹一次。
2654
2705
 
2655
2706
  const cwd = typeof sessionId === 'string' && sessionId !== '' ? sessionCwd(rootCtx, sessionId) : '';
2656
2707
  const enabledApps = apps.ids.filter((id) => childOn(id));
@@ -2670,8 +2721,8 @@ window.__ModuleLoader__.load({
2670
2721
  };
2671
2722
  let openWithDone = false;
2672
2723
  items.forEach((item, index) => {
2673
- // 费用/用量统计入口默认隐藏,设置里打开「显示费用明细」才出现。
2674
- if (item.cost && blankMenu.showCostMeter !== true) return;
2724
+ // 费用 / 后台任务这类状态入口默认隐藏,设置里打开「显示状态与费用」才出现。
2725
+ if (item.stats && blankMenu.showCostMeter !== true) return;
2675
2726
  // 设置里关掉「打开方式」时,空白菜单也不出现这一组,和工作区 / 会话右键一致。
2676
2727
  if (item.openWith && itemsConfig.openWith === false) return;
2677
2728
  if (item.openWith && ownOpenWith) {
@@ -3703,6 +3754,7 @@ window.__ModuleLoader__.load({
3703
3754
  .dsrm-submenu-trigger:hover:not(:disabled),.dsrm-submenu-trigger:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
3704
3755
  .dsrm-submenu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3705
3756
  .dsrm-submenu-chevron{color:var(--dsw-alias-label-caption)}
3757
+ .dsrm-submenu-chevron-flipped{transform:scaleX(-1)}
3706
3758
  .dsrm-host-label{display:flex;align-items:center;gap:6px;width:100%}
3707
3759
  .dsrm-host-label-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
3708
3760
  .dsrm-submenu{position:fixed;z-index:1101;min-width:180px;max-width:300px;max-height:min(380px,64vh);overflow:auto;padding:4px}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-row-menu",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "type": "module",
5
5
  "description": "Adds open-with and cleanup entries to the DSH sidebar context menus (interleaved on 0.2.2+, self-drawn workspace menu on 0.2.1), plus a conversation-body right-click menu that mirrors the session header toolbar with in-menu open-with and more-actions submenus.",
6
6
  "keywords": [