@chatcode/cco-better-sidebar 0.18.0-alpha.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 (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,1857 @@
1
+ /**
2
+ * The sidebar shell: panels mounted inside the unified panel host — a
3
+ * fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
4
+ * to document.body — instead of individual fixed-position elements, so a
5
+ * desktop shell's intermediate wrapper transforms can never hijack the
6
+ * panels' fixed containing block (the core AppFrame owns the left sidebar /
7
+ * center / details columns and has no right-side hole for plugins). The
8
+ * right panel hosts the original workbench; the bottom panel hosts a
9
+ * second, independent workbench. The bottom panel squeezes ONLY the center
10
+ * column (the agent output area): it spans from the app shell's own left
11
+ * sidebar to the right panel's left edge, so neither sidebar gives up any
12
+ * position (the right panel keeps its full height). A persistent cluster at
13
+ * the top-right corner toggles the bottom panel; the host's native rightbar
14
+ * button toggles this plugin's right panel (with a plugin fallback on older
15
+ * hosts). The right panel's width drags from its left edge, the bottom
16
+ * panel's height from its top edge, and the shared corner drags both at once.
17
+ * Current DSH hosts receive one integrated Session-header control group for
18
+ * the bottom panel, right panel, and manual; older hosts keep the viewport
19
+ * fallback. The whole layout lives in the per-session store, so switching
20
+ * conversations swaps the sidebar.
21
+ *
22
+ * The shell binds the workbench actions to the store and dispatches tab
23
+ * content to the views. New tabs come from the + menu (explorer / git /
24
+ * terminal; editors open from the explorer). Tabs live in one tree only —
25
+ * they never cross panels; only the panel sizes drag against each other.
26
+ *
27
+ * Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
28
+ * narrow migrates the bottom panel's tabs INTO the right tree
29
+ * (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
30
+ * strips. The right panel becomes a full-width drawer, the bottom panel
31
+ * and its toggle button disappear, and the layout push is disabled (the
32
+ * drawer floats). Widening does not migrate back: the tabs keep living in
33
+ * the right tree.
34
+ */
35
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
36
+ import { useSyncExternalStore } from 'react'
37
+ import { createPortal } from 'react-dom'
38
+ import clsx from 'clsx'
39
+ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
40
+ import type { Context } from '../context-types.ts'
41
+ import { appendToDraft } from './conversation-draft.ts'
42
+ import {
43
+ BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
44
+ floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
45
+ moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals,
46
+ resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
47
+ type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
48
+ } from './state.ts'
49
+ import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
50
+ import { IconBookOutline16, IconPinOutline16 } from './icons.tsx'
51
+ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
52
+ import { Workbench, type WorkbenchActions } from './split-pane.tsx'
53
+ import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
54
+ import { layoutPushSize } from './layout-push.ts'
55
+ import { parseDesktopEnv } from './desktop-env.ts'
56
+ import { getWcoSnapshot, subscribeWco } from './wco.ts'
57
+ import { getShellPreset } from './shell-presets.ts'
58
+ import { computeTitleBarStrip } from './titlebar-strip.ts'
59
+ import type { NewTabOption } from './TabBar.tsx'
60
+ import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
61
+ import { FreeWindow } from './FreeWindow.tsx'
62
+ import { relativeTo } from './paths.ts'
63
+ import { OrphanedTab } from './OrphanedTab.tsx'
64
+ import { RenderBoundary } from './RenderBoundary.tsx'
65
+ import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
66
+ import { t } from './locales.ts'
67
+ import { api, type SessionScope } from './api.ts'
68
+ import { AccountMenu } from './AccountMenu.tsx'
69
+ import css from './sidebar.module.css'
70
+
71
+ /** How many consecutive reconnect failures stop the agent-terminals push loop
72
+ * (mirror of the terminal view's own cap; the loop restarts on session switch). */
73
+ const FAILURE_LIMIT = 3
74
+
75
+ /** Public CLI documentation opened by the persistent top-right manual action. */
76
+ export const USER_MANUAL_URL = 'https://chatcode.chinaunicom.cn/doc/cli/'
77
+
78
+ /** Stable host affordance contributed by DSH's native right sidebar. */
79
+ const NATIVE_SIDEBAR_TOGGLE = 'button[data-sidebar-right-expand]'
80
+
81
+ /** Stable container left behind when DSH's native right sidebar is open and
82
+ * its expand button is therefore absent. The utilities row immediately before
83
+ * it owns Session actions such as the log-download menu. */
84
+ const NATIVE_HEADER_CORNER = '[data-conversation-header-corner]'
85
+
86
+ /** Resolve the shared Session-actions row on both native-sidebar states.
87
+ * Current DSH keeps the corner seat mounted while its expand control comes and
88
+ * goes; older hosts fall back to the native button's own parent. */
89
+ function nativeToolbarHostOf(nativeButton: HTMLButtonElement | null): HTMLElement | null {
90
+ const corner = nativeButton?.closest<HTMLElement>(NATIVE_HEADER_CORNER)
91
+ ?? document.querySelector<HTMLElement>(NATIVE_HEADER_CORNER)
92
+ const utilities = corner?.previousElementSibling
93
+ if (utilities instanceof HTMLElement && utilities.parentElement === corner?.parentElement) return utilities
94
+ return nativeButton?.parentElement ?? null
95
+ }
96
+
97
+ function UserManualButton() {
98
+ const label = t('userManual')
99
+ return (
100
+ <Tooltip label={label} side="bottom" delayMs={500}>
101
+ <button
102
+ type="button"
103
+ className={css.manualButton}
104
+ aria-label={label}
105
+ onClick={() => { window.open(USER_MANUAL_URL, '_blank', 'noopener,noreferrer') }}
106
+ >
107
+ <IconBookOutline16 />
108
+ <span>{label}</span>
109
+ </button>
110
+ </Tooltip>
111
+ )
112
+ }
113
+
114
+ /**
115
+ * OS file drags over the sidebar belong to the sidebar, not to the chat:
116
+ * DSH's composer (InputBar) listens for file drags on the DOCUMENT and
117
+ * answers with a full-screen "drop image here" mask plus image intake on
118
+ * drop. Both panel-host render sites swallow the whole event quartet —
119
+ * enter/over/leave/drop — so the region is a black hole to that document
120
+ * listener. All four must be stopped: InputBar keeps an enter/leave depth
121
+ * counter, and a leave that escapes without its matching enter unbalances
122
+ * the count (this was the full-screen mask flickering over the sidebar).
123
+ * The conversation column keeps DSH's native overlay and intake untouched;
124
+ * gated on the 'Files' type so in-app drags (tab reorder, split zones)
125
+ * propagate exactly as before.
126
+ */
127
+ const swallowOsFileDrag = (event: ReactDragEvent): void => {
128
+ if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
129
+ event.preventDefault()
130
+ event.stopPropagation()
131
+ }
132
+
133
+ /** The four drag events a file drag must never carry past the panel host. */
134
+ const osFileDragShield = {
135
+ onDragEnter: swallowOsFileDrag,
136
+ onDragOver: swallowOsFileDrag,
137
+ onDragLeave: swallowOsFileDrag,
138
+ onDrop: swallowOsFileDrag,
139
+ }
140
+
141
+ /**
142
+ * Append one user-space stylesheet (preset or custom CSS) as a tagged
143
+ * `<style>` element. The tag attribute carries the source identity so the
144
+ * running configuration is inspectable in DevTools; the returned tag is
145
+ * removed by the caller's effect cleanup.
146
+ */
147
+ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
148
+ const tag = document.createElement('style')
149
+ tag.setAttribute(attr, id)
150
+ tag.textContent = cssText
151
+ document.head.appendChild(tag)
152
+ return tag
153
+ }
154
+
155
+ /** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
156
+ * the runtime objects/callbacks the cell renders with. The memo comparator
157
+ * is the pure `tabContentCompare`; anything in the key decides a re-render
158
+ * must propagate, anything outside it must be a stable object (ctx/store)
159
+ * or covered by a compared field (paneId covers onOpenDiff's captured
160
+ * pane; sessionId/cwd cover onReferenceFile). */
161
+ interface TabContentProps extends TabContentMemoKey {
162
+ onToggleDir: (path: string) => void
163
+ onReferenceFile: (path: string) => void
164
+ ctx: Context
165
+ store: SidebarStore
166
+ /** Fired before a topology node jumps to its child session (see Sidebar). */
167
+ onSubagentJump: (childSessionId: string) => void
168
+ /** Open a diff tab from the git panel (placement handled by the store). */
169
+ onOpenDiff: (tab: SidebarTab) => void
170
+ }
171
+
172
+ /** Render the content of one tab (dispatched by type). */
173
+ const TabContent = memo(function TabContent(props: TabContentProps) {
174
+ const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
175
+ const scope = { sessionId, cwd }
176
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
177
+ if (descriptor === undefined) {
178
+ return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
179
+ }
180
+ // For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
181
+ // must receive the ORIGINAL tab id so it connects to the home session's PTY.
182
+ // The virtual tab's own id is a unique display key (prefixed); effectiveTabId
183
+ // restores the real id at the component boundary.
184
+ const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
185
+ return createElement(
186
+ RenderBoundary,
187
+ { className: css.tabBoundaryError },
188
+ createElement(descriptor.component, {
189
+ ctx, store, scope, tab: componentTab, visible, expanded, revealed,
190
+ onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
191
+ }),
192
+ )
193
+ }, tabContentCompare)
194
+
195
+ /** The + menu options for the current state, driven by the tab registry.
196
+ * Hidden tabs (editor/diff) never show; `available` returning false shows
197
+ * a disabled row (e.g. terminal at capacity) instead of hiding the option.
198
+ * Tabs the user disabled in the side card settings are filtered out
199
+ * entirely — re-enabling them is the settings page's job. */
200
+ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
201
+ const service = ctx.get('betterSidebar')
202
+ if (service === undefined) return []
203
+ return service.getTabs()
204
+ .filter(d => !d.hidden && service.isTabEnabled(d.id))
205
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
206
+ .map(d => ({
207
+ id: d.id,
208
+ label: typeof d.title === 'function' ? d.title() : d.title,
209
+ disabled: !(d.available?.(ctx, scope, state) ?? true),
210
+ icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
211
+ }))
212
+ }
213
+
214
+ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
215
+ const { ctx, store } = props
216
+
217
+ // Copy freshness: re-render the whole tree when the DSH locale switches.
218
+ // The module-level t() reads the active locale at call time, so a root
219
+ // re-render alone re-localizes every panel (no memo barriers below).
220
+ const localeRevision = useSyncExternalStore(
221
+ useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
222
+ useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
223
+ )
224
+ void localeRevision
225
+
226
+ // better-locale override freshness: when @huanlin/dsh-plugin-better-locale
227
+ // is installed and the user picks an override language (e.g. ja), the
228
+ // store's `active` changes but the DSH locale's `active` does NOT —
229
+ // better-locale keeps the dsh active value (zh/en) unchanged and only
230
+ // patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES
231
+ // above reads `getSnapshot().active`, so it sees no change and skips
232
+ // re-render. This second uSES reads the better-locale store's `active`
233
+ // directly, so an override switch fires a full re-render and t() picks
234
+ // up the new override text. Optional: ctx.get returns undefined when
235
+ // better-locale is absent (or when ctx is a minimal test mock without
236
+ // a `get` method), in which case this is a no-op uSES.
237
+ type BetterLocaleStore = {
238
+ readonly active: string | undefined
239
+ subscribe(listener: () => void): () => void
240
+ }
241
+ const betterLocaleStore = typeof ctx.get === 'function'
242
+ ? (ctx as unknown as {
243
+ get(name: 'betterLocale'): BetterLocaleStore | undefined
244
+ }).get('betterLocale')
245
+ : undefined
246
+ const betterLocaleActive = useSyncExternalStore(
247
+ useMemo(() => {
248
+ const store = betterLocaleStore
249
+ if (store === undefined) return (_cb: () => void) => () => {}
250
+ return (callback: () => void) => store.subscribe(callback)
251
+ }, [betterLocaleStore]),
252
+ useMemo(() => {
253
+ const store = betterLocaleStore
254
+ if (store === undefined) return () => undefined
255
+ return () => store.active
256
+ }, [betterLocaleStore]),
257
+ )
258
+ void betterLocaleActive
259
+
260
+ // Tab-registry revision: TabContent memo cells must pick up a descriptor
261
+ // a plugin registers/disposes after mount (the + menu / icons already read
262
+ // the registry at render). Rare events (plugin (un)mount), so one full
263
+ // re-render per change is fine — this is what keeps the memoized cells
264
+ // from going stale, mirroring the localeRevision mechanism above.
265
+ const [tabsVersion, setTabsVersion] = useState(0)
266
+ useEffect(() => {
267
+ const service = ctx.get('betterSidebar')
268
+ if (service === undefined) return
269
+ return service.subscribe(() => setTabsVersion(version => version + 1))
270
+ }, [ctx])
271
+
272
+ // Narrow (mobile) viewports collapse the two panels into one: the right
273
+ // panel becomes a full-width drawer holding BOTH workbenches, the bottom
274
+ // panel (and its toggle button) disappears, and the layout push is
275
+ // disabled (the drawer floats over the app shell). Entering narrow
276
+ // MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
277
+ // — the merged display is the right sidebar alone, the bottom tabs thrown
278
+ // into its strips. Widening never rewrites the migrated state: the tabs
279
+ // keep living in the right tree.
280
+ const viewport = useViewportSize()
281
+ const narrow = isNarrowWidth(viewport.width)
282
+
283
+ // On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
284
+ // when the visual viewport shrinks below the layout viewport, bottom-
285
+ // anchored panels would hide under the keyboard. Track the inset and
286
+ // offset the bottom-anchored surfaces by it. The obscured bottom strip is
287
+ // innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
288
+ // the visual viewport is scrolled/zoomed under browser chrome, so
289
+ // omitting it would over-lift the panels (CR #232 P2). offsetTop changes
290
+ // through the viewport's scroll event too, so both events are listened.
291
+ // Guarded: browsers without visualViewport (older WebViews, jsdom) stay
292
+ // at 0. rAF-throttled, same pattern as useNarrowViewport.
293
+ const [keyboardInset, setKeyboardInset] = useState(0)
294
+ const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
295
+ useEffect(() => {
296
+ const vv = window.visualViewport
297
+ if (vv === null || vv === undefined) return
298
+ let frame: number | null = null
299
+ const measure = (): void => {
300
+ frame = null
301
+ const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
302
+ setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
303
+ setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
304
+ }
305
+ const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
306
+ vv.addEventListener('resize', onResize)
307
+ vv.addEventListener('scroll', onResize)
308
+ measure()
309
+ return () => {
310
+ vv.removeEventListener('resize', onResize)
311
+ vv.removeEventListener('scroll', onResize)
312
+ if (frame !== null) cancelAnimationFrame(frame)
313
+ }
314
+ }, [])
315
+ // The bottom panel is offset above the on-screen keyboard. Cap its height
316
+ // against that same visible area, not the taller layout viewport, so the
317
+ // conversation keeps PANEL_MIN even on wide touch devices.
318
+ const layoutViewportHeight = visualViewportHeight ?? viewport.height
319
+
320
+ // Current conversation (the sessions list feed).
321
+ const sessionList = useSyncExternalStore(
322
+ useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
323
+ useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
324
+ )
325
+ const current = sessionList.current
326
+
327
+ // Per-session sidebar state.
328
+ const snapshot = useSyncExternalStore(
329
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
330
+ useCallback(() => store.getSnapshot(), [store]),
331
+ )
332
+ useEffect(() => { store.setSession(current) }, [current, store])
333
+
334
+ const state = snapshot.state
335
+ const sessionId = snapshot.sessionId
336
+ const [nativeToolbarHost, setNativeToolbarHost] = useState<HTMLElement | null>(
337
+ () => nativeToolbarHostOf(document.querySelector<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)),
338
+ )
339
+ const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
340
+ const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({
341
+ narrow,
342
+ panelOpen: false,
343
+ bottomOpen,
344
+ width: 0,
345
+ bottomHeight,
346
+ viewportWidth: viewport.width,
347
+ viewportHeight: layoutViewportHeight,
348
+ }).height
349
+
350
+ // The collapsed toggle cluster reclaims the top-right corner, so the DSH
351
+ // session header's right-aligned utilities (the "Session log" download
352
+ // capsule) must yield. layout.css keys off this body attribute to push the
353
+ // header's right padding out past the cluster. Only the CLOSED panel needs
354
+ // it — an open panel already squeezes `#root` left, moving the header clear.
355
+ const collapsed = state === undefined || !state.panelOpen
356
+ useEffect(() => {
357
+ if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
358
+ else document.body.removeAttribute('data-dsh-sidebar-collapsed')
359
+ return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
360
+ }, [collapsed])
361
+
362
+ // DSH already owns a right-sidebar corner seat. Use the adjacent Session
363
+ // utilities row as the persistent integration point: the native expand
364
+ // button disappears while DSH's own sidebar is open, but the seat and its
365
+ // sibling utilities stay mounted. The plugin renders all three controls in
366
+ // that one row and hides the intercepted native glyph, avoiding two sidebar
367
+ // buttons and avoiding the viewport-corner fallback over native content.
368
+ useEffect(() => {
369
+ const root = document.documentElement
370
+ let nativeButton: HTMLButtonElement | null = null
371
+ let toolbarHost: HTMLElement | null = null
372
+ let observedCluster: HTMLElement | null = null
373
+ let resizeObserver: ResizeObserver | undefined
374
+ let frame: number | null = null
375
+
376
+ const measure = (): void => {
377
+ frame = null
378
+ const next = document.querySelector<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)
379
+ if (next !== nativeButton) {
380
+ resizeObserver?.disconnect()
381
+ nativeButton?.removeAttribute('data-dsh-better-sidebar-toggle')
382
+ nativeButton = next
383
+ }
384
+
385
+ const nextToolbarHost = nativeToolbarHostOf(nativeButton)
386
+ setNativeToolbarHost(current => current === nextToolbarHost ? current : nextToolbarHost)
387
+ document.body.toggleAttribute('data-dsh-native-sidebar-toggle', nextToolbarHost !== null)
388
+
389
+ const cluster = document.querySelector<HTMLElement>('[data-dsh-toggle-cluster]')
390
+ if (nextToolbarHost !== toolbarHost || cluster !== observedCluster) {
391
+ resizeObserver?.disconnect()
392
+ toolbarHost = nextToolbarHost
393
+ observedCluster = cluster
394
+ if (typeof ResizeObserver !== 'undefined') {
395
+ resizeObserver = new ResizeObserver(scheduleMeasure)
396
+ if (toolbarHost !== null) resizeObserver.observe(toolbarHost)
397
+ if (observedCluster !== null) resizeObserver.observe(observedCluster)
398
+ }
399
+ }
400
+
401
+ if (nativeButton !== null) {
402
+ nativeButton.setAttribute('data-dsh-better-sidebar-toggle', '')
403
+ nativeButton.setAttribute('aria-label', state?.panelOpen === true ? t('collapse') : t('expand'))
404
+ const rect = (nextToolbarHost ?? nativeButton).getBoundingClientRect()
405
+ root.style.setProperty('--dsh-native-sidebar-toggle-top', `${Math.round(rect.top)}px`)
406
+ } else {
407
+ root.style.removeProperty('--dsh-native-sidebar-toggle-top')
408
+ }
409
+
410
+ if (cluster !== null) {
411
+ // Older hosts still use this reserve for the viewport fallback. The
412
+ // current host keeps the cluster in its own flex row and ignores it.
413
+ const reserve = Math.ceil(cluster.getBoundingClientRect().width) + 26
414
+ root.style.setProperty('--dsh-sidebar-toggle-reserve', `${reserve}px`)
415
+ }
416
+ }
417
+ function scheduleMeasure(): void {
418
+ if (frame !== null) return
419
+ frame = requestAnimationFrame(measure)
420
+ }
421
+ const interceptNativeClick = (event: MouseEvent): void => {
422
+ const target = event.target instanceof Element
423
+ ? event.target.closest<HTMLButtonElement>(NATIVE_SIDEBAR_TOGGLE)
424
+ : null
425
+ if (target === null) return
426
+ event.stopPropagation()
427
+ store.reduce(togglePanel)
428
+ }
429
+
430
+ document.addEventListener('click', interceptNativeClick, true)
431
+ window.addEventListener('resize', scheduleMeasure)
432
+ const mutationObserver = new MutationObserver(scheduleMeasure)
433
+ mutationObserver.observe(document.body, { childList: true, subtree: true })
434
+ measure()
435
+
436
+ return () => {
437
+ document.removeEventListener('click', interceptNativeClick, true)
438
+ window.removeEventListener('resize', scheduleMeasure)
439
+ mutationObserver.disconnect()
440
+ resizeObserver?.disconnect()
441
+ if (frame !== null) cancelAnimationFrame(frame)
442
+ nativeButton?.removeAttribute('data-dsh-better-sidebar-toggle')
443
+ document.body.removeAttribute('data-dsh-native-sidebar-toggle')
444
+ root.style.removeProperty('--dsh-native-sidebar-toggle-top')
445
+ root.style.removeProperty('--dsh-sidebar-toggle-reserve')
446
+ }
447
+ }, [state?.panelOpen, store])
448
+
449
+ // Title-bar / shell compatibility (the "位置兼容模式" scheme):
450
+ // auto — CONSERVATIVE: only the standard Window Controls Overlay
451
+ // geometry contributes (the real caption-overlay height,
452
+ // reactive to maximize/restore). No URL stamp, no preset, no
453
+ // guess — plain browsers see zero modification.
454
+ // preset — an opt-in built-in shell preset (shell-presets.ts) adds its
455
+ // per-shell strip as the no-WCO fallback.
456
+ // custom — the user's own CSS (injected below) + the legacy manual
457
+ // strip px.
458
+ // The resolved strip drives the SAME body attribute + CSS variable as the
459
+ // legacy boolean did, so the CSS contract is unchanged (layout.css /
460
+ // sidebar.module.css); only the value source changed. The cleanup removes
461
+ // both on unmount/boundary swap so a crashed sidebar never leaves them
462
+ // behind.
463
+ const desktopEnv = parseDesktopEnv()
464
+ const wco = useSyncExternalStore(
465
+ useMemo(() => subscribeWco, []),
466
+ getWcoSnapshot,
467
+ )
468
+ const scheme = snapshot.prefs.titleBarScheme
469
+ const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
470
+ const titleBarStrip = computeTitleBarStrip(
471
+ desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
472
+ )
473
+ const titleBarCompat = titleBarStrip > 0
474
+ useEffect(() => {
475
+ const root = document.documentElement
476
+ if (titleBarCompat) {
477
+ document.body.setAttribute('data-dsh-title-bar-compat', '')
478
+ root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
479
+ } else {
480
+ document.body.removeAttribute('data-dsh-title-bar-compat')
481
+ root.style.removeProperty('--dsh-title-bar-strip')
482
+ }
483
+ return () => {
484
+ document.body.removeAttribute('data-dsh-title-bar-compat')
485
+ root.style.removeProperty('--dsh-title-bar-strip')
486
+ }
487
+ }, [titleBarCompat, titleBarStrip])
488
+
489
+ // User-space CSS injection (the escape hatch): preset CSS (scheme
490
+ // `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
491
+ // the plugin's own styles — later in the cascade wins ties, and
492
+ // `!important` can override the JS-written inline strip variable. Each
493
+ // source gets its own tagged <style> so the running configuration stays
494
+ // inspectable; tags are removed on change/unmount so a stale stylesheet
495
+ // never outlives its fiber (HMR-safe).
496
+ const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
497
+ const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
498
+ useEffect(() => {
499
+ const tags: HTMLStyleElement[] = []
500
+ if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
501
+ if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
502
+ return () => { for (const tag of tags) tag.remove() }
503
+ }, [presetCss, customCss, preset?.id])
504
+
505
+ /**
506
+ * Bottom-panel merge on narrow viewports: whenever a session is current
507
+ * while narrow (mount, session switch, or a desktop→narrow transition),
508
+ * throw the bottom tree's tabs into the right tree. Idempotent — after
509
+ * the first migration the bottom tree is empty and the reducer returns
510
+ * the same reference, so this effect settles immediately.
511
+ */
512
+ useEffect(() => {
513
+ if (!narrow || sessionId === undefined) return
514
+ store.reduce(migrateBottomTabs)
515
+ }, [narrow, sessionId, store])
516
+
517
+ // While the session's header is still hydrating (or the session is blank),
518
+ // the list summary may carry no cwd; ask the host once (it falls back to
519
+ // the process cwd) so the explorer root and terminal cwd are real from
520
+ // first paint instead of showing "no session".
521
+ const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
522
+ useEffect(() => {
523
+ setFetchedCwd(undefined)
524
+ if (sessionId === undefined || summaryCwd !== undefined) return
525
+ let cancelled = false
526
+ api.sessionCwd({ sessionId })
527
+ .then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
528
+ .catch(() => { /* the explorer/git rows surface their own errors */ })
529
+ return () => { cancelled = true }
530
+ }, [sessionId, summaryCwd])
531
+ const cwd = summaryCwd ?? fetchedCwd
532
+
533
+ /**
534
+ * Agent terminals push: subscribe to the host's live list of agent-owned
535
+ * terminals for this session (created by the model through the
536
+ * `terminal_create` tool). The host pushes a JSON array on every
537
+ * create / close / exit; the sidebar reconciles the list into tabs
538
+ * (id `agent:<uuid>`, title from the agent). A disconnected socket
539
+ * retries with a short backoff so a refresh or transient drop reattaches
540
+ * the same shell without losing the agent's work — capped like the
541
+ * terminal view's own reconnect loop, so a refused endpoint never spins
542
+ * forever (the next session switch restarts the loop).
543
+ * While the terminal tab type is disabled in settings, pushes are
544
+ * ignored (no auto-added tabs); re-enabling makes the next push converge.
545
+ */
546
+ useEffect(() => {
547
+ if (sessionId === undefined) return
548
+ let socket: WebSocket | null = null
549
+ let retry: number | undefined
550
+ let closed = false
551
+ let failures = 0
552
+ const connect = (): void => {
553
+ if (closed) return
554
+ const url = new URL('/sidebar/ws/agent-terminals', location.origin)
555
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
556
+ url.search = new URLSearchParams({ sessionId }).toString()
557
+ socket = new WebSocket(url.toString())
558
+ socket.onmessage = (event) => {
559
+ if (typeof event.data !== 'string') return
560
+ try {
561
+ const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
562
+ if (!Array.isArray(list)) return
563
+ store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
564
+ ? s
565
+ : reconcileAgentTerminals(s, list))
566
+ } catch {
567
+ // Malformed push: ignore (the next push will reconcile).
568
+ }
569
+ }
570
+ socket.onclose = () => {
571
+ if (closed) return
572
+ failures += 1
573
+ if (failures >= FAILURE_LIMIT) {
574
+ console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
575
+ return
576
+ }
577
+ retry = window.setTimeout(connect, 2000)
578
+ }
579
+ socket.onerror = () => { socket?.close() }
580
+ }
581
+ connect()
582
+ return () => {
583
+ closed = true
584
+ window.clearTimeout(retry)
585
+ socket?.close()
586
+ }
587
+ }, [sessionId, store])
588
+
589
+ /**
590
+ * Agent opens push: subscribe to the host's `sidebar_open` requests for
591
+ * this session (the model actively opens a file / folder / HTTP(S) page).
592
+ * The host pushes one JSON request per open; the sidebar routes it to the
593
+ * matching built-in tab: a file opens in the editor (per-path dedupe), a
594
+ * folder opens a file window whose tree is rooted at the folder
595
+ * (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
596
+ * retries with a short backoff (mirror of the agent-terminals loop): the
597
+ * host queue keeps undelivered requests and replays them on the first
598
+ * attach, so a refresh or a session switch lands the opens the model
599
+ * queued while no view was connected.
600
+ * While the side-card setting is off, pushes are ignored as a defensive
601
+ * gate — the host already unregisters the tool and drains the queue.
602
+ */
603
+ useEffect(() => {
604
+ if (sessionId === undefined) return
605
+ let socket: WebSocket | null = null
606
+ let retry: number | undefined
607
+ let closed = false
608
+ let failures = 0
609
+ const connect = (): void => {
610
+ if (closed) return
611
+ const url = new URL('/sidebar/ws/agent-opens', location.origin)
612
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
613
+ url.search = new URLSearchParams({ sessionId }).toString()
614
+ socket = new WebSocket(url.toString())
615
+ socket.onmessage = (event) => {
616
+ if (typeof event.data !== 'string') return
617
+ try {
618
+ const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
619
+ if (request === null || typeof request !== 'object') return
620
+ if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
621
+ if (typeof request.target !== 'string' || request.target === '') return
622
+ if (store.getPrefs().agentOpenTools !== true) return
623
+ const scope = { sessionId }
624
+ const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
625
+ if (request.kind === 'url') {
626
+ ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
627
+ } else if (request.kind === 'folder') {
628
+ ctx.get('betterSidebar')?.openTab({
629
+ type: 'editor',
630
+ title,
631
+ path: request.target,
632
+ id: `editor:${request.target}`,
633
+ meta: { dir: true },
634
+ }, scope)
635
+ } else {
636
+ ctx.get('betterSidebar')?.openFile(scope, request.target, title)
637
+ }
638
+ } catch {
639
+ // Malformed push: ignore (the next push carries its own request).
640
+ }
641
+ }
642
+ socket.onclose = () => {
643
+ if (closed) return
644
+ failures += 1
645
+ if (failures >= FAILURE_LIMIT) {
646
+ console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
647
+ return
648
+ }
649
+ retry = window.setTimeout(connect, 2000)
650
+ }
651
+ socket.onerror = () => { socket?.close() }
652
+ }
653
+ connect()
654
+ return () => {
655
+ closed = true
656
+ window.clearTimeout(retry)
657
+ socket?.close()
658
+ }
659
+ }, [sessionId, store])
660
+
661
+ /**
662
+ * Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
663
+ * inject as VIRTUAL tabs into the first leaf of the right panel's split
664
+ * tree. The virtual tabs have unique ids (prefixed with the home session)
665
+ * and carry the home scope in meta. Clicking a virtual tab sets
666
+ * `activePinnedTabId` — the augmented tree overrides the leaf's `active`
667
+ * so the pinned tab's content renders in-place (TerminalView connects to
668
+ * the home session's PTY via WS, no session jump).
669
+ *
670
+ * Closing/unpinning a virtual tab targets the HOME session via reduceFor
671
+ * (which doesn't notify — targeted opens must not re-render the active
672
+ * session). The `pinnedRevision` state bump forces the pinnedEntries
673
+ * useMemo to recompute after such an action.
674
+ */
675
+ const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
676
+ const [pinnedRevision, setPinnedRevision] = useState(0)
677
+
678
+ /**
679
+ * Cross-session pinned-tab collection. Recomputed on every store notify,
680
+ * session-list change, and pinned action (the revision bump covers
681
+ * reduceFor updates that don't notify). Only tabs from OTHER sessions —
682
+ * the viewer's own pinned tabs are already on its tab strip.
683
+ */
684
+ const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
685
+ if (sessionId === undefined) return []
686
+ return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
687
+ }, [store, sessionId, cwd, snapshot, pinnedRevision])
688
+
689
+ /** Virtual SidebarTab objects for the pinned entries (stable references
690
+ * via useMemo so TabContent's memo comparator holds). */
691
+ const pinnedVirtualTabs = useMemo(
692
+ () => pinnedEntries.map(createPinnedVirtualTab),
693
+ [pinnedEntries],
694
+ )
695
+
696
+ /** The right panel's split tree with pinned virtual tabs injected into the
697
+ * first leaf. When `activePinnedTabId` is set, that leaf's `active` is
698
+ * overridden so the pinned tab's content is visible. */
699
+ const augmentedTree = useMemo(
700
+ () => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
701
+ [state, pinnedVirtualTabs, activePinnedTabId],
702
+ )
703
+
704
+ // The app shell's center column: the bottom panel spans ONLY that column
705
+ // ("squeezes the agent output area") — it starts at the app sidebar's
706
+ // right edge and ends at the details column's left edge (the details
707
+ // column sits between the center and the right panel). Measured directly
708
+ // from the AppFrame's center column DOM. Current hosts expose it as the
709
+ // parent of the [data-slot="main"] wrapper; earlier hosts marked the grid
710
+ // item as [data-pane="conversation"] or wrapped [data-slot="conversation"].
711
+ // This matches layout.css's composite anchor so the panel tracks the real
712
+ // horizontal edges — including the animated margin-right push while the
713
+ // right panel opens/closes; a frame that never appears keeps the initial
714
+ // zero-size fallback (the panel renders at 0 width until measured).
715
+ // The rect lives in a REF (not state): the open/close transition resizes
716
+ // the center column EVERY frame for its duration, and reacting per frame
717
+ // with setState re-renders the whole Sidebar (every mounted tab) at
718
+ // animation cadence — the visible toggle jank (#315). measureCenter
719
+ // writes the bottom panel's edges directly (same DOM-write pattern as
720
+ // applyDrag), so the panel still tracks the column per frame with zero
721
+ // React work; `centerMeasured` flips ONCE to gate the hidden→visible
722
+ // first-paint fallback.
723
+ const centerRectRef = useRef({ left: 0, right: 0 })
724
+ const [centerMeasured, setCenterMeasured] = useState(false)
725
+ // Refs keep the measure step stable across renders and let it skip work
726
+ // mid-drag: during a width/corner drag the layout push resizes the center
727
+ // column every frame, and reacting (setCenterRect → re-render) would
728
+ // re-introduce the drag lag this shell deliberately avoids. applyDrag
729
+ // writes the bottom panel's edges directly, so measurement pauses then.
730
+ const centerColRef = useRef<HTMLElement | null>(null)
731
+ const draggingRef = useRef(false)
732
+ const measureCenter = useCallback((): void => {
733
+ if (draggingRef.current) return
734
+ const col = centerColRef.current
735
+ if (col === null) return
736
+ if (!col.isConnected) {
737
+ // The observed column was detached (HMR re-render swapped the node
738
+ // in place): its rect is stale garbage. Drop the ref — the locate
739
+ // chain re-runs on the next mutation/interval tick and picks up the
740
+ // new column node (issue #248).
741
+ centerColRef.current = null
742
+ return
743
+ }
744
+ const rect = col.getBoundingClientRect()
745
+ // Ref + direct DOM write (see the centerRectRef comment): the bottom
746
+ // panel keeps tracking the center column per frame during the right
747
+ // panel's open/close animation without re-rendering the shell. The
748
+ // one-shot measured flip renders the panel visible once (a stale
749
+ // {0,0} fallback would flash full-width).
750
+ centerRectRef.current = { left: rect.left, right: rect.right }
751
+ const bottom = bottomRef.current
752
+ if (bottom !== null) {
753
+ bottom.style.setProperty('left', `${rect.left}px`)
754
+ bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
755
+ }
756
+ setCenterMeasured(prev => (prev ? prev : true))
757
+ }, [])
758
+ useEffect(() => {
759
+ let disposed = false
760
+ let observer: ResizeObserver | undefined
761
+ // Locate the AppFrame's center column. Current DSH puts [data-slot="main"]
762
+ // directly inside it; earlier hosts marked the grid item with
763
+ // [data-pane="conversation"] or put [data-slot="conversation"] inside it.
764
+ // Keep the JS locator aligned with layout.css's composite anchor, without
765
+ // hashed-class or positional dependencies. The shell swaps the
766
+ // boot page for the AppFrame only AFTER boot settles, so the first
767
+ // query may miss it. Never give up: watch #root's subtree (the swap and
768
+ // HMR re-renders mutate it) and re-run this locator — querying once and
769
+ // bailing would strand the panel at the zero-size fallback forever
770
+ // (observed: a 1px sliver at the viewport's left edge).
771
+ const locate = (): void => {
772
+ if (disposed) return
773
+ const col = document.querySelector<HTMLElement>('#root [data-slot="main"]')?.parentElement
774
+ ?? document.querySelector<HTMLElement>('#root [data-dsh-frame] > [data-pane="conversation"]')
775
+ ?? document.querySelector<HTMLElement>('#root [data-slot="conversation"]')?.parentElement
776
+ ?? null
777
+ if (col === null || !col.isConnected) {
778
+ if (centerColRef.current !== null) {
779
+ centerColRef.current = null
780
+ observer?.disconnect()
781
+ observer = undefined
782
+ }
783
+ return
784
+ }
785
+ if (centerColRef.current !== col) {
786
+ // A NEW column node (boot swap, HMR re-render, or a previous locate
787
+ // that found nothing): attach the ResizeObserver to THIS node and
788
+ // measure it once. Same-node size changes are the ResizeObserver's
789
+ // job — no forced measurement here, because a forced
790
+ // getBoundingClientRect per mutation would reflow the shell at
791
+ // mutation cadence.
792
+ centerColRef.current = col
793
+ observer?.disconnect()
794
+ observer = new ResizeObserver(measureCenter)
795
+ observer.observe(col)
796
+ measureCenter()
797
+ }
798
+ }
799
+ locate()
800
+ // rAF-debounce the mutation watchers: #root's subtree changes at chat
801
+ // cadence (streaming turns), and locate() itself must stay cheap.
802
+ let locateFrame: number | null = null
803
+ const scheduleLocate = (): void => {
804
+ if (locateFrame !== null) return
805
+ // Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
806
+ // attribute, which is the mutation this watcher observes — relocating
807
+ // per drag frame is pointless (the center column node cannot change
808
+ // while the pointer is captured) and adds a querySelector to every
809
+ // frame's budget (#315). The 1.5s retry below still covers any node
810
+ // swap that somehow lands mid-drag.
811
+ if (draggingRef.current) return
812
+ locateFrame = requestAnimationFrame(() => {
813
+ locateFrame = null
814
+ locate()
815
+ })
816
+ }
817
+ const watcher = new MutationObserver(scheduleLocate)
818
+ const root = document.getElementById('root')
819
+ if (root !== null) watcher.observe(root, { childList: true, subtree: true })
820
+ // The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
821
+ // clears those variables on teardown and re-writes them on setup — and
822
+ // that is also the moment the shell may have re-created the center
823
+ // column under a REUSED #root child (React swaps nodes in place, so
824
+ // #root's childList never changes and the watcher above never fires).
825
+ // Watching <html>'s style attribute catches that re-sync: the push
826
+ // rewrite re-locates and re-measures, so the bottom panel recovers
827
+ // instead of staying hidden on a stale {0,0} center rect.
828
+ const htmlStyleWatcher = new MutationObserver(scheduleLocate)
829
+ htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
830
+ // Last-resort safety net (issue #248): no watcher is guaranteed to fire
831
+ // for every HMR teardown/setup interleaving (e.g. the style attribute
832
+ // may end up byte-identical, and the col may be swapped before the
833
+ // subtree watcher attaches). A slow unconditional re-locate makes the
834
+ // panel converge on the real column within a couple of seconds no
835
+ // matter what sequence the shell used. locate() is cheap when nothing
836
+ // changed (one querySelector + an identity compare; no forced layout).
837
+ const retry = window.setInterval(locate, 1500)
838
+ return () => {
839
+ disposed = true
840
+ if (locateFrame !== null) cancelAnimationFrame(locateFrame)
841
+ window.clearInterval(retry)
842
+ observer?.disconnect()
843
+ watcher.disconnect()
844
+ htmlStyleWatcher.disconnect()
845
+ centerColRef.current = null
846
+ }
847
+ // Opening the bottom panel re-runs the whole locate/measure chain: a
848
+ // panel opened before the center column was ever found must not stay
849
+ // invisible forever (the HMR recovery path depends on the observers
850
+ // above, this is the belt-and-braces retry for the open moment itself).
851
+ }, [measureCenter, state?.bottomOpen])
852
+
853
+ /**
854
+ * Free windows — drag-out detection. The tab strips already drive HTML5
855
+ * DnD (payload application/x-dsh-tab) with drops owned by the panes
856
+ * (split/merge); this shell watches the DOCUMENT (capture) for the same
857
+ * drag hovering OUTSIDE the panel host: while the pointer is over the
858
+ * conversation column it arms the drop (preventDefault) and shows a hint
859
+ * overlay there, and the drop floats the tab at the release point. Targets
860
+ * inside the host are ignored here, so pane drops keep their behavior
861
+ * untouched. Only OUR tab drags count (the body flag is the tab strip's;
862
+ * OS file drags and any DSH drags pass through). Narrow viewports skip
863
+ * the gesture — the merged drawer covers the conversation, leaving
864
+ * nothing to drop onto (the tab context menu entry still floats tabs).
865
+ */
866
+ const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
867
+ const floatHintRef = useRef(false)
868
+ useEffect(() => {
869
+ if (narrow || sessionId === undefined) return
870
+ const inPanelHost = (target: EventTarget | null): boolean =>
871
+ target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
872
+ /** The conversation column's rect when the pointer is over it (and not
873
+ * over our own surfaces); null otherwise. */
874
+ const overConversation = (event: DragEvent): DOMRect | null => {
875
+ if (inPanelHost(event.target)) return null
876
+ const col = centerColRef.current
877
+ if (col === null || !col.isConnected) return null
878
+ const rect = col.getBoundingClientRect()
879
+ if (rect.width === 0 || rect.height === 0) return null
880
+ const { clientX: x, clientY: y } = event
881
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
882
+ return rect
883
+ }
884
+ const onDragOver = (event: DragEvent): void => {
885
+ if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
886
+ const rect = overConversation(event)
887
+ if (rect !== null) {
888
+ // preventDefault on dragover is what makes the browser deliver the
889
+ // drop (and drop the "no" cursor) over the conversation area.
890
+ event.preventDefault()
891
+ setFloatHint((prev) => {
892
+ const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
893
+ if (prev !== null && prev.left === next.left && prev.top === next.top
894
+ && prev.width === next.width && prev.height === next.height) return prev
895
+ return next
896
+ })
897
+ floatHintRef.current = true
898
+ } else if (floatHintRef.current) {
899
+ floatHintRef.current = false
900
+ setFloatHint(null)
901
+ }
902
+ }
903
+ const onDrop = (event: DragEvent): void => {
904
+ if (!floatHintRef.current) return
905
+ floatHintRef.current = false
906
+ setFloatHint(null)
907
+ const rect = overConversation(event)
908
+ if (rect === null) return
909
+ event.preventDefault()
910
+ event.stopPropagation()
911
+ const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
912
+ if (payload === null) return
913
+ store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
914
+ }
915
+ const clear = (): void => {
916
+ if (!floatHintRef.current) return
917
+ floatHintRef.current = false
918
+ setFloatHint(null)
919
+ }
920
+ document.addEventListener('dragover', onDragOver, true)
921
+ document.addEventListener('drop', onDrop, true)
922
+ window.addEventListener('dragend', clear, true)
923
+ window.addEventListener('blur', clear)
924
+ return () => {
925
+ document.removeEventListener('dragover', onDragOver, true)
926
+ document.removeEventListener('drop', onDrop, true)
927
+ window.removeEventListener('dragend', clear, true)
928
+ window.removeEventListener('blur', clear)
929
+ }
930
+ }, [narrow, sessionId, store])
931
+
932
+ /**
933
+ * Bottom-panel first-expansion auto terminal: the FIRST time the user
934
+ * expands the bottom panel in a session, try to open a fresh terminal tab
935
+ * there. "Try" is literal — the terminal's own quota and enable switch
936
+ * gate the attempt (a full quota or a disabled terminal type makes it a
937
+ * no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
938
+ * nested settings toggle, default on). Only a false→true TRANSITION fires
939
+ * (a panel persisted open never counts as an expansion), and the session's
940
+ * bottomOpenedOnce flag is set atomically with the first fire so later
941
+ * expansions never repeat it.
942
+ */
943
+ const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
944
+ useEffect(() => {
945
+ // The bottom panel does not exist on narrow viewports (the two
946
+ // workbenches merge into one panel), so the first-expansion auto
947
+ // terminal is a desktop-only behavior.
948
+ if (narrow) return
949
+ if (state === undefined) return
950
+ const wasOpen = bottomWasOpenRef.current
951
+ bottomWasOpenRef.current = state.bottomOpen
952
+ if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
953
+ if (state.bottomOpenedOnce) return
954
+ if (store.getPrefs().bottomPanelAutoTerminal === false) return
955
+ if (ctx.get('betterSidebar')?.isTabEnabled('terminal') === false) return
956
+ // Land the tab in the bottom panel's first pane; the once-flag is set
957
+ // atomically so later expansions never repeat the auto-open.
958
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
959
+ ctx.get('betterSidebar')?.openTab({ type: 'terminal' })
960
+ }, [state, store, ctx, narrow])
961
+
962
+ // Panel drags: the right panel's width (left edge strip), the bottom
963
+ // panel's height (top edge strip), and the shared corner (both at once).
964
+ // Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
965
+ // variables) instead of round-tripping the store on every pointer move —
966
+ // a store reduce re-renders both workbenches (terminals, editors…) per
967
+ // move, which is the visible drag lag. The store is committed once on
968
+ // pointer up (clamping + persistence).
969
+ const panelRef = useRef<HTMLDivElement | null>(null)
970
+ const bottomRef = useRef<HTMLDivElement | null>(null)
971
+ const widthDrag = useRef({ startX: 0, startWidth: 0 })
972
+ const [draggingWidth, setDraggingWidth] = useState(false)
973
+ const bottomDrag = useRef({ startY: 0, startHeight: 0 })
974
+ const [draggingBottom, setDraggingBottom] = useState(false)
975
+ const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
976
+ const [draggingCorner, setDraggingCorner] = useState(false)
977
+ const anyDragging = draggingWidth || draggingBottom || draggingCorner
978
+
979
+ // Pause center-column measurement while dragging, and re-measure once the
980
+ // drag settles at its committed size. The store commit lands on release and
981
+ // the final width equals the last drag width, so no ResizeObserver event
982
+ // fires to refresh centerRect — this explicit re-measure covers that gap.
983
+ useEffect(() => {
984
+ draggingRef.current = anyDragging
985
+ if (!anyDragging) measureCenter()
986
+ }, [anyDragging, measureCenter])
987
+
988
+ // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
989
+ // re-clamps on commit; these keep the panels from overshooting mid-drag).
990
+ const clampWidth = (width: number): number =>
991
+ Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
992
+ const clampHeight = (height: number): number =>
993
+ Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
994
+
995
+ /** Single writer for the layout-push variables: the app shell gives up
996
+ * the panel's width/height while open (0 while collapsed) through
997
+ * layout.css's margins. Every size change — drag frames and committed
998
+ * state — flows through here so the push never forks between paths. */
999
+ const writeGeometry = (width: number, height: number): void => {
1000
+ document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
1001
+ document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
1002
+ // The corner handle positions itself relative to the panel (CSS
1003
+ // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
1004
+ // variables are all it needs — no viewport coordinates written here
1005
+ // (issue #106: skins that inset the panels must not fight JS coords).
1006
+ }
1007
+
1008
+ /** Last size a drag actually applied to the DOM (updated by applyDrag).
1009
+ * When a pointer stream dies without any position info (issue #247: an
1010
+ * ultra-fast flick whose release events carried no usable coordinates),
1011
+ * the abort path adopts this instead of rolling back to the pre-drag
1012
+ * value — the DOM's current size is the only truthful record left. */
1013
+ const lastDragSize = useRef<{ width: number; height: number } | null>(null)
1014
+
1015
+ /** Apply a drag size to the DOM without touching React state or the store.
1016
+ * The bottom panel's right edge tracks the right panel's left edge HERE
1017
+ * too — React state only updates on release, so the inline right must be
1018
+ * written directly or the bottom panel would lag the sidebar mid-drag.
1019
+ * The layout push rides the shared writer (writeGeometry). */
1020
+ const applyDrag = (width: number, height: number): void => {
1021
+ lastDragSize.current = { width, height }
1022
+ panelRef.current?.style.setProperty('width', `${width}px`)
1023
+ bottomRef.current?.style.setProperty('height', `${height}px`)
1024
+ // centerRect.right is the center column's right edge at the committed
1025
+ // width (innerWidth - state.width - detailsWidth), so this equals
1026
+ // `width + detailsWidth` — derived from the measured column, keeping the
1027
+ // drag write-only (no React re-render mid-drag).
1028
+ bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
1029
+ const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
1030
+ writeGeometry(width, bottomPush)
1031
+ }
1032
+
1033
+ // Drags write at most once per frame: pointer events fire several times
1034
+ // faster than the display refresh, and each write reflows the app shell
1035
+ // (the layout push) plus the panels — batching to one write per frame is
1036
+ // what keeps the drag smooth. The store is still committed once on release.
1037
+ const dragFrame = useRef<number | null>(null)
1038
+ const pendingDrag = useRef<{ width: number; height: number } | null>(null)
1039
+ const scheduleDrag = (width: number, height: number): void => {
1040
+ pendingDrag.current = { width, height }
1041
+ if (dragFrame.current !== null) return
1042
+ dragFrame.current = requestAnimationFrame(() => {
1043
+ dragFrame.current = null
1044
+ const pending = pendingDrag.current
1045
+ if (pending !== null) {
1046
+ pendingDrag.current = null
1047
+ applyDrag(pending.width, pending.height)
1048
+ }
1049
+ })
1050
+ }
1051
+
1052
+ /** Flush any pending drag write and stop scheduling (the store commit on
1053
+ * pointer up applies the final clamped values). */
1054
+ const stopDragScheduling = (): void => {
1055
+ if (dragFrame.current !== null) {
1056
+ cancelAnimationFrame(dragFrame.current)
1057
+ dragFrame.current = null
1058
+ }
1059
+ pendingDrag.current = null
1060
+ }
1061
+
1062
+ /**
1063
+ * Finalize a drag on pointer up: flush the LAST drag frame to the DOM
1064
+ * synchronously, then commit the SAME clamped values to the store. A fast
1065
+ * release cancels the rAF before it ran — without the flush the DOM would
1066
+ * sit at the pre-drag size until React re-renders with the committed
1067
+ * value, and a value that never made it into a move handler would never
1068
+ * be applied at all. The measurement pause ends here too: the center
1069
+ * column is re-measured BEFORE the committed re-render lands, so the
1070
+ * bottom panel's React-rendered right edge already reflects the new
1071
+ * width (otherwise the re-render would re-apply the stale rect — the
1072
+ * bottom panel visibly jumps for one frame).
1073
+ */
1074
+ const commitDrag = (
1075
+ width: number,
1076
+ height: number,
1077
+ reduce: (state: SidebarState) => SidebarState,
1078
+ ): void => {
1079
+ stopDragScheduling()
1080
+ applyDrag(width, height)
1081
+ draggingRef.current = false
1082
+ measureCenter()
1083
+ store.reduce(reduce)
1084
+ }
1085
+
1086
+ /** Set once a drag's pointerup handler commits — premature capture loss
1087
+ * (pointercancel / lostpointercapture without pointerup) must then be told
1088
+ * apart from a normal release. */
1089
+ const dragCommitted = useRef(false)
1090
+ /**
1091
+ * Abort a drag whose pointer stream was interrupted (pointercancel, or
1092
+ * capture lost before pointerup): no pointerup will arrive, so without
1093
+ * this the dragging state would stick true and center-column measurement
1094
+ * would stay paused forever — the bottom panel freezes at stale edges and
1095
+ * stops tracking sidebar/app-rail layout changes.
1096
+ *
1097
+ * A FAST release is the common trigger: browsers merge pointermove bursts,
1098
+ * and an ultra-fast flick can cancel the stream before ANY move lands.
1099
+ * The commit order is therefore: the LAST KNOWN dragged size (the rAF
1100
+ * pending value) first, then the interrupting event's own pointer
1101
+ * position (only pointercancel is trusted to carry coordinates —
1102
+ * lostpointercapture's coordinates are not guaranteed, so the handlers
1103
+ * pass the event only from pointercancel), and finally the size the drag
1104
+ * last APPLIED to the DOM (lastDragSize). A drag that produced none of
1105
+ * those (pure down+up at the same spot) commits the store's own sizes —
1106
+ * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
1107
+ * an interrupted fast flick; the abort path added in the unified-host
1108
+ * refactor did, and that regression is what this ordering removes).
1109
+ *
1110
+ * Every commit path marks the drag committed, so the interrupt
1111
+ * double-fire (pointercancel → lostpointercapture) cannot commit once
1112
+ * and then roll the same drag back.
1113
+ */
1114
+ const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
1115
+ if (dragCommitted.current) return
1116
+ const pending = pendingDrag.current
1117
+ let width: number | undefined
1118
+ let height: number | undefined
1119
+ if (pending !== null) {
1120
+ width = pending.width
1121
+ height = pending.height
1122
+ } else if (event !== undefined) {
1123
+ // No move ever landed: the cancel position is all we have — commit it
1124
+ // (clamped) instead of rolling back the flick.
1125
+ if (draggingWidth) {
1126
+ width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
1127
+ height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0)
1128
+ } else if (draggingBottom) {
1129
+ width = Math.min(state?.width ?? 0, window.innerWidth)
1130
+ height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)))
1131
+ } else if (draggingCorner) {
1132
+ width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
1133
+ height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)))
1134
+ }
1135
+ }
1136
+ if (width !== undefined && height !== undefined) {
1137
+ dragCommitted.current = true
1138
+ pendingDrag.current = null
1139
+ if (dragFrame.current !== null) {
1140
+ cancelAnimationFrame(dragFrame.current)
1141
+ dragFrame.current = null
1142
+ }
1143
+ applyDrag(width, height)
1144
+ draggingRef.current = false
1145
+ measureCenter()
1146
+ store.reduce(s => setBottomHeight(setWidth(s, width), height))
1147
+ } else {
1148
+ // No pending write and no usable event coordinates: keep the size the
1149
+ // drag last applied instead of rolling back to the pre-drag value
1150
+ // (the flick's moves may have been consumed by the rAF just before
1151
+ // the stream died — the DOM already shows the dragged size). Clamp to
1152
+ // the current geometry like the layout-push effect (closed/narrow
1153
+ // panels are written 0 by the push).
1154
+ dragCommitted.current = true
1155
+ stopDragScheduling()
1156
+ const last = lastDragSize.current
1157
+ const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({
1158
+ narrow,
1159
+ panelOpen: state?.panelOpen === true,
1160
+ bottomOpen: state?.bottomOpen === true,
1161
+ width: last?.width ?? state?.width ?? 0,
1162
+ bottomHeight: last?.height ?? state?.bottomHeight ?? 0,
1163
+ viewportWidth: viewport.width,
1164
+ viewportHeight: layoutViewportHeight,
1165
+ })
1166
+ applyDrag(adoptedWidth, adoptedHeight)
1167
+ draggingRef.current = false
1168
+ measureCenter()
1169
+ store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
1170
+ }
1171
+ reset()
1172
+ }
1173
+
1174
+ // Layout push: the app shell gives up the panel's width/height while the
1175
+ // panels are open (0 while collapsed), so the conversation and input bar
1176
+ // are squeezed instead of covered. The margins are capped at the viewport
1177
+ // so a stale persisted size (e.g. fullscreen on a bigger window) can never
1178
+ // crush the app shell to zero. Dragging disables the layout transition.
1179
+ // On NARROW viewports the drawer FLOATS over the app shell — no push, the
1180
+ // conversation keeps the full width behind the drawer.
1181
+ useEffect(() => {
1182
+ const { width, height } = layoutPushSize({
1183
+ narrow,
1184
+ panelOpen: snapshot.state?.panelOpen === true,
1185
+ bottomOpen: snapshot.state?.bottomOpen === true,
1186
+ width: snapshot.state?.width ?? 0,
1187
+ bottomHeight: snapshot.state?.bottomHeight ?? 0,
1188
+ viewportWidth: viewport.width,
1189
+ viewportHeight: layoutViewportHeight,
1190
+ })
1191
+ const bottomPush = !narrow && snapshot.state?.bottomOpen === true
1192
+ ? height + keyboardInset
1193
+ : 0
1194
+ writeGeometry(width, bottomPush)
1195
+ }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset])
1196
+ // Unmount must release the push (issue #31): when the boundary swaps the
1197
+ // whole sidebar after a render crash (or the plugin fiber is disposed /
1198
+ // HMR), the CSS variables would otherwise stay on <html> and layout.css
1199
+ // keeps squeezing #root with a stale margin — "the sidebar cannot be
1200
+ // hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
1201
+ // in the push effect's cleanup: React can yield between a passive
1202
+ // effect's cleanup and setup phases, and removing the variables on a
1203
+ // dependency change used to paint the push-less layout for a frame (the
1204
+ // center column went full width) while the re-add restarted the margin
1205
+ // transition — the bottom panel flashed full width after every width
1206
+ // drag (issue #258). Keeping the variables continuously valid while
1207
+ // mounted makes the push invisible to mid-flush style recals.
1208
+ useEffect(() => {
1209
+ return () => {
1210
+ document.documentElement.style.removeProperty('--dsh-sidebar-width')
1211
+ document.documentElement.style.removeProperty('--dsh-sidebar-height')
1212
+ }
1213
+ }, [])
1214
+ useEffect(() => {
1215
+ if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
1216
+ else document.body.removeAttribute('data-dsh-sidebar-dragging')
1217
+ }, [anyDragging])
1218
+
1219
+
1220
+ const actions: WorkbenchActions = useMemo(() => ({
1221
+ closeTab: (paneId, tabId) => {
1222
+ // A closed terminal releases its pty immediately — including when its
1223
+ // socket is mid-reconnect, where the unmount close frame never reaches
1224
+ // the host and the process would hold the quota until the grace ends.
1225
+ // Agent terminals (tabId `agent:<uuid>`) close through a different
1226
+ // host route: the WS close frame is the primary path (sent by
1227
+ // TerminalView on unmount), and the agent-pty.close HTTP route is the
1228
+ // fallback when the WS is down.
1229
+ const current = store.getSnapshot().state
1230
+ // Terminal tabs may live in EITHER tree (the bottom panel hosts them
1231
+ // too) — the pty-release lookup covers both, or the HTTP fallback is
1232
+ // skipped for a bottom-panel terminal whose WS frame never arrived.
1233
+ const leaf = current === undefined
1234
+ ? undefined
1235
+ : leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
1236
+ const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
1237
+ // Route through the service: the tab-bar close is the canonical close
1238
+ // path (finds the pane itself, fires descriptor.onClose); the session
1239
+ // scope (with its cwd) rides to the callback.
1240
+ ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
1241
+ if (tab?.type === 'terminal') {
1242
+ if (isAgentTabId(tabId)) {
1243
+ const uuid = agentUuidOf(tabId)
1244
+ void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
1245
+ } else if (sessionId !== undefined) {
1246
+ void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
1247
+ }
1248
+ }
1249
+ },
1250
+ activateTab: (paneId, tabId) => {
1251
+ // Route through the service: same reducer (finds the pane in EITHER
1252
+ // tree, sets the active pane) and fires descriptor.onActivate; the
1253
+ // session scope (with its cwd) rides to the callback.
1254
+ ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
1255
+ },
1256
+ focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
1257
+ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
1258
+ store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
1259
+ },
1260
+ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
1261
+ store.reduce((s) => {
1262
+ let index = -1
1263
+ const source = leafWithTab(s.splits, beforeTabId)
1264
+ if (source !== undefined && source.id === toPane) {
1265
+ index = source.tabs.findIndex(tab => tab.id === beforeTabId)
1266
+ }
1267
+ return moveTab(s, payload.paneId, payload.tabId, toPane, index)
1268
+ })
1269
+ },
1270
+ resizeSplit: (splitId, index, deltaFrac) => {
1271
+ store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
1272
+ },
1273
+ // The tab context menu's "move to free window": no drop point exists, so
1274
+ // the window is born over the conversation column's center (the user's
1275
+ // focus area; clamped into the viewport by the reducer) — the same
1276
+ // landing the drag-out gesture produces.
1277
+ floatTab: (tabId) => {
1278
+ const col = centerColRef.current
1279
+ const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
1280
+ const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
1281
+ const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
1282
+ store.reduce(s => floatTab(s, tabId, x, y))
1283
+ },
1284
+ // Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
1285
+ // pin time so a workspace-scoped pin only resurfaces in sessions whose
1286
+ // cwd matches. Unpin passes null — the tab stays open in its home
1287
+ // session, just unmarked.
1288
+ pinTab: (tabId, scope) => {
1289
+ store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
1290
+ },
1291
+ }), [store, sessionId, cwd])
1292
+
1293
+ /**
1294
+ * Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
1295
+ * other sessions). Regular tab ids pass through unchanged. Virtual ids are
1296
+ * detected by the `pinned:` prefix and routed to the HOME session via
1297
+ * reduceFor (which doesn't notify — the revision bump is the local signal).
1298
+ */
1299
+ const wrappedActions = useMemo<WorkbenchActions>(() => {
1300
+ if (pinnedVirtualTabs.length === 0) return actions
1301
+ const closePinnedInHome = (virtualId: string): void => {
1302
+ const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
1303
+ // The home cwd lives in the virtual tab's meta (snapshotted at pin
1304
+ // time) — pass it to ptyClose so the host resolves the PTY in the
1305
+ // correct workspace container (same scope the WS open used).
1306
+ const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
1307
+ const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
1308
+ store.reduceFor(homeSessionId, s => {
1309
+ const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
1310
+ if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
1311
+ if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
1312
+ return s
1313
+ })
1314
+ if (isAgentTabId(originalId)) {
1315
+ void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
1316
+ } else {
1317
+ void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
1318
+ }
1319
+ if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
1320
+ setPinnedRevision(v => v + 1)
1321
+ }
1322
+ return {
1323
+ ...actions,
1324
+ activateTab: (paneId, tabId) => {
1325
+ if (isPinnedVirtualId(tabId)) {
1326
+ setActivePinnedTabId(tabId)
1327
+ } else {
1328
+ setActivePinnedTabId(null)
1329
+ actions.activateTab(paneId, tabId)
1330
+ }
1331
+ },
1332
+ closeTab: (paneId, tabId) => {
1333
+ if (isPinnedVirtualId(tabId)) {
1334
+ closePinnedInHome(tabId)
1335
+ } else {
1336
+ actions.closeTab(paneId, tabId)
1337
+ }
1338
+ },
1339
+ moveTabBefore: (payload, toPane, beforeTabId) => {
1340
+ if (isPinnedVirtualId(payload.tabId)) return
1341
+ if (isPinnedVirtualId(beforeTabId)) {
1342
+ actions.moveTabToEdge(payload, toPane, 'center')
1343
+ } else {
1344
+ actions.moveTabBefore(payload, toPane, beforeTabId)
1345
+ }
1346
+ },
1347
+ moveTabToEdge: (payload, toPane, zone) => {
1348
+ if (isPinnedVirtualId(payload.tabId)) return
1349
+ actions.moveTabToEdge(payload, toPane, zone)
1350
+ },
1351
+ floatTab: (tabId) => {
1352
+ if (isPinnedVirtualId(tabId)) return
1353
+ actions.floatTab(tabId)
1354
+ },
1355
+ pinTab: (tabId, scope) => {
1356
+ if (isPinnedVirtualId(tabId)) {
1357
+ if (scope !== null) return
1358
+ const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
1359
+ store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
1360
+ if (activePinnedTabId === tabId) setActivePinnedTabId(null)
1361
+ setPinnedRevision(v => v + 1)
1362
+ } else {
1363
+ actions.pinTab?.(tabId, scope)
1364
+ }
1365
+ },
1366
+ }
1367
+ }, [actions, pinnedVirtualTabs, activePinnedTabId, store])
1368
+
1369
+ /**
1370
+ * The explorer's @-reference button: append `@<relative path>` to the
1371
+ * session's composer draft (space-separated). The conversation service is
1372
+ * resolved lazily through `ctx.get` (the inject-free read — the app's own
1373
+ * plugins read 'conversation' the same way); a missing service or scope
1374
+ * degrades to a logged no-op, never a crash. Defined above the no-session
1375
+ * early return — a hook must never sit behind a conditional return
1376
+ * (React counts hooks per render).
1377
+ */
1378
+ const referenceInChat = useCallback((path: string): void => {
1379
+ if (sessionId === undefined) return
1380
+ appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
1381
+ }, [ctx, sessionId, cwd])
1382
+
1383
+ if (state === undefined || sessionId === undefined) {
1384
+ // Keep the unavailable controls focusable: touch users have no hover, so
1385
+ // focus is the only way the existing Tooltip can explain what is missing.
1386
+ return (
1387
+ <div data-dsh-panel-host {...osFileDragShield}>
1388
+ {nativeToolbarHost !== null ? createPortal(
1389
+ <div className={clsx(css.toggleCluster, css.toggleClusterNative)} data-dsh-toggle-cluster data-dsh-native-toolbar-cluster>
1390
+ {!narrow && (
1391
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
1392
+ <button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
1393
+ <IconPanelBottomOutline16 />
1394
+ </button>
1395
+ </Tooltip>
1396
+ )}
1397
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
1398
+ <button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
1399
+ <IconPanelRightOutline16 />
1400
+ </button>
1401
+ </Tooltip>
1402
+ <UserManualButton />
1403
+ </div>,
1404
+ nativeToolbarHost,
1405
+ ) : <div className={css.toggleCluster} data-dsh-toggle-cluster>
1406
+ {!narrow && (
1407
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
1408
+ <button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
1409
+ <IconPanelBottomOutline16 />
1410
+ </button>
1411
+ </Tooltip>
1412
+ )}
1413
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
1414
+ <button type="button" className={css.toggleButton} aria-disabled="true" aria-label={t('noSession')}>
1415
+ <IconPanelRightOutline16 />
1416
+ </button>
1417
+ </Tooltip>
1418
+ <UserManualButton />
1419
+ </div>}
1420
+ </div>
1421
+ )
1422
+ }
1423
+
1424
+ const bottomPanelHeight = layoutPushSize({
1425
+ narrow,
1426
+ panelOpen: state.panelOpen,
1427
+ // Keep the hidden panel's geometry ready for its slide-in transition;
1428
+ // only the layout push itself becomes zero while it is closed.
1429
+ bottomOpen: true,
1430
+ width: state.width,
1431
+ bottomHeight: state.bottomHeight,
1432
+ viewportWidth: viewport.width,
1433
+ viewportHeight: layoutViewportHeight,
1434
+ }).height
1435
+
1436
+ const onNewTab = (optionId: string): void => {
1437
+ const service = ctx.get('betterSidebar')
1438
+ const descriptor = service?.getTab(optionId)
1439
+ if (service === undefined || descriptor === undefined) return
1440
+ const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
1441
+ // The session scope rides along: lifecycle callbacks receive it (and
1442
+ // the open stays in the current session, as before).
1443
+ service.openTab({ type: optionId, title }, { sessionId, cwd })
1444
+ }
1445
+
1446
+ /**
1447
+ * The explorer's @-reference button: append `@<relative path>` to the
1448
+ * session's composer draft (space-separated). Resolves the session-scope
1449
+ * ctx and the conversation input service at click time; a missing service
1450
+ * or scope degrades to a logged no-op, never a crash.
1451
+ */
1452
+ /** The tab icon from the tab-type registry (shared by every workbench). */
1453
+ const tabIconOf = (tab: SidebarTab): ReactNode => {
1454
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
1455
+ if (descriptor === undefined) return null
1456
+ return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
1457
+ }
1458
+
1459
+ /**
1460
+ * The tab badge from the tab-type registry: a count (99+ capped) or a
1461
+ * short text pill. A throwing badge is swallowed (no pill) — the tab
1462
+ * strip must never break because a plugin's badge computation failed.
1463
+ */
1464
+ const tabBadgeOf = (tab: SidebarTab): ReactNode => {
1465
+ const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
1466
+ if (descriptor?.badge === undefined) return null
1467
+ let value: string | number | null | undefined
1468
+ try {
1469
+ value = descriptor.badge(ctx, { sessionId, cwd }, state)
1470
+ } catch (error) {
1471
+ console.error('[dsh-better-sidebar] tab badge error:', error)
1472
+ return null
1473
+ }
1474
+ if (value === null || value === undefined || value === '') return null
1475
+ const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
1476
+ return <span className={css.tabBadge}>{text}</span>
1477
+ }
1478
+
1479
+ /**
1480
+ * Render one tab's content. `active` (from the workbench) tells whether
1481
+ * this tab is the active one in its pane; combined with the panel's
1482
+ * open/closed state it gates live views (the Subagent topology pauses its
1483
+ * polling while the page is not actually visible). The pane id travels
1484
+ * with the tab so diff tabs can split below their source pane.
1485
+ */
1486
+ // `placement` decides the visibility contract handed to the tab component:
1487
+ // pane tabs are visible while their panel is open and they are active, but
1488
+ // a free window is its own surface — its tab stays visible no matter what
1489
+ // the panels do (the AGENTS §7.5 contract; plugin components honor
1490
+ // `visible` to pause work, so tying floats to panelOpen would blank them
1491
+ // the moment the sidebar collapses).
1492
+ const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => {
1493
+ // Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
1494
+ // TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
1495
+ // the descriptor component receives the ORIGINAL tab id (the virtual id
1496
+ // is only a display key). Regular tabs: effectiveTabId is undefined (no
1497
+ // override), scope is the current session's.
1498
+ const home = getPinnedHomeScope(tab)
1499
+ return (
1500
+ <TabContent
1501
+ tab={tab}
1502
+ effectiveTabId={home?.tabId}
1503
+ paneId={paneId}
1504
+ sessionId={home?.sessionId ?? sessionId}
1505
+ cwd={home?.cwd ?? cwd}
1506
+ expanded={state.expanded}
1507
+ revealed={state.revealed ?? []}
1508
+ onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
1509
+ onReferenceFile={referenceInChat}
1510
+ ctx={ctx}
1511
+ store={store}
1512
+ visible={
1513
+ placement === 'float'
1514
+ ? true
1515
+ : placement === 'bottom'
1516
+ ? state.bottomOpen && active
1517
+ : state.panelOpen && active
1518
+ }
1519
+ onSubagentJump={() => { /* retained for external tab descriptor compatibility */ }}
1520
+ onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
1521
+ localeRevision={localeRevision}
1522
+ tabsVersion={tabsVersion}
1523
+ />
1524
+ )
1525
+ }
1526
+
1527
+ return (
1528
+ <div data-dsh-panel-host {...osFileDragShield}>
1529
+ <AccountMenu />
1530
+ {/*
1531
+ Current DSH: all three controls live in the Session utilities row; the
1532
+ intercepted native rightbar glyph is hidden to leave one sidebar
1533
+ affordance. Older hosts without the header anchor keep the viewport
1534
+ fallback, which alone overlaps and reserves the open panel's tab strip.
1535
+ */}
1536
+ {nativeToolbarHost !== null ? createPortal(
1537
+ <div className={clsx(css.toggleCluster, css.toggleClusterNative)} data-dsh-toggle-cluster data-dsh-native-toolbar-cluster>
1538
+ {!narrow && (
1539
+ <Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
1540
+ <button
1541
+ type="button"
1542
+ className={css.toggleButton}
1543
+ aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
1544
+ onClick={() => { store.reduce(toggleBottomPanel) }}
1545
+ >
1546
+ <IconPanelBottomOutline16 />
1547
+ </button>
1548
+ </Tooltip>
1549
+ )}
1550
+ <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
1551
+ <button
1552
+ type="button"
1553
+ className={css.toggleButton}
1554
+ aria-label={state.panelOpen ? t('collapse') : t('expand')}
1555
+ onClick={() => { store.reduce(togglePanel) }}
1556
+ >
1557
+ <IconPanelRightOutline16 />
1558
+ </button>
1559
+ </Tooltip>
1560
+ <UserManualButton />
1561
+ </div>,
1562
+ nativeToolbarHost,
1563
+ ) : <div className={css.toggleCluster} data-dsh-toggle-cluster>
1564
+ {/*
1565
+ Narrow viewports merge the two workbenches into the one drawer —
1566
+ there is no bottom panel, so its toggle button is not offered.
1567
+ */}
1568
+ {!narrow && (
1569
+ <Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
1570
+ <button
1571
+ type="button"
1572
+ className={css.toggleButton}
1573
+ aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
1574
+ onClick={() => { store.reduce(toggleBottomPanel) }}
1575
+ >
1576
+ <IconPanelBottomOutline16 />
1577
+ </button>
1578
+ </Tooltip>
1579
+ )}
1580
+ <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
1581
+ <button
1582
+ type="button"
1583
+ className={css.toggleButton}
1584
+ aria-label={state.panelOpen ? t('collapse') : t('expand')}
1585
+ onClick={() => { store.reduce(togglePanel) }}
1586
+ >
1587
+ <IconPanelRightOutline16 />
1588
+ </button>
1589
+ </Tooltip>
1590
+ <UserManualButton />
1591
+ </div>}
1592
+ {/*
1593
+ The right panel stays mounted while collapsed (hidden off-screen) so
1594
+ the slide in/out can animate; visibility hides it after the slide
1595
+ settles. Its bottom edge follows the bottom panel's height (0 while
1596
+ the bottom panel is closed) — the VSCode-style "sidebar above panel".
1597
+ On NARROW viewports it is a full-width drawer holding both
1598
+ workbenches (see MobileWorkbench); the width drag strip is not
1599
+ offered there — a full-screen sheet has nothing to drag.
1600
+ */}
1601
+ <div
1602
+ ref={panelRef}
1603
+ className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
1604
+ data-dsh-panel
1605
+ style={{
1606
+ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
1607
+ // Narrow drawer: keep the bottom-anchored sheet above the on-screen
1608
+ // keyboard (visualViewport inset); desktop panels are full-height
1609
+ // and unaffected.
1610
+ bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
1611
+ }}
1612
+
1613
+ data-dragging={anyDragging || undefined}
1614
+ >
1615
+ {!narrow && (
1616
+ <div
1617
+ className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
1618
+
1619
+ onPointerDown={(event) => {
1620
+ event.preventDefault()
1621
+ event.currentTarget.setPointerCapture(event.pointerId)
1622
+ dragCommitted.current = false
1623
+ widthDrag.current = { startX: event.clientX, startWidth: state.width }
1624
+ setDraggingWidth(true)
1625
+ }}
1626
+ onPointerMove={(event) => {
1627
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1628
+ const { startX, startWidth } = widthDrag.current
1629
+ const width = clampWidth(startWidth + (startX - event.clientX))
1630
+ const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
1631
+ scheduleDrag(width, height)
1632
+ }}
1633
+ onPointerUp={(event) => {
1634
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1635
+ if (dragCommitted.current) return
1636
+ dragCommitted.current = true
1637
+ event.currentTarget.releasePointerCapture(event.pointerId)
1638
+ const { startX, startWidth } = widthDrag.current
1639
+ // The up position is the pointer's FINAL position — a fast
1640
+ // flick's tail is coalesced into the up event, so the last
1641
+ // pointermove (the rAF pending value) can be stale. Commit
1642
+ // from the up position (v0.13.1 semantics; issue #247).
1643
+ const width = clampWidth(startWidth + (startX - event.clientX))
1644
+ const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
1645
+ commitDrag(width, height, s => setWidth(s, width))
1646
+ setDraggingWidth(false)
1647
+ }}
1648
+ onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
1649
+ onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
1650
+ />
1651
+ )}
1652
+ <div className={css.panelBody}>
1653
+ <Workbench
1654
+ state={state}
1655
+ tree={augmentedTree}
1656
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
1657
+ actions={wrappedActions}
1658
+ onNewTab={onNewTab}
1659
+ renderTab={renderTab}
1660
+ getTabIcon={tabIconOf}
1661
+ getTabBadge={tabBadgeOf}
1662
+ />
1663
+ </div>
1664
+ {/*
1665
+ The shared corner (only while BOTH panels are open): the
1666
+ intersection of the right panel's left edge and the bottom panel's
1667
+ top edge. Horizontal drags resize the right panel's width, vertical
1668
+ drags the bottom panel's height — the two panels drag against each
1669
+ other. Rendered INSIDE the right panel and positioned by CSS
1670
+ relative to it (left edge + the bottom panel's height via the
1671
+ --dsh-sidebar-height layout variable) — no JS-written viewport
1672
+ coordinates to keep in sync. (Never on narrow viewports: the
1673
+ bottom panel does not exist there.)
1674
+ */}
1675
+ {!narrow && state.panelOpen && state.bottomOpen && (
1676
+ <div
1677
+ className={css.cornerHandle}
1678
+ data-dragging={draggingCorner || undefined}
1679
+ onPointerDown={(event) => {
1680
+ event.preventDefault()
1681
+ event.currentTarget.setPointerCapture(event.pointerId)
1682
+ dragCommitted.current = false
1683
+ cornerDrag.current = {
1684
+ startX: event.clientX,
1685
+ startY: event.clientY,
1686
+ startWidth: state.width,
1687
+ startHeight: state.bottomHeight,
1688
+ }
1689
+ setDraggingCorner(true)
1690
+ }}
1691
+ onPointerMove={(event) => {
1692
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1693
+ const { startX, startY, startWidth, startHeight } = cornerDrag.current
1694
+ const width = clampWidth(startWidth + (startX - event.clientX))
1695
+ const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
1696
+ scheduleDrag(width, height)
1697
+ }}
1698
+ onPointerUp={(event) => {
1699
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1700
+ if (dragCommitted.current) return
1701
+ dragCommitted.current = true
1702
+ event.currentTarget.releasePointerCapture(event.pointerId)
1703
+ const { startX, startY, startWidth, startHeight } = cornerDrag.current
1704
+ // Up position wins over the rAF pending value (see the width
1705
+ // strip handler — issue #247).
1706
+ const width = clampWidth(startWidth + (startX - event.clientX))
1707
+ const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
1708
+ commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
1709
+ setDraggingCorner(false)
1710
+ }}
1711
+ onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
1712
+ onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
1713
+ />
1714
+ )}
1715
+ </div>
1716
+ {/*
1717
+ The bottom panel: a second, independent workbench. It squeezes ONLY
1718
+ the center column (the agent output area): it starts at the app
1719
+ shell's own left sidebar and ends at the right panel's left edge —
1720
+ neither sidebar gives up any position (the right panel keeps its
1721
+ full height). Its resize strip is the top edge; hidden by sliding
1722
+ down like the right panel. On NARROW viewports it does not exist —
1723
+ the bottom workbench lives inside the drawer (MobileWorkbench).
1724
+ */}
1725
+ {/* The bottom panel only becomes VISIBLE once the center column is
1726
+ measured: before that, `centerRect` is the {0,0} fallback and
1727
+ `right` computes to the full viewport width — the panel (and its
1728
+ overflow content) would flash full-width for a frame until the
1729
+ first measurement lands. Rendering stays unconditional so the
1730
+ mount/render chain (auto-terminal etc.) is never gated on
1731
+ geometry. */}
1732
+ {!narrow && (
1733
+ <div
1734
+ ref={bottomRef}
1735
+ className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
1736
+ data-dsh-panel
1737
+ data-dsh-bottom-panel
1738
+ style={{
1739
+ height: bottomPanelHeight,
1740
+ left: centerRectRef.current.left,
1741
+ // Keep the panel above the on-screen keyboard when the visual
1742
+ // viewport shrinks (see the keyboardInset effect).
1743
+ bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
1744
+ // Direct from the center column's measured right edge: the bottom
1745
+ // panel spans ONLY the center column, ending exactly at the
1746
+ // details column's left edge (the details column sits between the
1747
+ // center and the right panel, and the right panel's margin-right
1748
+ // push is already baked into centerRect.right).
1749
+ right: window.innerWidth - centerRectRef.current.right,
1750
+ // The seam against the open right panel needs its own hairline
1751
+ // (the right panel's border-left alone is covered by this panel's
1752
+ // fill — without it the corner looks cut off).
1753
+ borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
1754
+ // Unmeasured center column → keep the panel invisible (zero-size
1755
+ // geometry would flash full-width overflow instead).
1756
+ visibility: centerMeasured ? undefined : 'hidden',
1757
+ }}
1758
+
1759
+ data-dragging={(draggingBottom || draggingCorner) || undefined}
1760
+ >
1761
+ <div
1762
+ className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
1763
+
1764
+ onPointerDown={(event) => {
1765
+ event.preventDefault()
1766
+ event.currentTarget.setPointerCapture(event.pointerId)
1767
+ dragCommitted.current = false
1768
+ bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
1769
+ setDraggingBottom(true)
1770
+ }}
1771
+ onPointerMove={(event) => {
1772
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1773
+ const { startY, startHeight } = bottomDrag.current
1774
+ const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
1775
+ scheduleDrag(Math.min(state.width, window.innerWidth), height)
1776
+ }}
1777
+ onPointerUp={(event) => {
1778
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1779
+ if (dragCommitted.current) return
1780
+ dragCommitted.current = true
1781
+ event.currentTarget.releasePointerCapture(event.pointerId)
1782
+ const { startY, startHeight } = bottomDrag.current
1783
+ // Up position wins over the rAF pending value (see the width
1784
+ // strip handler — issue #247).
1785
+ const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
1786
+ commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
1787
+ setDraggingBottom(false)
1788
+ }}
1789
+ onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
1790
+ onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
1791
+ />
1792
+ {/*
1793
+ The bottom panel's own close control at its tab strip's right end
1794
+ (the strip reserves the width via CSS so the + menu never hides
1795
+ under it): one tap collapses the panel.
1796
+ */}
1797
+ <Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
1798
+ <button
1799
+ type="button"
1800
+ className={css.bottomClose}
1801
+ aria-label={t('collapseBottomPanel')}
1802
+ onClick={() => { store.reduce(toggleBottomPanel) }}
1803
+ >
1804
+ <IconCloseFill14 />
1805
+ </button>
1806
+ </Tooltip>
1807
+ <div className={css.panelBody}>
1808
+ <Workbench
1809
+ state={state}
1810
+ tree={state.bottomSplits}
1811
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
1812
+ actions={actions}
1813
+ onNewTab={onNewTab}
1814
+ renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
1815
+ getTabIcon={tabIconOf}
1816
+ getTabBadge={tabBadgeOf}
1817
+ />
1818
+ </div>
1819
+ </div>
1820
+ )}
1821
+ {/*
1822
+ Free windows: tabs dragged out onto the conversation area (or floated
1823
+ from the tab context menu). They live in the panel host like the
1824
+ panels (viewport coordinates, immune to desktop-shell transforms) but
1825
+ are independent of panel state — a window stays up while panels
1826
+ collapse. The floats array's order is the stacking order; the content
1827
+ reuses the regular tab renderer, so every tab type floats unchanged.
1828
+ */}
1829
+ {state.floats.map(float => (
1830
+ <FreeWindow
1831
+ key={float.id}
1832
+ float={float}
1833
+ renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
1834
+ getTabIcon={tabIconOf}
1835
+ onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
1836
+ onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
1837
+ onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
1838
+ onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
1839
+ onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
1840
+ />
1841
+ ))}
1842
+ {/*
1843
+ The drag-out hint: while a tab drag hovers the conversation column,
1844
+ a dashed overlay marks the drop zone there (pointer-transparent — it
1845
+ must not disturb the drag it describes).
1846
+ */}
1847
+ {floatHint !== null && (
1848
+ <div
1849
+ className={css.floatDropHint}
1850
+ style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
1851
+ >
1852
+ <span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
1853
+ </div>
1854
+ )}
1855
+ </div>
1856
+ )
1857
+ }