dsh-better-workspace 0.37.3 → 0.38.0

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/README.md CHANGED
@@ -55,7 +55,7 @@
55
55
  <table>
56
56
  <tr>
57
57
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="右键菜单"/></td>
58
- <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)。</td>
58
+ <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)。<br/><b>v0.38.0 起接入官方统一右键菜单</b>:会话/工作区行由宿主共享菜单渲染,官方条目与插件补充项(移动到分组 / 移出分组 / 自定义外观 / 修改默认预设,排在分隔线之后)同席,官方新增条目会自动出现;分组行仍用插件菜单,旧宿主回退原菜单。</td>
59
59
  </tr>
60
60
  </table>
61
61
 
@@ -124,6 +124,7 @@ DSH 的侧边栏浏览区是一个公开插槽 `sidebar.workspaces`(single),官
124
124
  - **dsh 0.1.6-alpha.1**:图标集与浏览器注入面都变了,本插件 **0.10.2 起自动适配**——图标网格只列本宿主真正导出的字形(退役的 `IconSendOutline16` 自动落到等价的 `IconSendOutline14`,已保存的旧选择不会变成空图标位),会话拖拽排序在宿主不再注入 `insertSessionBefore` 时改用浏览器本地顺序,**功能不丢**。升级 dsh 与升级插件谁先谁后都收敛。
125
125
  - **dsh 0.1.6-alpha.2**:设置页重组——本插件 **0.12.0 起双座位**注册设置卡片(旧宿主的 设置→插件 卡片 + 新宿主 插件面板 bundle 页,按包名挂载);工作区树新增**磁盘目录嵌套层**(官方 workspace-tree 语义),添加工作区回归官方目录流(原生选择器 / 官方应用内浏览器)。
126
126
  - **dsh 0.2.2-alpha.2**:自绘会话行渲染官方 `sidebar.session.row.trailing` 行尾座位(时间之后、置顶角标之前;空会话不渲染),官方实验性 worktree / PR 图标因此在插件接管侧栏后仍能出现;同时接受官方的空白会话复用语义(不传 `agentPreset` 时会复用同工作区任意 preset 的空白会话)。该座位按能力探测:老宿主没有它时行尾照常渲染,不会因此报错。兼容下限保持 `>=0.1.6-alpha.2`。
127
+ - **dsh 0.2.2-alpha.3**:会话/工作区行接入官方统一右键菜单(`contextMenus`)——官方条目与插件补充项同席,插件条目 order 700、排在其后并以分隔线隔开,官方今后新增的条目会自动出现。工作区分组 / 会话分组行是插件私有虚拟对象,仍用插件菜单;没有该服务的旧宿主整体回退本地菜单,兼容下限保持 `>=0.1.6-alpha.2`。
127
128
  - **跨端**:web 与桌面共用同一套客户端资产;外观 / 开关可经设置卡片手动跨端同步,**折叠状态与会话本地序每端本地**(设计如此)。
128
129
 
129
130
  ## 限制与路线图
package/README_EN.md CHANGED
@@ -55,7 +55,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
55
55
  <table>
56
56
  <tr>
57
57
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="Context menu"/></td>
58
- <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / customize; session &amp; session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).</td>
58
+ <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / customize; session &amp; session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).<br/><b>Since v0.38.0 the shared host context menu owns session/workspace rows</b>: official entries and this plugin's extras (move to group / move out / customize / default preset, after a separator) share one popup, and future official entries appear automatically; group rows keep the plugin menu and older hosts fall back to it.</td>
59
59
  </tr>
60
60
  </table>
61
61
 
@@ -101,6 +101,7 @@ Same `dsh-better-*` family, zero overlap: [dsh-better-sidebar](https://github.co
101
101
  - **dsh 0.1.6-alpha.1**: both the primitives icon set and the browser injection face changed; this plugin **adapts automatically since 0.10.2**. The icon grid only lists glyphs this host actually exports (the retired `IconSendOutline16` resolves to the equivalent `IconSendOutline14`, so a previously saved choice never turns into an empty icon slot), and session drag-to-reorder falls back to a browser-local order when the host no longer injects `insertSessionBefore` — **no feature is lost**. Either upgrade order (plugin first or dsh first) converges.
102
102
  - **dsh 0.1.6-alpha.2**: the settings pages were reworked — since **0.12.0** this plugin registers its settings card on **both seats** (the pre-alpha.2 Settings → Plugins card and the alpha.2+ Plugins panel's bundle page, keyed by package name); the tree gains the **disk-nesting layer** (official workspace-tree semantics) and add-workspace rides the official directory flow (native chooser / official in-app browser).
103
103
  - **dsh 0.2.2-alpha.2**: the hand-drawn session rows now render the shipped `sidebar.session.row.trailing` seat (after the time, before the pin badge; blank rows skip it), so the official experimental worktree / PR icons still appear after this plugin takes over the sidebar. The plugin also accepts the host's blank-session reuse semantics (without `agentPreset`, a blank from any preset in the same workspace may be reused). The seat is capability-probed: hosts without it keep rendering the row tail and never error. The compatibility floor stays `>=0.1.6-alpha.2`.
104
+ - **dsh 0.2.2-alpha.3**: session/workspace rows join the shipped unified context menu (`contextMenus`) — official entries and this plugin's extras share one popup, the extras sit at order 700 after a separator, and future official entries appear automatically. Workspace-group / session-group rows are plugin-private virtual objects and keep the plugin menu; older hosts without the service fall back to the local menu, and the compatibility floor stays `>=0.1.6-alpha.2`.
104
105
  - **Across surfaces**: web and desktop share one set of client assets; appearance / switches sync manually through the settings card, while **collapse state and the local session order stay per-browser** by design.
105
106
 
106
107
  ## Limitations / roadmap
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.37.3",
3
+ "version": "0.38.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树。Workspace tree for the DSH sidebar workspace list, nested by official disk folders with optional \"/\"-name grouping; the New Session workspace picker shows the same tree.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.37.3",
3
+ "version": "0.38.0",
4
4
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树(同一份视图选项即时同步);添加工作区走官方目录流。Workspace tree for the DSH sidebar: disk-path nesting (official workspace-tree semantics), with optional \"/\"-name grouping (off by default), and the same tree in the New Session workspace picker; add-workspace rides the official directory flow.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -31,7 +31,8 @@
31
31
  "@deepseek-ai/dsh-client-ui-slots",
32
32
  "@deepseek-ai/dsh-client-ui-conversation",
33
33
  "@deepseek-ai/dsh-client-modules",
34
- "@deepseek-ai/dsh-api-remotes"
34
+ "@deepseek-ai/dsh-api-remotes",
35
+ "@deepseek-ai/dsh-client-ui-context-menu"
35
36
  ],
36
37
  "platform": "web"
37
38
  }
package/src/client.js CHANGED
@@ -5366,6 +5366,42 @@ window.__ModuleLoader__.load({
5366
5366
  )
5367
5367
  : HOST_SNAP_UNAVAILABLE
5368
5368
  }
5369
+ // dsh 0.2.2-alpha.3: the shared context menu owns the popup, so its
5370
+ // captured target (not our local ctx) decides which row keeps the open
5371
+ // state. Capability probing stays inside the callbacks — old hosts have
5372
+ // no service and the hook degrades to a stable null.
5373
+ // Late-arriving shared menu: the row highlight subscribes to face.active,
5374
+ // but the face itself can be adopted AFTER the first render (soft inject).
5375
+ // A tiny module-level revision bus re-renders those rows once, so the
5376
+ // highlight starts following the official target without waiting for an
5377
+ // unrelated store tick.
5378
+ const menuFaceListeners = new Set()
5379
+ const subscribeMenuFace = (listener) => {
5380
+ menuFaceListeners.add(listener)
5381
+ return () => { menuFaceListeners.delete(listener) }
5382
+ }
5383
+ const notifyMenuFace = () => {
5384
+ for (const listener of Array.from(menuFaceListeners)) {
5385
+ try { listener() } catch { /* one stale row must not stop the rest */ }
5386
+ }
5387
+ }
5388
+ const useActiveContextMenu = (faceOf) => {
5389
+ const [, bumpFace] = React.useState(0)
5390
+ React.useEffect(() => {
5391
+ if (typeof faceOf === 'function' && faceOf()) return undefined
5392
+ return subscribeMenuFace(() => bumpFace((value) => value + 1))
5393
+ }, [])
5394
+ const face = typeof faceOf === 'function' ? faceOf() : null
5395
+ const active = face && face.active
5396
+ return HAS_USE_SYNC_EXTERNAL_STORE
5397
+ ? React.useSyncExternalStore(
5398
+ (onChange) => (active && typeof active.subscribe === 'function' ? active.subscribe(onChange) : () => {}),
5399
+ () => {
5400
+ try { return active && typeof active.getSnapshot === 'function' ? active.getSnapshot() : null } catch { return null }
5401
+ },
5402
+ )
5403
+ : null
5404
+ }
5369
5405
 
5370
5406
  // MANUAL cross-device sync (user-chosen, never automatic): the settings
5371
5407
  // card offers a pull button ("从客户端获取" on the web / "从 Web 获取" in
@@ -6125,6 +6161,23 @@ window.__ModuleLoader__.load({
6125
6161
  }
6126
6162
  }
6127
6163
 
6164
+ // dsh 0.2.2-alpha.3 keyboard trigger, mirroring the shared menu's own
6165
+ // getTriggerProps: ContextMenu or Shift+F10 opens the row menu and the
6166
+ // row itself is reachable with Tab. Kept local (not the official helper)
6167
+ // so the SAME openCtx path still owns the target payload and the fallback
6168
+ // to the local menu on hosts without the service.
6169
+ const menuTriggerProps = (onContextMenu) => typeof onContextMenu !== 'function' ? {} : {
6170
+ tabIndex: 0,
6171
+ onKeyDown: (event) => {
6172
+ if (!event || event.repeat || event.isComposing || event.ctrlKey || event.altKey || event.metaKey) return
6173
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return
6174
+ event.preventDefault()
6175
+ event.stopPropagation()
6176
+ try { event.currentTarget.focus() } catch { /* detached row */ }
6177
+ onContextMenu(event)
6178
+ },
6179
+ }
6180
+
6128
6181
  function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dropSlot, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
6129
6182
  const total = countWorkspaces(node)
6130
6183
  const iconEl = iconOf(iconMode, expanded)
@@ -6149,6 +6202,7 @@ window.__ModuleLoader__.load({
6149
6202
  role: 'treeitem',
6150
6203
  'aria-expanded': expanded,
6151
6204
  ...(dragEvents || {}),
6205
+ ...menuTriggerProps(onContextMenu),
6152
6206
  },
6153
6207
  // Official projectRow duality (Rows.module.css): the chevron hides
6154
6208
  // until hover, swapping in for the folder glyph; the arrow spins
@@ -6179,6 +6233,7 @@ window.__ModuleLoader__.load({
6179
6233
  role: 'treeitem',
6180
6234
  'aria-expanded': sessionsOpen,
6181
6235
  ...(dragEvents || {}),
6236
+ ...menuTriggerProps(onContextMenu),
6182
6237
  },
6183
6238
  // Official projectRow duality (Rows.module.css): the chevron hides
6184
6239
  // until hover, swapping in for the folder glyph; the arrow spins
@@ -6429,6 +6484,7 @@ window.__ModuleLoader__.load({
6429
6484
  role: 'treeitem',
6430
6485
  'aria-description': node.archived ? archivedTitle || undefined : undefined,
6431
6486
  ...(dragEvents || {}),
6487
+ ...menuTriggerProps(onContextMenu),
6432
6488
  },
6433
6489
  E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
6434
6490
  status && typeof ui.StateDot === 'function'
@@ -6486,6 +6542,7 @@ window.__ModuleLoader__.load({
6486
6542
  role: 'treeitem',
6487
6543
  'aria-expanded': expanded,
6488
6544
  ...(dragEvents || {}),
6545
+ ...menuTriggerProps(onContextMenu),
6489
6546
  },
6490
6547
  // Session groups are a plugin concept: their chevron stays visible
6491
6548
  // (only the official container rows hide it until hover).
@@ -7666,6 +7723,7 @@ window.__ModuleLoader__.load({
7666
7723
  shippedBrowserFace, scheduleSeatReader, seatEntriesReader,
7667
7724
  officialT,
7668
7725
  presetFaceOf,
7726
+ contextMenuFaceOf, setMenuBridge,
7669
7727
  t,
7670
7728
  } = props
7671
7729
  // Issue #13: local reads and writes go through OUR store instance, never
@@ -7684,6 +7742,10 @@ window.__ModuleLoader__.load({
7684
7742
  // Every consumer below keeps reading this local, so a late-arriving
7685
7743
  // agent-preset service is picked up by the next render's menu build.
7686
7744
  const presetFace = typeof presetFaceOf === 'function' ? presetFaceOf() : null
7745
+ // Official menu integration (alpha.3): the active target drives the
7746
+ // workspace row's open state, while the bridge carries the live bw
7747
+ // extras into the apply-level contribution.
7748
+ const activeContextMenu = useActiveContextMenu(contextMenuFaceOf)
7687
7749
 
7688
7750
  if (typeof useWorkspaces !== 'function' || typeof useSessions !== 'function') {
7689
7751
  console.error('[dsh-better-workspace] standard snapshot hooks missing; browser renders nothing')
@@ -8521,9 +8583,53 @@ window.__ModuleLoader__.load({
8521
8583
  const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
8522
8584
  const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
8523
8585
 
8586
+ // Keyboard triggers carry clientX/clientY = 0. Mirror the shared menu's
8587
+ // own trigger: (0,0) anchors the popup at the focused row's bottom-left
8588
+ // instead of the viewport corner. Pointer events pass their point
8589
+ // through untouched.
8590
+ const menuPointOf = (e) => {
8591
+ const x = e && Number.isFinite(e.clientX) ? e.clientX : 0
8592
+ const y = e && Number.isFinite(e.clientY) ? e.clientY : 0
8593
+ if (x !== 0 || y !== 0) return { x, y }
8594
+ const target = e && e.currentTarget
8595
+ const rect = target && typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : null
8596
+ return rect ? { x: rect.left, y: rect.bottom } : { x, y }
8597
+ }
8524
8598
  const openCtx = (kind, payload, e) => {
8525
8599
  if (e) e.preventDefault()
8526
- setCtx({ kind, payload, x: e.clientX, y: e.clientY })
8600
+ const point = menuPointOf(e)
8601
+ // dsh 0.2.2-alpha.3: session and workspace are official target
8602
+ // families, so the shared popup owns their entries (official ones
8603
+ // plus our order-700 extras). Folder/session-group rows are virtual
8604
+ // objects with no official family and keep the local menu. Hosts
8605
+ // without the service keep the local menu for everything.
8606
+ if (e && (kind === 'session' || kind === 'workspace')) {
8607
+ const face = typeof contextMenuFaceOf === 'function' ? contextMenuFaceOf() : null
8608
+ if (face && typeof face.open === 'function') {
8609
+ try {
8610
+ if (kind === 'session') {
8611
+ face.open({
8612
+ kind: 'session',
8613
+ value: { sessionId: payload.id, displayTitle: String(payload.title || '') },
8614
+ }, point)
8615
+ } else {
8616
+ face.open({
8617
+ kind: 'workspace',
8618
+ value: {
8619
+ workspaceId: payload.workspaceId,
8620
+ path: String(payload.path || ''),
8621
+ displayTitle: String(payload.title || payload.leaf || ''),
8622
+ },
8623
+ }, point)
8624
+ }
8625
+ setCtx(null)
8626
+ return
8627
+ } catch (error) {
8628
+ console.warn('[dsh-better-workspace] official context menu open failed; using local menu', error)
8629
+ }
8630
+ }
8631
+ }
8632
+ setCtx({ kind, payload, ...point })
8527
8633
  }
8528
8634
 
8529
8635
  // Session-action surface (v0.20.0, official mirror): the right-click
@@ -10233,8 +10339,13 @@ window.__ModuleLoader__.load({
10233
10339
  // The in-row ellipsis is retired; the ctx surface stays the
10234
10340
  // right-click path. While the menu is open the row still pins
10235
10341
  // (hover fill + visible actions), matching the official menuOpen
10236
- // state.
10237
- menuOpen: !!(ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId),
10342
+ // state — the shared popup reports its captured target through
10343
+ // contextMenus.active, while older hosts keep the local ctx.
10344
+ menuOpen: !!(
10345
+ (ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId)
10346
+ || (activeContextMenu && activeContextMenu.kind === 'workspace'
10347
+ && activeContextMenu.value && activeContextMenu.value.workspaceId === workspace.workspaceId)
10348
+ ),
10238
10349
  hoverBody: workspaceHoverBody,
10239
10350
  dropHalf: wsDropHalf(workspace.workspaceId),
10240
10351
  dropInto: wsRootDropActive(workspace.workspaceId) || wsGroupIntoActive(workspace.workspaceId),
@@ -10497,6 +10608,13 @@ window.__ModuleLoader__.load({
10497
10608
  customizeEntry,
10498
10609
  ]
10499
10610
  }
10611
+ // One group-LEAVING implementation for the local menu and the official
10612
+ // contribution: retitle to the leaf and let the user-touch wrapper pin it.
10613
+ const moveSessionOut = (payload) => {
10614
+ const title = String(payload.title || '')
10615
+ const leaf = leafOf(title)
10616
+ if (leaf !== title) Promise.resolve().then(() => renameByUser(payload.id, leaf)).catch(fail)
10617
+ }
10500
10618
  const pickSessionAction = (payload, id) => {
10501
10619
  if (id === 'rename') { setDialog({ kind: 'sess-rename', session: payload }); return true }
10502
10620
  if (id === 'fork') { forkSession(payload.id); return true }
@@ -10536,13 +10654,48 @@ window.__ModuleLoader__.load({
10536
10654
  }
10537
10655
  // Plain retitle back to the leaf: the row leaves its group and
10538
10656
  // returns to the top level of its workspace (v0.18.0).
10539
- if (id === 'move-out') {
10540
- const leaf = leafOf(String(payload.title || ''))
10541
- if (leaf !== payload.title) Promise.resolve().then(() => renameByUser(payload.id, leaf)).catch(fail)
10542
- return true
10543
- }
10657
+ if (id === 'move-out') { moveSessionOut(payload); return true }
10544
10658
  return false
10545
10659
  }
10660
+ // Shared workspace-preset flow: the local menu and the official
10661
+ // contribution both land here, so the blank-session rules cannot drift.
10662
+ const openWorkspacePresetDialog = (payload) => {
10663
+ if (!presetFace) return
10664
+ // Freeze the target NOW (the dialog outlives the menu ctx): the
10665
+ // blank lookup mirrors the official createInWorkspace rule — a
10666
+ // standing blank IS the workspace default (its preset projection
10667
+ // is what the hero chip shows). With NO standing blank the edit
10668
+ // still applies: ClientSessions.create prepares one WITH the
10669
+ // picked preset (the same channel as the official new-session
10670
+ // button), which makes it the workspace default. Creating a
10671
+ // session here is deliberate, not a side effect — the host has
10672
+ // no other per-workspace default store (agent-preset-registry
10673
+ // selectedDefault is GLOBAL; the standing blank IS the carrier).
10674
+ const raw = (storedItems || []).find((item) => item.workspaceId === payload.workspaceId)
10675
+ const blankIds = raw && Array.isArray(raw.sessionIds) ? raw.sessionIds : []
10676
+ const blankId = raw && raw.path
10677
+ ? (list.ids || []).find((sid) => {
10678
+ const summary = list.byId[sid]
10679
+ return summary && summary.blank === true && summary.cwd === raw.path
10680
+ && blankIds.indexOf(sid) !== -1 && !archivedSet.has(sid)
10681
+ })
10682
+ : undefined
10683
+ const blankSummary = blankId !== undefined ? list.byId[blankId] : undefined
10684
+ const projection = blankSummary ? blankSummary.projectionValues : undefined
10685
+ setDialog({
10686
+ kind: 'ws-preset',
10687
+ hasBlank: blankId !== undefined,
10688
+ applyPreset: blankId !== undefined
10689
+ ? (presetId) => presetFace.select(blankId, presetId)
10690
+ // Reuse declined / no draft to reuse: create WITH the preset.
10691
+ // Shape matches select's { ok } contract; a SessionCreateError
10692
+ // lands in the dialog as an inline refusal line.
10693
+ : (presetId) => Promise.resolve()
10694
+ .then(() => sessions.create({ cwd: raw.path, agentPreset: presetId }))
10695
+ .then(() => ({ ok: true }), (reason) => ({ ok: false, refusal: reason instanceof Error ? reason.message : String(reason || 'session create failed') })),
10696
+ current: projection && typeof projection.agentPreset === 'string' ? projection.agentPreset : undefined,
10697
+ })
10698
+ }
10546
10699
  const menuEntries = () => {
10547
10700
  if (ctx === null) return []
10548
10701
  const customizeEntry = { id: 'customize', label: t('custom.title'), icon: icon('IconPersonalizationOutline16', 16) }
@@ -10624,42 +10777,7 @@ window.__ModuleLoader__.load({
10624
10777
  else if (kind === 'sgroup' && id === 'delete-sgroup') setDialog({ kind: 'sgroup-delete', target: payload })
10625
10778
  else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
10626
10779
  else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
10627
- else if (kind === 'workspace' && id === 'ws-preset' && presetFace) {
10628
- // Freeze the target NOW (the dialog outlives the menu ctx): the
10629
- // blank lookup mirrors the official createInWorkspace rule — a
10630
- // standing blank IS the workspace default (its preset projection
10631
- // is what the hero chip shows). With NO standing blank the edit
10632
- // still applies: ClientSessions.create prepares one WITH the
10633
- // picked preset (the same channel as the official new-session
10634
- // button), which makes it the workspace default. Creating a
10635
- // session here is deliberate, not a side effect — the host has
10636
- // no other per-workspace default store (agent-preset-registry
10637
- // selectedDefault is GLOBAL; the standing blank IS the carrier).
10638
- const raw = (storedItems || []).find((item) => item.workspaceId === payload.workspaceId)
10639
- const blankIds = raw && Array.isArray(raw.sessionIds) ? raw.sessionIds : []
10640
- const blankId = raw && raw.path
10641
- ? (list.ids || []).find((sid) => {
10642
- const summary = list.byId[sid]
10643
- return summary && summary.blank === true && summary.cwd === raw.path
10644
- && blankIds.indexOf(sid) !== -1 && !archivedSet.has(sid)
10645
- })
10646
- : undefined
10647
- const blankSummary = blankId !== undefined ? list.byId[blankId] : undefined
10648
- const projection = blankSummary ? blankSummary.projectionValues : undefined
10649
- setDialog({
10650
- kind: 'ws-preset',
10651
- hasBlank: blankId !== undefined,
10652
- applyPreset: blankId !== undefined
10653
- ? (presetId) => presetFace.select(blankId, presetId)
10654
- // Reuse declined / no draft to reuse: create WITH the preset.
10655
- // Shape matches select's { ok } contract; a SessionCreateError
10656
- // lands in the dialog as an inline refusal line.
10657
- : (presetId) => Promise.resolve()
10658
- .then(() => sessions.create({ cwd: raw.path, agentPreset: presetId }))
10659
- .then(() => ({ ok: true }), (reason) => ({ ok: false, refusal: reason instanceof Error ? reason.message : String(reason || 'session create failed') })),
10660
- current: projection && typeof projection.agentPreset === 'string' ? projection.agentPreset : undefined,
10661
- })
10662
- }
10780
+ else if (kind === 'workspace' && id === 'ws-preset' && presetFace) openWorkspacePresetDialog(payload)
10663
10781
  else if (kind === 'sgroup' && id === 'rename-sgroup') setDialog({ kind: 'sgroup-rename', target: payload })
10664
10782
  else if (kind === 'session') {
10665
10783
  // The shared dispatcher owns every session verb (see
@@ -10668,6 +10786,40 @@ window.__ModuleLoader__.load({
10668
10786
  }
10669
10787
  }
10670
10788
 
10789
+ // The official contribution runs outside this component, so it gets a
10790
+ // live bridge to the SAME action implementations (dialogs included).
10791
+ // The effect republishes after every render: slash switches, the preset
10792
+ // face and the current session inventory all feed availability.
10793
+ const menuBridgeValue = {
10794
+ slashes: { session: sessionSlash, workspace: workspaceSlash },
10795
+ hasPreset: () => !!presetFace,
10796
+ groupPrefix: (title) => groupPrefixOf(title),
10797
+ label: (key) => t(key),
10798
+ customize: (kind, payload) => {
10799
+ const name = kind === 'workspace' ? (payload.title || payload.leaf) : payload.title
10800
+ setCustomize({ kind, entryKey: keyOf(kind, payload), name })
10801
+ },
10802
+ moveGroup: (kind, payload) => {
10803
+ const title = kind === 'workspace' ? (payload.title || payload.leaf) : payload.title
10804
+ const target = kind === 'workspace'
10805
+ ? { kind: 'workspace', id: payload.workspaceId, title }
10806
+ : { kind: 'session', id: payload.id, title }
10807
+ setDialog({
10808
+ kind: 'group-move',
10809
+ target,
10810
+ initial: groupPrefixOf(target.title),
10811
+ groupPaths: collectGroupPaths(kind),
10812
+ })
10813
+ },
10814
+ moveOut: moveSessionOut,
10815
+ workspacePreset: (payload) => openWorkspacePresetDialog(payload),
10816
+ }
10817
+ React.useEffect(() => {
10818
+ if (typeof setMenuBridge !== 'function') return undefined
10819
+ setMenuBridge(menuBridgeValue)
10820
+ return () => setMenuBridge(null)
10821
+ })
10822
+
10671
10823
  /* --------------------------- dialogs --------------------------- */
10672
10824
  // TextDialog / ConfirmDialog are module-level components: a per-render
10673
10825
  // inline definition would remount on every parent tick and drop input.
@@ -11161,6 +11313,17 @@ window.__ModuleLoader__.load({
11161
11313
  // the handle before any flow can open.
11162
11314
  pickerState.api = uiWorkspace
11163
11315
 
11316
+ // dsh 0.2.2-alpha.3 context menus. The service is acquired SOFTLY for
11317
+ // the same reason as remote.agentPresets: a hard inject would pend the
11318
+ // whole bundle forever on older hosts. The bridge is written by the
11319
+ // browser seat through browserInjected (a stable getter), so the
11320
+ // contribution resolves against the same dialogs and switches the
11321
+ // local menu uses.
11322
+ let contextMenus = null
11323
+ let menuBridge = null
11324
+ let menusRegistered = false
11325
+ const setMenuBridge = (bridge) => { menuBridge = bridge }
11326
+
11164
11327
  // The OFFICIAL browser's own dictionary (NS 'workspace'), bound so the
11165
11328
  // context menu COPY follows upstream wording automatically — the menu
11166
11329
  // mirrors the official row menus item-for-item (Rows.tsx), and this
@@ -11263,6 +11426,126 @@ window.__ModuleLoader__.load({
11263
11426
  }
11264
11427
  }
11265
11428
 
11429
+ // The official shared menu owns session/workspace rows from alpha.3 on.
11430
+ // The built-in contributions cover pin/rename/fork/copy/archive and
11431
+ // workspace rename/delete; these two bring the bw-only verbs (name
11432
+ // groups, Customize appearance, the workspace default preset) after
11433
+ // them. The first extra carries the separator, so the popup keeps the
11434
+ // same grouping the retired local menu had.
11435
+ const withMenuSeparator = (actions) => actions.map((action, index) => index === 0
11436
+ ? Object.assign({}, action, { separatorBefore: true })
11437
+ : action)
11438
+ const registerContextMenus = (menus) => {
11439
+ if (!menus || menusRegistered) return
11440
+ if (typeof menus.register !== 'function' || typeof menus.open !== 'function') return
11441
+ menusRegistered = true
11442
+ const sessionExtras = {
11443
+ id: 'bw-session-extras',
11444
+ target: 'session',
11445
+ order: 700,
11446
+ resolve: (target) => {
11447
+ const bridge = menuBridge
11448
+ if (!bridge) return []
11449
+ const title = String((target && target.displayTitle) || '')
11450
+ const actions = []
11451
+ if (bridge.slashes && bridge.slashes.session) {
11452
+ actions.push({
11453
+ id: 'bw-move-group',
11454
+ label: bridge.label('menu.moveToGroup'),
11455
+ run: () => bridge.moveGroup('session', { id: target.sessionId, title }),
11456
+ })
11457
+ }
11458
+ if (bridge.groupPrefix && bridge.groupPrefix(title) !== '') {
11459
+ actions.push({
11460
+ id: 'bw-move-out',
11461
+ label: bridge.label('menu.moveOutGroup'),
11462
+ run: () => bridge.moveOut({ id: target.sessionId, title }),
11463
+ })
11464
+ }
11465
+ actions.push({
11466
+ id: 'bw-customize',
11467
+ label: bridge.label('custom.title'),
11468
+ run: () => bridge.customize('session', { id: target.sessionId, title }),
11469
+ })
11470
+ return withMenuSeparator(actions)
11471
+ },
11472
+ }
11473
+ const workspaceExtras = {
11474
+ id: 'bw-workspace-extras',
11475
+ target: 'workspace',
11476
+ order: 700,
11477
+ resolve: (target) => {
11478
+ const bridge = menuBridge
11479
+ if (!bridge) return []
11480
+ const title = String((target && target.displayTitle) || '')
11481
+ const payload = { workspaceId: target.workspaceId, path: target.path, title, leaf: title }
11482
+ const actions = []
11483
+ if (bridge.slashes && bridge.slashes.workspace) {
11484
+ actions.push({
11485
+ id: 'bw-move-group',
11486
+ label: bridge.label('menu.moveToGroup'),
11487
+ run: () => bridge.moveGroup('workspace', payload),
11488
+ })
11489
+ }
11490
+ actions.push({
11491
+ id: 'bw-customize',
11492
+ label: bridge.label('custom.title'),
11493
+ run: () => bridge.customize('workspace', payload),
11494
+ })
11495
+ if (bridge.hasPreset && bridge.hasPreset()) {
11496
+ actions.push({
11497
+ id: 'bw-ws-preset',
11498
+ label: bridge.label('menu.workspacePreset'),
11499
+ run: () => bridge.workspacePreset(payload),
11500
+ })
11501
+ }
11502
+ return withMenuSeparator(actions)
11503
+ },
11504
+ }
11505
+ try {
11506
+ ctx.effect(() => {
11507
+ const disposers = []
11508
+ try { disposers.push(menus.register(sessionExtras)) } catch (registerError) {
11509
+ console.warn('[dsh-better-workspace] session context menu registration failed', registerError)
11510
+ }
11511
+ try { disposers.push(menus.register(workspaceExtras)) } catch (registerError) {
11512
+ console.warn('[dsh-better-workspace] workspace context menu registration failed', registerError)
11513
+ }
11514
+ return () => {
11515
+ for (const dispose of disposers) {
11516
+ try { dispose() } catch { /* already withdrawn */ }
11517
+ }
11518
+ }
11519
+ }, 'better-workspace: context menu contributions')
11520
+ } catch (effectError) {
11521
+ console.warn('[dsh-better-workspace] context menu effect failed', effectError)
11522
+ }
11523
+ }
11524
+ const adoptContextMenus = (candidate) => {
11525
+ if (!candidate || typeof candidate.register !== 'function' || typeof candidate.open !== 'function') return
11526
+ const changed = contextMenus !== candidate
11527
+ contextMenus = candidate
11528
+ registerContextMenus(candidate)
11529
+ if (changed) notifyMenuFace()
11530
+ }
11531
+ try { adoptContextMenus(ctx.get('contextMenus')) } catch (contextMenuProbeError) {
11532
+ console.warn('[dsh-better-workspace] context menu probe failed', contextMenuProbeError)
11533
+ }
11534
+ if (!contextMenus && ctx && typeof ctx.inject === 'function') {
11535
+ // Late arrival: the shared menu service mounts after this bundle on
11536
+ // some boots. A soft child fiber waits for it; old hosts simply keep
11537
+ // the fiber pending and the local menu stays in charge.
11538
+ try {
11539
+ ctx.inject(['contextMenus'], (fctx) => {
11540
+ try { adoptContextMenus(fctx && fctx.contextMenus) } catch (lateError) {
11541
+ console.warn('[dsh-better-workspace] context menu late face failed', lateError)
11542
+ }
11543
+ })
11544
+ } catch (injectError) {
11545
+ console.warn('[dsh-better-workspace] context menu soft inject failed', injectError)
11546
+ }
11547
+ }
11548
+
11266
11549
  if (ctx.locale && typeof ctx.locale.register === 'function') {
11267
11550
  ctx.effect(() => {
11268
11551
  try {
@@ -11346,6 +11629,12 @@ window.__ModuleLoader__.load({
11346
11629
  // closure live, so every render and every menu build re-asks; hosts
11347
11630
  // without the service keep returning null, which simply hides the row.
11348
11631
  presetFaceOf: () => presetFace,
11632
+ // dsh 0.2.2-alpha.3: the shared context-menu service (getter for the
11633
+ // same late-arrival reason as presetFaceOf; null on older hosts, which
11634
+ // keep the local menu) and the live action bridge its contribution
11635
+ // reads. The getter is what openCtx probes on every right-click.
11636
+ contextMenuFaceOf: () => contextMenus,
11637
+ setMenuBridge,
11349
11638
  // Issue #13: the PRIVATE store face — a stable getter like presetFaceOf
11350
11639
  // (the slot renderer caches inject results; keep the same discipline
11351
11640
  // even though this face never changes).