dsh-coding-sidebar 1.0.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.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +524 -0
  3. package/cordis.patch.yml +49 -0
  4. package/lib/client-editor.js +48229 -0
  5. package/lib/client-mermaid.js +200745 -0
  6. package/lib/client-registry.js +21867 -0
  7. package/lib/client-terminal.js +9361 -0
  8. package/lib/client.js +21867 -0
  9. package/lib/index.js +4556 -0
  10. package/lib/invariant.js +22 -0
  11. package/lib/types/agent-opens.d.ts +65 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +39 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +13 -0
  19. package/lib/types/client/FileTree.d.ts +38 -0
  20. package/lib/types/client/FreeWindow.d.ts +29 -0
  21. package/lib/types/client/GitView.d.ts +10 -0
  22. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  23. package/lib/types/client/OrphanedTab.d.ts +11 -0
  24. package/lib/types/client/PdfView.d.ts +6 -0
  25. package/lib/types/client/RenderBoundary.d.ts +32 -0
  26. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  27. package/lib/types/client/SideCardSection.d.ts +74 -0
  28. package/lib/types/client/SideChatView.d.ts +16 -0
  29. package/lib/types/client/Sidebar.d.ts +6 -0
  30. package/lib/types/client/SubagentView.d.ts +16 -0
  31. package/lib/types/client/TabBar.d.ts +54 -0
  32. package/lib/types/client/TerminalView.d.ts +24 -0
  33. package/lib/types/client/TextEditor.d.ts +10 -0
  34. package/lib/types/client/TreePanel.d.ts +24 -0
  35. package/lib/types/client/UploadOverlay.d.ts +20 -0
  36. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  37. package/lib/types/client/api.d.ts +275 -0
  38. package/lib/types/client/binary-download.d.ts +12 -0
  39. package/lib/types/client/breakpoints.d.ts +17 -0
  40. package/lib/types/client/browser.d.ts +60 -0
  41. package/lib/types/client/builtins/index.d.ts +18 -0
  42. package/lib/types/client/builtins/tabs.d.ts +11 -0
  43. package/lib/types/client/builtins/viewers.d.ts +3 -0
  44. package/lib/types/client/chunk-loader.d.ts +131 -0
  45. package/lib/types/client/chunks/editor.d.ts +9 -0
  46. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  47. package/lib/types/client/chunks/terminal.d.ts +9 -0
  48. package/lib/types/client/cm-themes.d.ts +24 -0
  49. package/lib/types/client/conversation-draft.d.ts +14 -0
  50. package/lib/types/client/desktop-env.d.ts +35 -0
  51. package/lib/types/client/editor-load.d.ts +66 -0
  52. package/lib/types/client/frame-batcher.d.ts +24 -0
  53. package/lib/types/client/icons.d.ts +59 -0
  54. package/lib/types/client/image-types.d.ts +3 -0
  55. package/lib/types/client/ime-guard.d.ts +36 -0
  56. package/lib/types/client/index.d.ts +25 -0
  57. package/lib/types/client/intercept.d.ts +46 -0
  58. package/lib/types/client/lang.d.ts +14 -0
  59. package/lib/types/client/layout-push.d.ts +15 -0
  60. package/lib/types/client/lazy-chunk.d.ts +25 -0
  61. package/lib/types/client/link-intercept.d.ts +42 -0
  62. package/lib/types/client/locales-ar.d.ts +18 -0
  63. package/lib/types/client/locales-de.d.ts +4 -0
  64. package/lib/types/client/locales-fr.d.ts +10 -0
  65. package/lib/types/client/locales-hi.d.ts +17 -0
  66. package/lib/types/client/locales-id.d.ts +15 -0
  67. package/lib/types/client/locales-it.d.ts +8 -0
  68. package/lib/types/client/locales-ja.d.ts +17 -0
  69. package/lib/types/client/locales-ko.d.ts +9 -0
  70. package/lib/types/client/locales-nl.d.ts +16 -0
  71. package/lib/types/client/locales-pl.d.ts +19 -0
  72. package/lib/types/client/locales-pt.d.ts +1 -0
  73. package/lib/types/client/locales-ru.d.ts +13 -0
  74. package/lib/types/client/locales-sv.d.ts +1 -0
  75. package/lib/types/client/locales-th.d.ts +17 -0
  76. package/lib/types/client/locales-tr.d.ts +17 -0
  77. package/lib/types/client/locales-vi.d.ts +17 -0
  78. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  79. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  80. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  81. package/lib/types/client/locales.d.ts +452 -0
  82. package/lib/types/client/markdown-html.d.ts +96 -0
  83. package/lib/types/client/markdown-images.d.ts +45 -0
  84. package/lib/types/client/markdown-labels.d.ts +38 -0
  85. package/lib/types/client/md-toc.d.ts +4 -0
  86. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  87. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  88. package/lib/types/client/mermaid.d.ts +12 -0
  89. package/lib/types/client/open-when-sized.d.ts +20 -0
  90. package/lib/types/client/open-with-settings.d.ts +4 -0
  91. package/lib/types/client/open-with.d.ts +87 -0
  92. package/lib/types/client/openpath-intercept.d.ts +85 -0
  93. package/lib/types/client/paths.d.ts +43 -0
  94. package/lib/types/client/pdf-types.d.ts +2 -0
  95. package/lib/types/client/pinned.d.ts +86 -0
  96. package/lib/types/client/plugin-settings.d.ts +8 -0
  97. package/lib/types/client/plugins-shared.d.ts +27 -0
  98. package/lib/types/client/plugins-tabs.d.ts +3 -0
  99. package/lib/types/client/plugins-viewers.d.ts +3 -0
  100. package/lib/types/client/prefs.d.ts +41 -0
  101. package/lib/types/client/produced-files.d.ts +29 -0
  102. package/lib/types/client/selection-payload.d.ts +27 -0
  103. package/lib/types/client/service.d.ts +439 -0
  104. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  105. package/lib/types/client/shell-presets.d.ts +48 -0
  106. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  107. package/lib/types/client/split-pane.d.ts +36 -0
  108. package/lib/types/client/state.d.ts +432 -0
  109. package/lib/types/client/subagent-detect.d.ts +54 -0
  110. package/lib/types/client/subagent-jobs.d.ts +63 -0
  111. package/lib/types/client/tab-content-memo.d.ts +40 -0
  112. package/lib/types/client/terminal-font.d.ts +78 -0
  113. package/lib/types/client/terminal-links.d.ts +119 -0
  114. package/lib/types/client/theme.d.ts +45 -0
  115. package/lib/types/client/titlebar-strip.d.ts +23 -0
  116. package/lib/types/client/upload.d.ts +69 -0
  117. package/lib/types/client/wco.d.ts +47 -0
  118. package/lib/types/config.d.ts +64 -0
  119. package/lib/types/context-types.d.ts +605 -0
  120. package/lib/types/fs-operations.d.ts +28 -0
  121. package/lib/types/fs-search.d.ts +24 -0
  122. package/lib/types/fs-tree.d.ts +51 -0
  123. package/lib/types/git.d.ts +124 -0
  124. package/lib/types/html-route.d.ts +59 -0
  125. package/lib/types/index.d.ts +47 -0
  126. package/lib/types/invariant.d.ts +15 -0
  127. package/lib/types/jobs-routes.d.ts +45 -0
  128. package/lib/types/open-external.d.ts +24 -0
  129. package/lib/types/path-security.d.ts +20 -0
  130. package/lib/types/prefs-shared.d.ts +236 -0
  131. package/lib/types/pty-deps.d.ts +78 -0
  132. package/lib/types/pty-manager.d.ts +168 -0
  133. package/lib/types/sidechat-core.d.ts +172 -0
  134. package/lib/types/sidechat-routes.d.ts +30 -0
  135. package/lib/types/subagent-activity.d.ts +44 -0
  136. package/lib/types/subagent-live-route.d.ts +44 -0
  137. package/lib/types/tools.d.ts +34 -0
  138. package/lib/types/trust-fence.d.ts +24 -0
  139. package/lib/types/wire.d.ts +38 -0
  140. package/package.json +172 -0
  141. package/scripts/install.ps1 +306 -0
  142. package/scripts/install.sh +287 -0
  143. package/src/agent-opens.ts +265 -0
  144. package/src/agent-pty.ts +527 -0
  145. package/src/browser-probe.ts +26 -0
  146. package/src/bundle-route.ts +129 -0
  147. package/src/client/BrowserView.tsx +289 -0
  148. package/src/client/DiffTab.tsx +114 -0
  149. package/src/client/DiffView.tsx +305 -0
  150. package/src/client/EditorHost.tsx +558 -0
  151. package/src/client/FileTree.tsx +669 -0
  152. package/src/client/FreeWindow.tsx +337 -0
  153. package/src/client/GitView.tsx +717 -0
  154. package/src/client/MarkdownHtml.tsx +296 -0
  155. package/src/client/OrphanedTab.tsx +27 -0
  156. package/src/client/PdfView.tsx +110 -0
  157. package/src/client/RenderBoundary.tsx +49 -0
  158. package/src/client/SandboxStatusBar.tsx +60 -0
  159. package/src/client/SideCardSection.module.css +960 -0
  160. package/src/client/SideCardSection.tsx +1163 -0
  161. package/src/client/SideChatView.module.css +437 -0
  162. package/src/client/SideChatView.tsx +645 -0
  163. package/src/client/Sidebar.tsx +1370 -0
  164. package/src/client/SubagentView.module.css +547 -0
  165. package/src/client/SubagentView.tsx +883 -0
  166. package/src/client/TabBar.tsx +365 -0
  167. package/src/client/TerminalView.tsx +424 -0
  168. package/src/client/TextEditor.tsx +468 -0
  169. package/src/client/TreePanel.tsx +275 -0
  170. package/src/client/UploadOverlay.tsx +62 -0
  171. package/src/client/add-plugin-modal.tsx +199 -0
  172. package/src/client/api.ts +357 -0
  173. package/src/client/binary-download.tsx +23 -0
  174. package/src/client/breakpoints.ts +61 -0
  175. package/src/client/browser.ts +167 -0
  176. package/src/client/builtins/index.ts +37 -0
  177. package/src/client/builtins/tabs.tsx +340 -0
  178. package/src/client/builtins/viewers.tsx +125 -0
  179. package/src/client/chunk-loader.ts +358 -0
  180. package/src/client/chunks/editor.tsx +9 -0
  181. package/src/client/chunks/mermaid.tsx +10 -0
  182. package/src/client/chunks/terminal.tsx +9 -0
  183. package/src/client/cm-themes.ts +129 -0
  184. package/src/client/conversation-draft.ts +29 -0
  185. package/src/client/css-modules.d.ts +5 -0
  186. package/src/client/desktop-env.ts +72 -0
  187. package/src/client/editor-load.ts +92 -0
  188. package/src/client/frame-batcher.ts +56 -0
  189. package/src/client/icons.tsx +152 -0
  190. package/src/client/image-types.ts +8 -0
  191. package/src/client/ime-guard.ts +47 -0
  192. package/src/client/index.tsx +427 -0
  193. package/src/client/intercept.tsx +188 -0
  194. package/src/client/lang.ts +225 -0
  195. package/src/client/layout-push.ts +24 -0
  196. package/src/client/layout.css +90 -0
  197. package/src/client/lazy-chunk.tsx +89 -0
  198. package/src/client/link-intercept.ts +73 -0
  199. package/src/client/locales-ar.ts +368 -0
  200. package/src/client/locales-de.ts +353 -0
  201. package/src/client/locales-fr.ts +360 -0
  202. package/src/client/locales-hi.ts +367 -0
  203. package/src/client/locales-id.ts +365 -0
  204. package/src/client/locales-it.ts +358 -0
  205. package/src/client/locales-ja.ts +367 -0
  206. package/src/client/locales-ko.ts +359 -0
  207. package/src/client/locales-nl.ts +365 -0
  208. package/src/client/locales-pl.ts +369 -0
  209. package/src/client/locales-pt.ts +350 -0
  210. package/src/client/locales-ru.ts +365 -0
  211. package/src/client/locales-sv.ts +350 -0
  212. package/src/client/locales-th.ts +367 -0
  213. package/src/client/locales-tr.ts +367 -0
  214. package/src/client/locales-vi.ts +367 -0
  215. package/src/client/locales-zh-HK.ts +382 -0
  216. package/src/client/locales-zh-MO.ts +382 -0
  217. package/src/client/locales-zh-TW.ts +382 -0
  218. package/src/client/locales.ts +894 -0
  219. package/src/client/markdown-html.ts +331 -0
  220. package/src/client/markdown-images.ts +137 -0
  221. package/src/client/markdown-labels.tsx +52 -0
  222. package/src/client/md-toc.tsx +127 -0
  223. package/src/client/mermaid-blocks.ts +110 -0
  224. package/src/client/mermaid-sanitize.ts +93 -0
  225. package/src/client/mermaid.tsx +406 -0
  226. package/src/client/open-when-sized.ts +43 -0
  227. package/src/client/open-with-settings.tsx +135 -0
  228. package/src/client/open-with.ts +221 -0
  229. package/src/client/openpath-intercept.ts +143 -0
  230. package/src/client/paths.ts +63 -0
  231. package/src/client/pdf-types.ts +4 -0
  232. package/src/client/pinned.ts +183 -0
  233. package/src/client/plugin-settings.ts +43 -0
  234. package/src/client/plugins-shared.ts +29 -0
  235. package/src/client/plugins-tabs.ts +96 -0
  236. package/src/client/plugins-viewers.ts +30 -0
  237. package/src/client/prefs.ts +210 -0
  238. package/src/client/produced-files.ts +120 -0
  239. package/src/client/selection-payload.ts +86 -0
  240. package/src/client/service.ts +859 -0
  241. package/src/client/settings-nav-icon.ts +45 -0
  242. package/src/client/shell-presets.ts +83 -0
  243. package/src/client/sidebar.module.css +2788 -0
  244. package/src/client/sidechat-transcript.ts +321 -0
  245. package/src/client/split-pane.tsx +331 -0
  246. package/src/client/state.ts +1497 -0
  247. package/src/client/subagent-detect.ts +137 -0
  248. package/src/client/subagent-jobs.ts +161 -0
  249. package/src/client/tab-content-memo.ts +55 -0
  250. package/src/client/terminal-font.ts +224 -0
  251. package/src/client/terminal-links.ts +194 -0
  252. package/src/client/theme.ts +110 -0
  253. package/src/client/titlebar-strip.ts +37 -0
  254. package/src/client/upload.ts +187 -0
  255. package/src/client/wco.ts +120 -0
  256. package/src/config.ts +154 -0
  257. package/src/context-types.ts +611 -0
  258. package/src/fs-operations.ts +95 -0
  259. package/src/fs-search.ts +113 -0
  260. package/src/fs-tree.ts +158 -0
  261. package/src/git.ts +446 -0
  262. package/src/html-route.ts +106 -0
  263. package/src/index.ts +1291 -0
  264. package/src/invariant.ts +32 -0
  265. package/src/jobs-routes.ts +257 -0
  266. package/src/open-external.ts +90 -0
  267. package/src/path-security.ts +75 -0
  268. package/src/prefs-shared.ts +280 -0
  269. package/src/pty-deps.ts +240 -0
  270. package/src/pty-manager.ts +376 -0
  271. package/src/sidechat-core.ts +468 -0
  272. package/src/sidechat-routes.ts +344 -0
  273. package/src/subagent-activity.ts +91 -0
  274. package/src/subagent-live-route.ts +93 -0
  275. package/src/tools.ts +482 -0
  276. package/src/trust-fence.ts +84 -0
  277. package/src/wire.ts +100 -0
@@ -0,0 +1,1370 @@
1
+ /**
2
+ * The sidebar shell: a panel mounted inside the unified panel host — a
3
+ * fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
4
+ * to document.body — instead of an individual fixed-position element, so a
5
+ * desktop shell's intermediate wrapper transforms can never hijack the
6
+ * panel's fixed containing block (the core AppFrame owns the left sidebar /
7
+ * center / details columns and has no right-side hole for plugins). The
8
+ * panel hosts the workbench; a persistent toggle button at the top-right
9
+ * corner opens/closes it, and its width drags from the left edge. The whole
10
+ * layout lives in the per-session store, so switching conversations swaps
11
+ * the sidebar.
12
+ *
13
+ * The shell binds the workbench actions to the store and dispatches tab
14
+ * content to the views. New tabs come from the + menu (explorer / git /
15
+ * terminal; editors open from the explorer). Tabs can also be dragged out
16
+ * of the panel onto the conversation area as free windows.
17
+ *
18
+ * Narrow (mobile, <768px) viewports turn the panel into a full-width
19
+ * drawer: the layout push is disabled (the drawer floats over the app
20
+ * shell) and the width drag strip is not offered — a full-screen sheet has
21
+ * nothing to drag.
22
+ */
23
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
24
+ import { useSyncExternalStore } from 'react'
25
+ import clsx from 'clsx'
26
+ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
27
+ import type { Context, SidebarSessionList } from '../context-types.ts'
28
+ import { appendToDraft } from './conversation-draft.ts'
29
+ import {
30
+ PANEL_MIN, activateTab, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
31
+ isAgentTabId, leafWithTab,
32
+ moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
33
+ resizeFloat, resizeSplitIn, setTabPin, setWidth, toggleExpanded, togglePanel,
34
+ type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
35
+ } from './state.ts'
36
+ import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
37
+ import { IconPinOutline16 } from './icons.tsx'
38
+ import { IconPanelRightOutline16 } from './icons.tsx'
39
+ import { Workbench, type WorkbenchActions } from './split-pane.tsx'
40
+ import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
41
+ import { layoutPushSize } from './layout-push.ts'
42
+ import { parseDesktopEnv } from './desktop-env.ts'
43
+ import { getWcoSnapshot, subscribeWco } from './wco.ts'
44
+ import { getShellPreset } from './shell-presets.ts'
45
+ import { computeTitleBarStrip } from './titlebar-strip.ts'
46
+ import type { NewTabOption } from './TabBar.tsx'
47
+ import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
48
+ import { FreeWindow } from './FreeWindow.tsx'
49
+ import { relativeTo } from './paths.ts'
50
+ import { OrphanedTab } from './OrphanedTab.tsx'
51
+ import { RenderBoundary } from './RenderBoundary.tsx'
52
+ import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
53
+ import { detectNewDirectSubagent } from './subagent-detect.ts'
54
+ import { detectNewJob } from './subagent-jobs.ts'
55
+ import { t } from './locales.ts'
56
+ import { api, type SessionScope } from './api.ts'
57
+ import css from './sidebar.module.css'
58
+
59
+ /** How many consecutive reconnect failures stop the agent-terminals push loop
60
+ * (mirror of the terminal view's own cap; the loop restarts on session switch). */
61
+ const FAILURE_LIMIT = 3
62
+
63
+ /**
64
+ * Subagent auto-open debounce (ms). The host delivers a new child's origin
65
+ * and its title in SEPARATE frames: a Side Chat thread's first visible
66
+ * frame still shows a fallback title (no 'Side: ' prefix), so an immediate
67
+ * 0→N decision mistakes it for a genuine subagent and pops the task page.
68
+ * The trigger therefore re-evaluates against the live snapshot once the
69
+ * title frame has had time to land.
70
+ */
71
+ const AUTO_OPEN_DEBOUNCE_MS = 500
72
+
73
+ /**
74
+ * OS file drags over the sidebar belong to the sidebar, not to the chat:
75
+ * DSH's composer (InputBar) listens for file drags on the DOCUMENT and
76
+ * answers with a full-screen "drop image here" mask plus image intake on
77
+ * drop. Both panel-host render sites swallow the whole event quartet —
78
+ * enter/over/leave/drop — so the region is a black hole to that document
79
+ * listener. All four must be stopped: InputBar keeps an enter/leave depth
80
+ * counter, and a leave that escapes without its matching enter unbalances
81
+ * the count (this was the full-screen mask flickering over the sidebar).
82
+ * The conversation column keeps DSH's native overlay and intake untouched;
83
+ * gated on the 'Files' type so in-app drags (tab reorder, split zones)
84
+ * propagate exactly as before.
85
+ */
86
+ const swallowOsFileDrag = (event: ReactDragEvent): void => {
87
+ if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
88
+ event.preventDefault()
89
+ event.stopPropagation()
90
+ }
91
+
92
+ /** The four drag events a file drag must never carry past the panel host. */
93
+ const osFileDragShield = {
94
+ onDragEnter: swallowOsFileDrag,
95
+ onDragOver: swallowOsFileDrag,
96
+ onDragLeave: swallowOsFileDrag,
97
+ onDrop: swallowOsFileDrag,
98
+ }
99
+
100
+ /**
101
+ * Append one user-space stylesheet (preset or custom CSS) as a tagged
102
+ * `<style>` element. The tag attribute carries the source identity so the
103
+ * running configuration is inspectable in DevTools; the returned tag is
104
+ * removed by the caller's effect cleanup.
105
+ */
106
+ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
107
+ const tag = document.createElement('style')
108
+ tag.setAttribute(attr, id)
109
+ tag.textContent = cssText
110
+ document.head.appendChild(tag)
111
+ return tag
112
+ }
113
+
114
+ /** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
115
+ * the runtime objects/callbacks the cell renders with. The memo comparator
116
+ * is the pure `tabContentCompare`; anything in the key decides a re-render
117
+ * must propagate, anything outside it must be a stable object (ctx/store)
118
+ * or covered by a compared field (paneId covers onOpenDiff's captured
119
+ * pane; sessionId/cwd cover onReferenceFile). */
120
+ interface TabContentProps extends TabContentMemoKey {
121
+ onToggleDir: (path: string) => void
122
+ onReferenceFile: (path: string) => void
123
+ ctx: Context
124
+ store: SidebarStore
125
+ /** Fired before a topology node jumps to its child session (see Sidebar). */
126
+ onSubagentJump: (childSessionId: string) => void
127
+ /** Open a diff tab from the git panel (placement handled by the store). */
128
+ onOpenDiff: (tab: SidebarTab) => void
129
+ }
130
+
131
+ /** Render the content of one tab (dispatched by type). */
132
+ const TabContent = memo(function TabContent(props: TabContentProps) {
133
+ const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
134
+ const scope = { sessionId, cwd }
135
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
136
+ if (descriptor === undefined) {
137
+ return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
138
+ }
139
+ // For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
140
+ // must receive the ORIGINAL tab id so it connects to the home session's PTY.
141
+ // The virtual tab's own id is a unique display key (prefixed); effectiveTabId
142
+ // restores the real id at the component boundary.
143
+ const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
144
+ return createElement(
145
+ RenderBoundary,
146
+ { className: css.tabBoundaryError },
147
+ createElement(descriptor.component, {
148
+ ctx, store, scope, tab: componentTab, visible, expanded, revealed,
149
+ onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
150
+ }),
151
+ )
152
+ }, tabContentCompare)
153
+
154
+ /** The + menu options for the current state, driven by the tab registry.
155
+ * Hidden tabs (editor/diff) never show; `available` returning false shows
156
+ * a disabled row (e.g. terminal at capacity) instead of hiding the option.
157
+ * Tabs the user disabled in the side card settings are filtered out
158
+ * entirely — re-enabling them is the settings page's job. */
159
+ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
160
+ const service = ctx.get('betterSidebar')
161
+ if (service === undefined) return []
162
+ return service.getTabs()
163
+ .filter(d => !d.hidden && service.isTabEnabled(d.id))
164
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
165
+ .map(d => ({
166
+ id: d.id,
167
+ label: typeof d.title === 'function' ? d.title() : d.title,
168
+ disabled: !(d.available?.(ctx, scope, state) ?? true),
169
+ icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
170
+ }))
171
+ }
172
+
173
+ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
174
+ const { ctx, store } = props
175
+
176
+ // Copy freshness: re-render the whole tree when the DSH locale switches.
177
+ // The module-level t() reads the active locale at call time, so a root
178
+ // re-render alone re-localizes every panel (no memo barriers below).
179
+ const localeRevision = useSyncExternalStore(
180
+ useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
181
+ useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
182
+ )
183
+ void localeRevision
184
+
185
+ // better-locale override freshness: when @huanlin/dsh-plugin-better-locale
186
+ // is installed and the user picks an override language (e.g. ja), the
187
+ // store's `active` changes but the DSH locale's `active` does NOT —
188
+ // better-locale keeps the dsh active value (zh/en) unchanged and only
189
+ // patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES
190
+ // above reads `getSnapshot().active`, so it sees no change and skips
191
+ // re-render. This second uSES reads the better-locale store's `active`
192
+ // directly, so an override switch fires a full re-render and t() picks
193
+ // up the new override text. Optional: ctx.get returns undefined when
194
+ // better-locale is absent (or when ctx is a minimal test mock without
195
+ // a `get` method), in which case this is a no-op uSES.
196
+ type BetterLocaleStore = {
197
+ readonly active: string | undefined
198
+ subscribe(listener: () => void): () => void
199
+ }
200
+ const betterLocaleStore = typeof ctx.get === 'function'
201
+ ? (ctx as unknown as {
202
+ get(name: 'betterLocale'): BetterLocaleStore | undefined
203
+ }).get('betterLocale')
204
+ : undefined
205
+ const betterLocaleActive = useSyncExternalStore(
206
+ useMemo(() => {
207
+ const store = betterLocaleStore
208
+ if (store === undefined) return (_cb: () => void) => () => {}
209
+ return (callback: () => void) => store.subscribe(callback)
210
+ }, [betterLocaleStore]),
211
+ useMemo(() => {
212
+ const store = betterLocaleStore
213
+ if (store === undefined) return () => undefined
214
+ return () => store.active
215
+ }, [betterLocaleStore]),
216
+ )
217
+ void betterLocaleActive
218
+
219
+ // Tab-registry revision: TabContent memo cells must pick up a descriptor
220
+ // a plugin registers/disposes after mount (the + menu / icons already read
221
+ // the registry at render). Rare events (plugin (un)mount), so one full
222
+ // re-render per change is fine — this is what keeps the memoized cells
223
+ // from going stale, mirroring the localeRevision mechanism above.
224
+ const [tabsVersion, setTabsVersion] = useState(0)
225
+ useEffect(() => {
226
+ const service = ctx.get('betterSidebar')
227
+ if (service === undefined) return
228
+ return service.subscribe(() => setTabsVersion(version => version + 1))
229
+ }, [ctx])
230
+
231
+ // Narrow (mobile) viewports turn the panel into a full-width drawer: the
232
+ // layout push is disabled (the drawer floats over the app shell) and the
233
+ // width drag strip is not offered.
234
+ const viewport = useViewportSize()
235
+ const narrow = isNarrowWidth(viewport.width)
236
+
237
+ // On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
238
+ // when the visual viewport shrinks below the layout viewport, the
239
+ // bottom-anchored drawer would hide under the keyboard. Track the inset
240
+ // and offset the drawer by it. The obscured bottom strip is
241
+ // innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
242
+ // the visual viewport is scrolled/zoomed under browser chrome, so
243
+ // omitting it would over-lift the drawer (CR #232 P2). offsetTop changes
244
+ // through the viewport's scroll event too, so both events are listened.
245
+ // Guarded: browsers without visualViewport (older WebViews, jsdom) stay
246
+ // at 0. rAF-throttled, same pattern as useNarrowViewport.
247
+ const [keyboardInset, setKeyboardInset] = useState(0)
248
+ useEffect(() => {
249
+ const vv = window.visualViewport
250
+ if (vv === null || vv === undefined) return
251
+ let frame: number | null = null
252
+ const measure = (): void => {
253
+ frame = null
254
+ const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
255
+ setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
256
+ }
257
+ const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
258
+ vv.addEventListener('resize', onResize)
259
+ vv.addEventListener('scroll', onResize)
260
+ measure()
261
+ return () => {
262
+ vv.removeEventListener('resize', onResize)
263
+ vv.removeEventListener('scroll', onResize)
264
+ if (frame !== null) cancelAnimationFrame(frame)
265
+ }
266
+ }, [])
267
+ // Current conversation (the sessions list feed).
268
+ const sessionList = useSyncExternalStore(
269
+ useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
270
+ useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
271
+ )
272
+ const current = sessionList.current
273
+
274
+ // Per-session sidebar state.
275
+ const snapshot = useSyncExternalStore(
276
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
277
+ useCallback(() => store.getSnapshot(), [store]),
278
+ )
279
+ useEffect(() => { store.setSession(current) }, [current, store])
280
+
281
+ const state = snapshot.state
282
+ const sessionId = snapshot.sessionId
283
+ const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
284
+
285
+ // The collapsed toggle cluster reclaims the top-right corner, so the DSH
286
+ // session header's right-aligned utilities (the "Session log" download
287
+ // capsule) must yield. layout.css keys off this body attribute to push the
288
+ // header's right padding out past the cluster. Only the CLOSED panel needs
289
+ // it — an open panel already squeezes `#root` left, moving the header clear.
290
+ const collapsed = state === undefined || !state.panelOpen
291
+ useEffect(() => {
292
+ if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
293
+ else document.body.removeAttribute('data-dsh-sidebar-collapsed')
294
+ return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
295
+ }, [collapsed])
296
+
297
+ // Title-bar / shell compatibility (the "位置兼容模式" scheme):
298
+ // auto — CONSERVATIVE: only the standard Window Controls Overlay
299
+ // geometry contributes (the real caption-overlay height,
300
+ // reactive to maximize/restore). No URL stamp, no preset, no
301
+ // guess — plain browsers see zero modification.
302
+ // preset — an opt-in built-in shell preset (shell-presets.ts) adds its
303
+ // per-shell strip as the no-WCO fallback.
304
+ // custom — the user's own CSS (injected below) + the legacy manual
305
+ // strip px.
306
+ // The resolved strip drives the SAME body attribute + CSS variable as the
307
+ // legacy boolean did, so the CSS contract is unchanged (layout.css /
308
+ // sidebar.module.css); only the value source changed. The cleanup removes
309
+ // both on unmount/boundary swap so a crashed sidebar never leaves them
310
+ // behind.
311
+ const desktopEnv = parseDesktopEnv()
312
+ const wco = useSyncExternalStore(
313
+ useMemo(() => subscribeWco, []),
314
+ getWcoSnapshot,
315
+ )
316
+ const scheme = snapshot.prefs.titleBarScheme
317
+ const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
318
+ const titleBarStrip = computeTitleBarStrip(
319
+ desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
320
+ )
321
+ const titleBarCompat = titleBarStrip > 0
322
+ useEffect(() => {
323
+ const root = document.documentElement
324
+ if (titleBarCompat) {
325
+ document.body.setAttribute('data-dsh-title-bar-compat', '')
326
+ root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
327
+ } else {
328
+ document.body.removeAttribute('data-dsh-title-bar-compat')
329
+ root.style.removeProperty('--dsh-title-bar-strip')
330
+ }
331
+ return () => {
332
+ document.body.removeAttribute('data-dsh-title-bar-compat')
333
+ root.style.removeProperty('--dsh-title-bar-strip')
334
+ }
335
+ }, [titleBarCompat, titleBarStrip])
336
+
337
+ // User-space CSS injection (the escape hatch): preset CSS (scheme
338
+ // `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
339
+ // the plugin's own styles — later in the cascade wins ties, and
340
+ // `!important` can override the JS-written inline strip variable. Each
341
+ // source gets its own tagged <style> so the running configuration stays
342
+ // inspectable; tags are removed on change/unmount so a stale stylesheet
343
+ // never outlives its fiber (HMR-safe).
344
+ const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
345
+ const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
346
+ useEffect(() => {
347
+ const tags: HTMLStyleElement[] = []
348
+ if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
349
+ if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
350
+ return () => { for (const tag of tags) tag.remove() }
351
+ }, [presetCss, customCss, preset?.id])
352
+
353
+ // While the session's header is still hydrating (or the session is blank),
354
+ // the list summary may carry no cwd; ask the host once (it falls back to
355
+ // the process cwd) so the explorer root and terminal cwd are real from
356
+ // first paint instead of showing "no session".
357
+ const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
358
+ useEffect(() => {
359
+ setFetchedCwd(undefined)
360
+ if (sessionId === undefined || summaryCwd !== undefined) return
361
+ let cancelled = false
362
+ api.sessionCwd({ sessionId })
363
+ .then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
364
+ .catch(() => { /* the explorer/git rows surface their own errors */ })
365
+ return () => { cancelled = true }
366
+ }, [sessionId, summaryCwd])
367
+ const cwd = summaryCwd ?? fetchedCwd
368
+
369
+ /**
370
+ * Agent terminals push: subscribe to the host's live list of agent-owned
371
+ * terminals for this session (created by the model through the
372
+ * `terminal_create` tool). The host pushes a JSON array on every
373
+ * create / close / exit; the sidebar reconciles the list into tabs
374
+ * (id `agent:<uuid>`, title from the agent). A disconnected socket
375
+ * retries with a short backoff so a refresh or transient drop reattaches
376
+ * the same shell without losing the agent's work — capped like the
377
+ * terminal view's own reconnect loop, so a refused endpoint never spins
378
+ * forever (the next session switch restarts the loop).
379
+ * While the terminal tab type is disabled in settings, pushes are
380
+ * ignored (no auto-added tabs); re-enabling makes the next push converge.
381
+ */
382
+ useEffect(() => {
383
+ if (sessionId === undefined) return
384
+ let socket: WebSocket | null = null
385
+ let retry: number | undefined
386
+ let closed = false
387
+ let failures = 0
388
+ const connect = (): void => {
389
+ if (closed) return
390
+ const url = new URL('/sidebar/ws/agent-terminals', location.origin)
391
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
392
+ url.search = new URLSearchParams({ sessionId }).toString()
393
+ socket = new WebSocket(url.toString())
394
+ socket.onmessage = (event) => {
395
+ if (typeof event.data !== 'string') return
396
+ try {
397
+ const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
398
+ if (!Array.isArray(list)) return
399
+ store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
400
+ ? s
401
+ : reconcileAgentTerminals(s, list))
402
+ } catch {
403
+ // Malformed push: ignore (the next push will reconcile).
404
+ }
405
+ }
406
+ socket.onclose = () => {
407
+ if (closed) return
408
+ failures += 1
409
+ if (failures >= FAILURE_LIMIT) {
410
+ console.error('[dsh-coding-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
411
+ return
412
+ }
413
+ retry = window.setTimeout(connect, 2000)
414
+ }
415
+ socket.onerror = () => { socket?.close() }
416
+ }
417
+ connect()
418
+ return () => {
419
+ closed = true
420
+ window.clearTimeout(retry)
421
+ socket?.close()
422
+ }
423
+ }, [sessionId, store])
424
+
425
+ /**
426
+ * Agent opens push: subscribe to the host's `sidebar_open` requests for
427
+ * this session (the model actively opens a file / folder / HTTP(S) page).
428
+ * The host pushes one JSON request per open; the sidebar routes it to the
429
+ * matching built-in tab: a file opens in the editor (per-path dedupe), a
430
+ * folder opens a file window whose tree is rooted at the folder
431
+ * (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
432
+ * retries with a short backoff (mirror of the agent-terminals loop): the
433
+ * host queue keeps undelivered requests and replays them on the first
434
+ * attach, so a refresh or a session switch lands the opens the model
435
+ * queued while no view was connected.
436
+ * While the side-card setting is off, pushes are ignored as a defensive
437
+ * gate — the host already unregisters the tool and drains the queue.
438
+ */
439
+ useEffect(() => {
440
+ if (sessionId === undefined) return
441
+ let socket: WebSocket | null = null
442
+ let retry: number | undefined
443
+ let closed = false
444
+ let failures = 0
445
+ const connect = (): void => {
446
+ if (closed) return
447
+ const url = new URL('/sidebar/ws/agent-opens', location.origin)
448
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
449
+ url.search = new URLSearchParams({ sessionId }).toString()
450
+ socket = new WebSocket(url.toString())
451
+ socket.onmessage = (event) => {
452
+ if (typeof event.data !== 'string') return
453
+ try {
454
+ const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
455
+ if (request === null || typeof request !== 'object') return
456
+ if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
457
+ if (typeof request.target !== 'string' || request.target === '') return
458
+ if (store.getPrefs().agentOpenTools !== true) return
459
+ const scope = { sessionId }
460
+ const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
461
+ if (request.kind === 'url') {
462
+ ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
463
+ } else if (request.kind === 'folder') {
464
+ ctx.get('betterSidebar')?.openTab({
465
+ type: 'editor',
466
+ title,
467
+ path: request.target,
468
+ id: `editor:${request.target}`,
469
+ meta: { dir: true },
470
+ }, scope)
471
+ } else {
472
+ ctx.get('betterSidebar')?.openFile(scope, request.target, title)
473
+ }
474
+ } catch {
475
+ // Malformed push: ignore (the next push carries its own request).
476
+ }
477
+ }
478
+ socket.onclose = () => {
479
+ if (closed) return
480
+ failures += 1
481
+ if (failures >= FAILURE_LIMIT) {
482
+ console.error('[dsh-coding-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
483
+ return
484
+ }
485
+ retry = window.setTimeout(connect, 2000)
486
+ }
487
+ socket.onerror = () => { socket?.close() }
488
+ }
489
+ connect()
490
+ return () => {
491
+ closed = true
492
+ window.clearTimeout(retry)
493
+ socket?.close()
494
+ }
495
+ }, [sessionId, store])
496
+
497
+ /**
498
+ * Subagent auto-activation: the moment the current conversation spawns its
499
+ * FIRST direct subagent (a 0 → N transition on the list feed), the "auto
500
+ * open" pref is on, and the Subagent tab type is enabled in settings,
501
+ * open the panel (if collapsed) and focus the Subagent page
502
+ * (single-instance: an existing tab is focused, never duplicated).
503
+ * Switching to a session that already has subagents never triggers — its
504
+ * baseline starts at the current count — so a deliberate layout is never
505
+ * fought.
506
+ *
507
+ * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
508
+ * is also a subagent-origin child, and its 'Side: ' title lands one frame
509
+ * after its origin — an immediate check would misread that first frame as
510
+ * a new subagent and pop this page on every thread creation. The timer
511
+ * re-evaluates the ORIGINAL baseline against the live snapshot; by then
512
+ * the title filter (isSideThreadSummary) sees the settled label.
513
+ */
514
+ const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
515
+ const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
516
+ useEffect(() => {
517
+ const prev = listBaselineRef.current
518
+ listBaselineRef.current = sessionList
519
+ if (sessionId === undefined || prev === undefined) return
520
+ if (autoOpenPendingRef.current !== null) return
521
+ if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
522
+ const baseline = prev
523
+ const timer = window.setTimeout(() => {
524
+ autoOpenPendingRef.current = null
525
+ if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
526
+ if (!store.getPrefs().autoOpenSubagent) return
527
+ if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
528
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
529
+ // Pin the landing to the first pane: the auto-opened Subagent page
530
+ // must appear in the panel that just expanded, not wherever the user
531
+ // last touched.
532
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
533
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
534
+ }, AUTO_OPEN_DEBOUNCE_MS)
535
+ autoOpenPendingRef.current = { baseline, timer }
536
+ }, [sessionList, sessionId, store, ctx])
537
+
538
+ // A session switch (or unmount) voids any armed auto-open recheck.
539
+ useEffect(() => () => {
540
+ const pending = autoOpenPendingRef.current
541
+ if (pending !== null) window.clearTimeout(pending.timer)
542
+ autoOpenPendingRef.current = null
543
+ }, [sessionId])
544
+
545
+ /**
546
+ * Job auto-activation: the moment a NEW background job appears for the
547
+ * current conversation (a job id the previous snapshot lacked), the
548
+ * auto-open pref is on, and the Jobs tab type is enabled, open the panel
549
+ * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
550
+ * (0 → N only), ANY new job id triggers: the agent may start several
551
+ * jobs in one session, and each should surface. A fresh page load never
552
+ * triggers — its baseline starts at the current snapshot.
553
+ */
554
+ const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
555
+ useEffect(() => {
556
+ const prev = jobBaselineRef.current
557
+ jobBaselineRef.current = sessionList
558
+ if (sessionId === undefined || prev === undefined) return
559
+ if (!detectNewJob(prev, sessionList, sessionId)) return
560
+ if (!store.getPrefs().autoOpenJobs) return
561
+ if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
562
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
563
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
564
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
565
+ }, [sessionList, sessionId, store, ctx])
566
+
567
+ /**
568
+ * Topology jump-back: clicking a subagent node on the Subagent page calls
569
+ * the official `openSubagent`, which switches the sidebar to that child
570
+ * session's OWN layout (a fresh child session defaults to the explorer).
571
+ * The README contract says the Subagent page must stay open with the jumped
572
+ * node highlighted — so once the current session becomes the recorded jump
573
+ * target, re-open the Subagent page on top of the child's layout (expanding
574
+ * the panel first if it is collapsed). Only this explicit node click arms
575
+ * the flag, so switching to a subagent session by any other means keeps
576
+ * that session's own layout untouched.
577
+ */
578
+ const subagentJumpRef = useRef<string | undefined>(undefined)
579
+ useEffect(() => {
580
+ const pending = subagentJumpRef.current
581
+ if (pending === undefined || sessionId !== pending) return
582
+ subagentJumpRef.current = undefined
583
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
584
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
585
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
586
+ }, [sessionId, store, ctx])
587
+
588
+ /**
589
+ /**
590
+ * Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
591
+ * inject as VIRTUAL tabs into the first leaf of the right panel's split
592
+ * tree. The virtual tabs have unique ids (prefixed with the home session)
593
+ * and carry the home scope in meta. Clicking a virtual tab sets
594
+ * `activePinnedTabId` — the augmented tree overrides the leaf's `active`
595
+ * so the pinned tab's content renders in-place (TerminalView connects to
596
+ * the home session's PTY via WS, no session jump).
597
+ *
598
+ * Closing/unpinning a virtual tab targets the HOME session via reduceFor
599
+ * (which doesn't notify — targeted opens must not re-render the active
600
+ * session). The `pinnedRevision` state bump forces the pinnedEntries
601
+ * useMemo to recompute after such an action.
602
+ */
603
+ const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
604
+ const [pinnedRevision, setPinnedRevision] = useState(0)
605
+
606
+ /**
607
+ * Cross-session pinned-tab collection. Recomputed on every store notify,
608
+ * session-list change, and pinned action (the revision bump covers
609
+ * reduceFor updates that don't notify). Only tabs from OTHER sessions —
610
+ * the viewer's own pinned tabs are already on its tab strip.
611
+ */
612
+ const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
613
+ if (sessionId === undefined) return []
614
+ return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
615
+ }, [store, sessionId, cwd, snapshot, pinnedRevision])
616
+
617
+ /** Virtual SidebarTab objects for the pinned entries (stable references
618
+ * via useMemo so TabContent's memo comparator holds). */
619
+ const pinnedVirtualTabs = useMemo(
620
+ () => pinnedEntries.map(createPinnedVirtualTab),
621
+ [pinnedEntries],
622
+ )
623
+
624
+ /** The right panel's split tree with pinned virtual tabs injected into the
625
+ * first leaf. When `activePinnedTabId` is set, that leaf's `active` is
626
+ * overridden so the pinned tab's content is visible. */
627
+ const augmentedTree = useMemo(
628
+ () => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
629
+ [state, pinnedVirtualTabs, activePinnedTabId],
630
+ )
631
+
632
+ // The app shell's center column (the conversation area): its DOM node is
633
+ // the drop-zone anchor for free-window drag-out (overConversation tests
634
+ // the pointer against it) and the landing center for floatTab. Located
635
+ // once (plus watchers) and cached in a ref — both consumers measure the
636
+ // rect live at event time, so no cached geometry is kept.
637
+ const centerColRef = useRef<HTMLElement | null>(null)
638
+ const draggingRef = useRef(false)
639
+ useEffect(() => {
640
+ let disposed = false
641
+ // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
642
+ // [data-slot] containers: the conversation slot wrapper
643
+ // ([data-slot="conversation"]) sits directly inside the center column,
644
+ // so its parent IS that column — no hashed-class or positional
645
+ // dependency (layout.css uses the same anchor). The shell swaps the
646
+ // boot page for the AppFrame only AFTER boot settles, so the first
647
+ // query may miss it. Never give up: watch #root's subtree (the swap and
648
+ // HMR re-renders mutate it) and re-run this locator — querying once and
649
+ // bailing would strand the drag-out zone at the fallback rect forever.
650
+ const locate = (): void => {
651
+ if (disposed) return
652
+ const col = document.querySelector('#root [data-slot="conversation"]')
653
+ ?.parentElement as HTMLElement | undefined
654
+ if (col === undefined || !col.isConnected) {
655
+ // The column is gone or was detached (HMR re-render swapped the
656
+ // node in place): drop the ref so the consumers' fallback kicks in
657
+ // and the next watcher tick re-locates the new node (issue #248).
658
+ centerColRef.current = null
659
+ return
660
+ }
661
+ centerColRef.current = col
662
+ }
663
+ locate()
664
+ // rAF-debounce the mutation watchers: #root's subtree changes at chat
665
+ // cadence (streaming turns), and locate() itself must stay cheap.
666
+ let locateFrame: number | null = null
667
+ const scheduleLocate = (): void => {
668
+ if (locateFrame !== null) return
669
+ // Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
670
+ // attribute, which is the mutation this watcher observes — relocating
671
+ // per drag frame is pointless (the center column node cannot change
672
+ // while the pointer is captured) and adds a querySelector to every
673
+ // frame's budget (#315). The 1.5s retry below still covers any node
674
+ // swap that somehow lands mid-drag.
675
+ if (draggingRef.current) return
676
+ locateFrame = requestAnimationFrame(() => {
677
+ locateFrame = null
678
+ locate()
679
+ })
680
+ }
681
+ const watcher = new MutationObserver(scheduleLocate)
682
+ const root = document.getElementById('root')
683
+ if (root !== null) watcher.observe(root, { childList: true, subtree: true })
684
+ // The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
685
+ // clears those variables on teardown and re-writes them on setup — and
686
+ // that is also the moment the shell may have re-created the center
687
+ // column under a REUSED #root child (React swaps nodes in place, so
688
+ // #root's childList never changes and the watcher above never fires).
689
+ // Watching <html>'s style attribute catches that re-sync.
690
+ const htmlStyleWatcher = new MutationObserver(scheduleLocate)
691
+ htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
692
+ // Last-resort safety net (issue #248): no watcher is guaranteed to fire
693
+ // for every HMR teardown/setup interleaving (e.g. the style attribute
694
+ // may end up byte-identical, and the col may be swapped before the
695
+ // subtree watcher attaches). A slow unconditional re-locate keeps the
696
+ // cached node honest within a couple of seconds no matter what
697
+ // sequence the shell used. locate() is cheap when nothing changed
698
+ // (one querySelector + an identity compare; no forced layout).
699
+ const retry = window.setInterval(locate, 1500)
700
+ return () => {
701
+ disposed = true
702
+ if (locateFrame !== null) cancelAnimationFrame(locateFrame)
703
+ window.clearInterval(retry)
704
+ watcher.disconnect()
705
+ htmlStyleWatcher.disconnect()
706
+ centerColRef.current = null
707
+ }
708
+ }, [])
709
+
710
+ /**
711
+ * Free windows — drag-out detection. The tab strips already drive HTML5
712
+ * DnD (payload application/x-dsh-tab) with drops owned by the panes
713
+ * (split/merge); this shell watches the DOCUMENT (capture) for the same
714
+ * drag hovering OUTSIDE the panel host: while the pointer is over the
715
+ * conversation column it arms the drop (preventDefault) and shows a hint
716
+ * overlay there, and the drop floats the tab at the release point. Targets
717
+ * inside the host are ignored here, so pane drops keep their behavior
718
+ * untouched. Only OUR tab drags count (the body flag is the tab strip's;
719
+ * OS file drags and any DSH drags pass through). Narrow viewports skip
720
+ * the gesture — the merged drawer covers the conversation, leaving
721
+ * nothing to drop onto (the tab context menu entry still floats tabs).
722
+ */
723
+ const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
724
+ const floatHintRef = useRef(false)
725
+ useEffect(() => {
726
+ if (narrow || sessionId === undefined) return
727
+ const inPanelHost = (target: EventTarget | null): boolean =>
728
+ target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
729
+ /** The conversation column's rect when the pointer is over it (and not
730
+ * over our own surfaces); null otherwise. */
731
+ const overConversation = (event: DragEvent): DOMRect | null => {
732
+ if (inPanelHost(event.target)) return null
733
+ const col = centerColRef.current
734
+ if (col === null || !col.isConnected) return null
735
+ const rect = col.getBoundingClientRect()
736
+ if (rect.width === 0 || rect.height === 0) return null
737
+ const { clientX: x, clientY: y } = event
738
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
739
+ return rect
740
+ }
741
+ const onDragOver = (event: DragEvent): void => {
742
+ if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
743
+ const rect = overConversation(event)
744
+ if (rect !== null) {
745
+ // preventDefault on dragover is what makes the browser deliver the
746
+ // drop (and drop the "no" cursor) over the conversation area.
747
+ event.preventDefault()
748
+ setFloatHint((prev) => {
749
+ const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
750
+ if (prev !== null && prev.left === next.left && prev.top === next.top
751
+ && prev.width === next.width && prev.height === next.height) return prev
752
+ return next
753
+ })
754
+ floatHintRef.current = true
755
+ } else if (floatHintRef.current) {
756
+ floatHintRef.current = false
757
+ setFloatHint(null)
758
+ }
759
+ }
760
+ const onDrop = (event: DragEvent): void => {
761
+ if (!floatHintRef.current) return
762
+ floatHintRef.current = false
763
+ setFloatHint(null)
764
+ const rect = overConversation(event)
765
+ if (rect === null) return
766
+ event.preventDefault()
767
+ event.stopPropagation()
768
+ const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
769
+ if (payload === null) return
770
+ store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
771
+ }
772
+ const clear = (): void => {
773
+ if (!floatHintRef.current) return
774
+ floatHintRef.current = false
775
+ setFloatHint(null)
776
+ }
777
+ document.addEventListener('dragover', onDragOver, true)
778
+ document.addEventListener('drop', onDrop, true)
779
+ window.addEventListener('dragend', clear, true)
780
+ window.addEventListener('blur', clear)
781
+ return () => {
782
+ document.removeEventListener('dragover', onDragOver, true)
783
+ document.removeEventListener('drop', onDrop, true)
784
+ window.removeEventListener('dragend', clear, true)
785
+ window.removeEventListener('blur', clear)
786
+ }
787
+ }, [narrow, sessionId, store])
788
+
789
+ // Panel drag: the panel's width (left edge strip). Drags write the size
790
+ // DIRECTLY to the DOM (panel style + the layout CSS variable) instead of
791
+ // round-tripping the store on every pointer move — a store reduce
792
+ // re-renders the workbench (terminals, editors…) per move, which is the
793
+ // visible drag lag. The store is committed once on pointer up (clamping
794
+ // + persistence).
795
+ const panelRef = useRef<HTMLDivElement | null>(null)
796
+ const widthDrag = useRef({ startX: 0, startWidth: 0 })
797
+ const [draggingWidth, setDraggingWidth] = useState(false)
798
+ const anyDragging = draggingWidth
799
+
800
+ // Mirror the dragging flag into a ref so non-React callbacks (the center
801
+ // column locator's rAF debounce) can read it without re-subscribing.
802
+ useEffect(() => {
803
+ draggingRef.current = anyDragging
804
+ }, [anyDragging])
805
+
806
+ // Clamp mirror of setWidth for mid-drag values (the store re-clamps on
807
+ // commit; this keeps the panel from overshooting mid-drag).
808
+ const clampWidth = (width: number): number =>
809
+ Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
810
+
811
+ /** Single writer for the layout-push variable: the app shell gives up
812
+ * the panel's width while open (0 while collapsed) through layout.css's
813
+ * margin. Every size change — drag frames and committed state — flows
814
+ * through here so the push never forks between paths. */
815
+ const writeGeometry = (width: number): void => {
816
+ document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
817
+ }
818
+
819
+ /** Last size a drag actually applied to the DOM (updated by applyDrag).
820
+ * When a pointer stream dies without any position info (issue #247: an
821
+ * ultra-fast flick whose release events carried no usable coordinates),
822
+ * the abort path adopts this instead of rolling back to the pre-drag
823
+ * value — the DOM's current size is the only truthful record left. */
824
+ const lastDragSize = useRef<number | null>(null)
825
+
826
+ /** Apply a drag size to the DOM without touching React state or the
827
+ * store. The layout push rides the shared writer (writeGeometry). */
828
+ const applyDrag = (width: number): void => {
829
+ lastDragSize.current = width
830
+ panelRef.current?.style.setProperty('width', `${width}px`)
831
+ writeGeometry(width)
832
+ }
833
+
834
+ // Drags write at most once per frame: pointer events fire several times
835
+ // faster than the display refresh, and each write reflows the app shell
836
+ // (the layout push) plus the panel — batching to one write per frame is
837
+ // what keeps the drag smooth. The store is still committed once on release.
838
+ const dragFrame = useRef<number | null>(null)
839
+ const pendingDrag = useRef<number | null>(null)
840
+ const scheduleDrag = (width: number): void => {
841
+ pendingDrag.current = width
842
+ if (dragFrame.current !== null) return
843
+ dragFrame.current = requestAnimationFrame(() => {
844
+ dragFrame.current = null
845
+ const pending = pendingDrag.current
846
+ if (pending !== null) {
847
+ pendingDrag.current = null
848
+ applyDrag(pending)
849
+ }
850
+ })
851
+ }
852
+
853
+ /** Flush any pending drag write and stop scheduling (the store commit on
854
+ * pointer up applies the final clamped values). */
855
+ const stopDragScheduling = (): void => {
856
+ if (dragFrame.current !== null) {
857
+ cancelAnimationFrame(dragFrame.current)
858
+ dragFrame.current = null
859
+ }
860
+ pendingDrag.current = null
861
+ }
862
+
863
+ /**
864
+ * Finalize a drag on pointer up: flush the LAST drag frame to the DOM
865
+ * synchronously, then commit the SAME clamped value to the store. A fast
866
+ * release cancels the rAF before it ran — without the flush the DOM would
867
+ * sit at the pre-drag size until React re-renders with the committed
868
+ * value, and a value that never made it into a move handler would never
869
+ * be applied at all.
870
+ */
871
+ const commitDrag = (width: number, reduce: (state: SidebarState) => SidebarState): void => {
872
+ stopDragScheduling()
873
+ applyDrag(width)
874
+ draggingRef.current = false
875
+ store.reduce(reduce)
876
+ }
877
+
878
+ /** Set once a drag's pointerup handler commits — premature capture loss
879
+ * (pointercancel / lostpointercapture without pointerup) must then be told
880
+ * apart from a normal release. */
881
+ const dragCommitted = useRef(false)
882
+ /**
883
+ * Abort a drag whose pointer stream was interrupted (pointercancel, or
884
+ * capture lost before pointerup): no pointerup will arrive, so without
885
+ * this the dragging state would stick true and the DOM would sit at an
886
+ * uncommitted size until the next re-render.
887
+ *
888
+ * A FAST release is the common trigger: browsers merge pointermove bursts,
889
+ * and an ultra-fast flick can cancel the stream before ANY move lands.
890
+ * The commit order is therefore: the LAST KNOWN dragged size (the rAF
891
+ * pending value) first, then the interrupting event's own pointer
892
+ * position (only pointercancel is trusted to carry coordinates —
893
+ * lostpointercapture's coordinates are not guaranteed, so the handlers
894
+ * pass the event only from pointercancel), and finally the size the drag
895
+ * last APPLIED to the DOM (lastDragSize). A drag that produced none of
896
+ * those (pure down+up at the same spot) commits the store's own size —
897
+ * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
898
+ * an interrupted fast flick; the abort path added in the unified-host
899
+ * refactor did, and that regression is what this ordering removes).
900
+ *
901
+ * Every commit path marks the drag committed, so the interrupt
902
+ * double-fire (pointercancel → lostpointercapture) cannot commit once
903
+ * and then roll the same drag back.
904
+ */
905
+ const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
906
+ if (dragCommitted.current) return
907
+ const pending = pendingDrag.current
908
+ let width: number | undefined
909
+ if (pending !== null) {
910
+ width = pending
911
+ } else if (event !== undefined) {
912
+ // No move ever landed: the cancel position is all we have — commit it
913
+ // (clamped) instead of rolling back the flick.
914
+ width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
915
+ }
916
+ if (width !== undefined) {
917
+ dragCommitted.current = true
918
+ pendingDrag.current = null
919
+ if (dragFrame.current !== null) {
920
+ cancelAnimationFrame(dragFrame.current)
921
+ dragFrame.current = null
922
+ }
923
+ applyDrag(width)
924
+ draggingRef.current = false
925
+ store.reduce(s => setWidth(s, width))
926
+ } else {
927
+ // No pending write and no usable event coordinates: keep the size the
928
+ // drag last applied instead of rolling back to the pre-drag value
929
+ // (the flick's moves may have been consumed by the rAF just before
930
+ // the stream died — the DOM already shows the dragged size). Clamp to
931
+ // the current geometry like the layout-push effect (closed/narrow
932
+ // panels are written 0 by the push).
933
+ dragCommitted.current = true
934
+ stopDragScheduling()
935
+ const adoptedWidth = layoutPushSize({
936
+ narrow,
937
+ panelOpen: state?.panelOpen === true,
938
+ width: lastDragSize.current ?? state?.width ?? 0,
939
+ viewportWidth: viewport.width,
940
+ })
941
+ applyDrag(adoptedWidth)
942
+ draggingRef.current = false
943
+ store.reduce(s => setWidth(s, adoptedWidth))
944
+ }
945
+ reset()
946
+ }
947
+
948
+ // Layout push: the app shell gives up the panel's width while it is open
949
+ // (0 while collapsed), so the conversation and input bar are squeezed
950
+ // instead of covered. The margin is capped at the viewport so a stale
951
+ // persisted size (e.g. fullscreen on a bigger window) can never crush the
952
+ // app shell to zero. Dragging disables the layout transition. On NARROW
953
+ // viewports the drawer FLOATS over the app shell — no push, the
954
+ // conversation keeps the full width behind the drawer.
955
+ useEffect(() => {
956
+ writeGeometry(layoutPushSize({
957
+ narrow,
958
+ panelOpen: snapshot.state?.panelOpen === true,
959
+ width: snapshot.state?.width ?? 0,
960
+ viewportWidth: viewport.width,
961
+ }))
962
+ }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, viewport.width])
963
+ // Unmount must release the push (issue #31): when the boundary swaps the
964
+ // whole sidebar after a render crash (or the plugin fiber is disposed /
965
+ // HMR), the CSS variables would otherwise stay on <html> and layout.css
966
+ // keeps squeezing #root with a stale margin — "the sidebar cannot be
967
+ // hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
968
+ // in the push effect's cleanup: React can yield between a passive
969
+ // effect's cleanup and setup phases, and removing the variables on a
970
+ // dependency change used to paint the push-less layout for a frame (the
971
+ // center column went full width) while the re-add restarted the margin
972
+ // transition — the shell flashed push-less after every width drag
973
+ // (issue #258). Keeping the variable continuously valid while mounted
974
+ // makes the push invisible to mid-flush style recalcs.
975
+ useEffect(() => {
976
+ return () => {
977
+ document.documentElement.style.removeProperty('--dsh-sidebar-width')
978
+ }
979
+ }, [])
980
+ useEffect(() => {
981
+ if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
982
+ else document.body.removeAttribute('data-dsh-sidebar-dragging')
983
+ }, [anyDragging])
984
+
985
+
986
+ const actions: WorkbenchActions = useMemo(() => ({
987
+ closeTab: (paneId, tabId) => {
988
+ // A closed terminal releases its pty immediately — including when its
989
+ // socket is mid-reconnect, where the unmount close frame never reaches
990
+ // the host and the process would hold the quota until the grace ends.
991
+ // Agent terminals (tabId `agent:<uuid>`) close through a different
992
+ // host route: the WS close frame is the primary path (sent by
993
+ // TerminalView on unmount), and the agent-pty.close HTTP route is the
994
+ // fallback when the WS is down.
995
+ const current = store.getSnapshot().state
996
+ const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
997
+ const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
998
+ // Route through the service: the tab-bar close is the canonical close
999
+ // path (finds the pane itself, fires descriptor.onClose); the session
1000
+ // scope (with its cwd) rides to the callback.
1001
+ ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
1002
+ if (tab?.type === 'terminal') {
1003
+ if (isAgentTabId(tabId)) {
1004
+ const uuid = agentUuidOf(tabId)
1005
+ void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
1006
+ } else if (sessionId !== undefined) {
1007
+ void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
1008
+ }
1009
+ }
1010
+ },
1011
+ activateTab: (paneId, tabId) => {
1012
+ // Route through the service: same reducer (finds the pane, sets the
1013
+ // active pane) and fires descriptor.onActivate; the session scope
1014
+ // (with its cwd) rides to the callback.
1015
+ ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
1016
+ },
1017
+ focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
1018
+ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
1019
+ store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
1020
+ },
1021
+ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
1022
+ store.reduce((s) => {
1023
+ let index = -1
1024
+ const source = leafWithTab(s.splits, beforeTabId)
1025
+ if (source !== undefined && source.id === toPane) {
1026
+ index = source.tabs.findIndex(tab => tab.id === beforeTabId)
1027
+ }
1028
+ return moveTab(s, payload.paneId, payload.tabId, toPane, index)
1029
+ })
1030
+ },
1031
+ resizeSplit: (splitId, index, deltaFrac) => {
1032
+ store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
1033
+ },
1034
+ // The tab context menu's "move to free window": no drop point exists, so
1035
+ // the window is born over the conversation column's center (the user's
1036
+ // focus area; clamped into the viewport by the reducer) — the same
1037
+ // landing the drag-out gesture produces.
1038
+ floatTab: (tabId) => {
1039
+ const col = centerColRef.current
1040
+ const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
1041
+ const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
1042
+ const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
1043
+ store.reduce(s => floatTab(s, tabId, x, y))
1044
+ },
1045
+ // Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
1046
+ // pin time so a workspace-scoped pin only resurfaces in sessions whose
1047
+ // cwd matches. Unpin passes null — the tab stays open in its home
1048
+ // session, just unmarked.
1049
+ pinTab: (tabId, scope) => {
1050
+ store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
1051
+ },
1052
+ }), [store, sessionId, cwd])
1053
+
1054
+ /**
1055
+ * Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
1056
+ * other sessions). Regular tab ids pass through unchanged. Virtual ids are
1057
+ * detected by the `pinned:` prefix and routed to the HOME session via
1058
+ * reduceFor (which doesn't notify — the revision bump is the local signal).
1059
+ */
1060
+ const wrappedActions = useMemo<WorkbenchActions>(() => {
1061
+ if (pinnedVirtualTabs.length === 0) return actions
1062
+ const closePinnedInHome = (virtualId: string): void => {
1063
+ const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
1064
+ // The home cwd lives in the virtual tab's meta (snapshotted at pin
1065
+ // time) — pass it to ptyClose so the host resolves the PTY in the
1066
+ // correct workspace container (same scope the WS open used).
1067
+ const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
1068
+ const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
1069
+ store.reduceFor(homeSessionId, s => {
1070
+ const leaf = leafWithTab(s.splits, originalId)
1071
+ if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
1072
+ if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
1073
+ return s
1074
+ })
1075
+ if (isAgentTabId(originalId)) {
1076
+ void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
1077
+ } else {
1078
+ void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
1079
+ }
1080
+ if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
1081
+ setPinnedRevision(v => v + 1)
1082
+ }
1083
+ return {
1084
+ ...actions,
1085
+ activateTab: (paneId, tabId) => {
1086
+ if (isPinnedVirtualId(tabId)) {
1087
+ setActivePinnedTabId(tabId)
1088
+ } else {
1089
+ setActivePinnedTabId(null)
1090
+ actions.activateTab(paneId, tabId)
1091
+ }
1092
+ },
1093
+ closeTab: (paneId, tabId) => {
1094
+ if (isPinnedVirtualId(tabId)) {
1095
+ closePinnedInHome(tabId)
1096
+ } else {
1097
+ actions.closeTab(paneId, tabId)
1098
+ }
1099
+ },
1100
+ moveTabBefore: (payload, toPane, beforeTabId) => {
1101
+ if (isPinnedVirtualId(payload.tabId)) return
1102
+ if (isPinnedVirtualId(beforeTabId)) {
1103
+ actions.moveTabToEdge(payload, toPane, 'center')
1104
+ } else {
1105
+ actions.moveTabBefore(payload, toPane, beforeTabId)
1106
+ }
1107
+ },
1108
+ moveTabToEdge: (payload, toPane, zone) => {
1109
+ if (isPinnedVirtualId(payload.tabId)) return
1110
+ actions.moveTabToEdge(payload, toPane, zone)
1111
+ },
1112
+ floatTab: (tabId) => {
1113
+ if (isPinnedVirtualId(tabId)) return
1114
+ actions.floatTab(tabId)
1115
+ },
1116
+ pinTab: (tabId, scope) => {
1117
+ if (isPinnedVirtualId(tabId)) {
1118
+ if (scope !== null) return
1119
+ const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
1120
+ store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
1121
+ if (activePinnedTabId === tabId) setActivePinnedTabId(null)
1122
+ setPinnedRevision(v => v + 1)
1123
+ } else {
1124
+ actions.pinTab?.(tabId, scope)
1125
+ }
1126
+ },
1127
+ }
1128
+ }, [actions, pinnedVirtualTabs, activePinnedTabId, store])
1129
+
1130
+ /**
1131
+ * The explorer's @-reference button: append `@<relative path>` to the
1132
+ * session's composer draft (space-separated). The conversation service is
1133
+ * resolved lazily through `ctx.get` (the inject-free read — the app's own
1134
+ * plugins read 'conversation' the same way); a missing service or scope
1135
+ * degrades to a logged no-op, never a crash. Defined above the no-session
1136
+ * early return — a hook must never sit behind a conditional return
1137
+ * (React counts hooks per render).
1138
+ */
1139
+ const referenceInChat = useCallback((path: string): void => {
1140
+ if (sessionId === undefined) return
1141
+ appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
1142
+ }, [ctx, sessionId, cwd])
1143
+
1144
+ if (state === undefined || sessionId === undefined) {
1145
+ // Keep the unavailable controls focusable: touch users have no hover, so
1146
+ // focus is the only way the existing Tooltip can explain what is missing.
1147
+ return (
1148
+ <div data-dsh-panel-host {...osFileDragShield}>
1149
+ <div className={css.toggleCluster} data-dsh-toggle-cluster>
1150
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
1151
+ <button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
1152
+ <IconPanelRightOutline16 />
1153
+ </button>
1154
+ </Tooltip>
1155
+ </div>
1156
+ </div>
1157
+ )
1158
+ }
1159
+
1160
+ const onNewTab = (optionId: string): void => {
1161
+ const service = ctx.get('betterSidebar')
1162
+ const descriptor = service?.getTab(optionId)
1163
+ if (service === undefined || descriptor === undefined) return
1164
+ const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
1165
+ // The session scope rides along: lifecycle callbacks receive it (and
1166
+ // the open stays in the current session, as before).
1167
+ service.openTab({ type: optionId, title }, { sessionId, cwd })
1168
+ }
1169
+
1170
+ /**
1171
+ * The explorer's @-reference button: append `@<relative path>` to the
1172
+ * session's composer draft (space-separated). Resolves the session-scope
1173
+ * ctx and the conversation input service at click time; a missing service
1174
+ * or scope degrades to a logged no-op, never a crash.
1175
+ */
1176
+ /** The tab icon from the tab-type registry (shared by every workbench). */
1177
+ const tabIconOf = (tab: SidebarTab): ReactNode => {
1178
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
1179
+ if (descriptor === undefined) return null
1180
+ return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
1181
+ }
1182
+
1183
+ /**
1184
+ * The tab badge from the tab-type registry: a count (99+ capped) or a
1185
+ * short text pill. A throwing badge is swallowed (no pill) — the tab
1186
+ * strip must never break because a plugin's badge computation failed.
1187
+ */
1188
+ const tabBadgeOf = (tab: SidebarTab): ReactNode => {
1189
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
1190
+ if (descriptor?.badge === undefined) return null
1191
+ let value: string | number | null | undefined
1192
+ try {
1193
+ value = descriptor.badge(ctx, { sessionId, cwd }, state)
1194
+ } catch (error) {
1195
+ console.error('[dsh-coding-sidebar] tab badge error:', error)
1196
+ return null
1197
+ }
1198
+ if (value === null || value === undefined || value === '') return null
1199
+ const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
1200
+ return <span className={css.tabBadge}>{text}</span>
1201
+ }
1202
+
1203
+ /**
1204
+ * Render one tab's content. `active` (from the workbench) tells whether
1205
+ * this tab is the active one in its pane; combined with the panel's
1206
+ * open/closed state it gates live views (the Subagent topology pauses its
1207
+ * polling while the page is not actually visible). The pane id travels
1208
+ * with the tab so diff tabs can split below their source pane.
1209
+ */
1210
+ // `placement` decides the visibility contract handed to the tab component:
1211
+ // pane tabs are visible while the panel is open and they are active, but
1212
+ // a free window is its own surface — its tab stays visible no matter what
1213
+ // the panel does (the AGENTS §7.5 contract; plugin components honor
1214
+ // `visible` to pause work, so tying floats to panelOpen would blank them
1215
+ // the moment the sidebar collapses).
1216
+ const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'float' = 'top') => {
1217
+ // Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
1218
+ // TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
1219
+ // the descriptor component receives the ORIGINAL tab id (the virtual id
1220
+ // is only a display key). Regular tabs: effectiveTabId is undefined (no
1221
+ // override), scope is the current session's.
1222
+ const home = getPinnedHomeScope(tab)
1223
+ return (
1224
+ <TabContent
1225
+ tab={tab}
1226
+ effectiveTabId={home?.tabId}
1227
+ paneId={paneId}
1228
+ sessionId={home?.sessionId ?? sessionId}
1229
+ cwd={home?.cwd ?? cwd}
1230
+ expanded={state.expanded}
1231
+ revealed={state.revealed ?? []}
1232
+ onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
1233
+ onReferenceFile={referenceInChat}
1234
+ ctx={ctx}
1235
+ store={store}
1236
+ visible={placement === 'float' ? true : state.panelOpen && active}
1237
+ onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
1238
+ onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
1239
+ localeRevision={localeRevision}
1240
+ tabsVersion={tabsVersion}
1241
+ />
1242
+ )
1243
+ }
1244
+
1245
+ return (
1246
+ <div data-dsh-panel-host {...osFileDragShield}>
1247
+ {/*
1248
+ The persistent toggle button at the top-right corner (side glyph).
1249
+ Always pinned to the viewport corner — inside the panel's top-right
1250
+ while it is open, sitting flush in the tab strip whose right end it
1251
+ really squeezes (the strip reserves its width via CSS), so the tabs
1252
+ genuinely yield space to it.
1253
+ */}
1254
+ <div className={css.toggleCluster} data-dsh-toggle-cluster>
1255
+ <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
1256
+ <button
1257
+ type="button"
1258
+ className={css.toggleButton}
1259
+ aria-label={state.panelOpen ? t('collapse') : t('expand')}
1260
+ onClick={() => { store.reduce(togglePanel) }}
1261
+ >
1262
+ <IconPanelRightOutline16 />
1263
+ </button>
1264
+ </Tooltip>
1265
+ </div>
1266
+ {/*
1267
+ The panel stays mounted while collapsed (hidden off-screen) so the
1268
+ slide in/out can animate; visibility hides it after the slide
1269
+ settles. On NARROW viewports it is a full-width drawer; the width
1270
+ drag strip is not offered there — a full-screen sheet has nothing
1271
+ to drag.
1272
+ */}
1273
+ <div
1274
+ ref={panelRef}
1275
+ className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
1276
+ data-dsh-panel
1277
+ style={{
1278
+ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
1279
+ // Narrow drawer: keep the bottom-anchored sheet above the on-screen
1280
+ // keyboard (visualViewport inset); desktop panels are full-height
1281
+ // and unaffected.
1282
+ bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
1283
+ }}
1284
+
1285
+ data-dragging={anyDragging || undefined}
1286
+ >
1287
+ {!narrow && (
1288
+ <div
1289
+ className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
1290
+
1291
+ onPointerDown={(event) => {
1292
+ event.preventDefault()
1293
+ event.currentTarget.setPointerCapture(event.pointerId)
1294
+ dragCommitted.current = false
1295
+ widthDrag.current = { startX: event.clientX, startWidth: state.width }
1296
+ setDraggingWidth(true)
1297
+ }}
1298
+ onPointerMove={(event) => {
1299
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1300
+ const { startX, startWidth } = widthDrag.current
1301
+ scheduleDrag(clampWidth(startWidth + (startX - event.clientX)))
1302
+ }}
1303
+ onPointerUp={(event) => {
1304
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1305
+ if (dragCommitted.current) return
1306
+ dragCommitted.current = true
1307
+ event.currentTarget.releasePointerCapture(event.pointerId)
1308
+ const { startX, startWidth } = widthDrag.current
1309
+ // The up position is the pointer's FINAL position — a fast
1310
+ // flick's tail is coalesced into the up event, so the last
1311
+ // pointermove (the rAF pending value) can be stale. Commit
1312
+ // from the up position (v0.13.1 semantics; issue #247).
1313
+ const width = clampWidth(startWidth + (startX - event.clientX))
1314
+ commitDrag(width, s => setWidth(s, width))
1315
+ setDraggingWidth(false)
1316
+ }}
1317
+ onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
1318
+ onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
1319
+ />
1320
+ )}
1321
+ <div className={css.panelBody}>
1322
+ <Workbench
1323
+ state={state}
1324
+ tree={augmentedTree}
1325
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
1326
+ actions={wrappedActions}
1327
+ onNewTab={onNewTab}
1328
+ renderTab={renderTab}
1329
+ getTabIcon={tabIconOf}
1330
+ getTabBadge={tabBadgeOf}
1331
+ />
1332
+ </div>
1333
+ </div>
1334
+ {/*
1335
+ Free windows: tabs dragged out onto the conversation area (or floated
1336
+ from the tab context menu). They live in the panel host like the
1337
+ panels (viewport coordinates, immune to desktop-shell transforms) but
1338
+ are independent of panel state — a window stays up while panels
1339
+ collapse. The floats array's order is the stacking order; the content
1340
+ reuses the regular tab renderer, so every tab type floats unchanged.
1341
+ */}
1342
+ {state.floats.map(float => (
1343
+ <FreeWindow
1344
+ key={float.id}
1345
+ float={float}
1346
+ renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
1347
+ getTabIcon={tabIconOf}
1348
+ onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
1349
+ onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
1350
+ onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
1351
+ onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
1352
+ onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
1353
+ />
1354
+ ))}
1355
+ {/*
1356
+ The drag-out hint: while a tab drag hovers the conversation column,
1357
+ a dashed overlay marks the drop zone there (pointer-transparent — it
1358
+ must not disturb the drag it describes).
1359
+ */}
1360
+ {floatHint !== null && (
1361
+ <div
1362
+ className={css.floatDropHint}
1363
+ style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
1364
+ >
1365
+ <span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
1366
+ </div>
1367
+ )}
1368
+ </div>
1369
+ )
1370
+ }