@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,1148 @@
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 two-button
13
+ * cluster at the top-right corner toggles each panel; the right panel's
14
+ * width drags from its left edge, the bottom panel's height from its top
15
+ * edge, and the shared corner drags both at once. The whole layout lives in
16
+ * the per-session store, so switching conversations swaps the sidebar.
17
+ *
18
+ * The shell binds the workbench actions to the store and dispatches tab
19
+ * content to the views. New tabs come from the + menu (explorer / git /
20
+ * terminal; editors open from the explorer). Tabs live in one tree only —
21
+ * they never cross panels; only the panel sizes drag against each other.
22
+ *
23
+ * Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
24
+ * narrow migrates the bottom panel's tabs INTO the right tree
25
+ * (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
26
+ * strips. The right panel becomes a full-width drawer, the bottom panel
27
+ * and its toggle button disappear, and the layout push is disabled (the
28
+ * drawer floats). Widening does not migrate back: the tabs keep living in
29
+ * the right tree.
30
+ */
31
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
32
+ import { useSyncExternalStore } from 'react'
33
+ import clsx from 'clsx'
34
+ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
35
+ import type { Context, SidebarSessionList } from '../context-types.ts'
36
+ import { appendToDraft } from './conversation-draft.ts'
37
+ import {
38
+ BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
39
+ reconcileAgentTerminals,
40
+ resizeSplitIn, setBottomHeight, setWidth, toggleExpanded, togglePanel,
41
+ type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
42
+ } from './state.ts'
43
+ import { IconPanelRightOutline16 } from './icons.tsx'
44
+ import { Workbench, type WorkbenchActions } from './split-pane.tsx'
45
+ import { useNarrowViewport } from './breakpoints.ts'
46
+ import { parseDesktopEnv } from './desktop-env.ts'
47
+ import { getWcoSnapshot, subscribeWco } from './wco.ts'
48
+ import { getShellPreset } from './shell-presets.ts'
49
+ import { computeTitleBarStrip } from './titlebar-strip.ts'
50
+ import type { NewTabOption } from './TabBar.tsx'
51
+ import type { TabDragPayload } from './TabBar.tsx'
52
+ import { relativeTo } from './paths.ts'
53
+ import { OrphanedTab } from './OrphanedTab.tsx'
54
+ import { RenderBoundary } from './RenderBoundary.tsx'
55
+ import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
56
+ import { detectNewDirectSubagent } from './subagent-detect.ts'
57
+ import { detectNewJob } from './subagent-jobs.ts'
58
+ import { t } from './locales.ts'
59
+ import { api, type SessionScope } from './api.ts'
60
+ import css from './sidebar.module.css'
61
+
62
+ /** How many consecutive reconnect failures stop the agent-terminals push loop
63
+ * (mirror of the terminal view's own cap; the loop restarts on session switch). */
64
+ const FAILURE_LIMIT = 3
65
+
66
+ /**
67
+ * Subagent auto-open debounce (ms). The host delivers a new child's origin
68
+ * and its title in SEPARATE frames: a Side Chat thread's first visible
69
+ * frame still shows a fallback title (no 'Side: ' prefix), so an immediate
70
+ * 0→N decision mistakes it for a genuine subagent and pops the task page.
71
+ * The trigger therefore re-evaluates against the live snapshot once the
72
+ * title frame has had time to land.
73
+ */
74
+ const AUTO_OPEN_DEBOUNCE_MS = 500
75
+
76
+ /**
77
+ * OS file drags over the sidebar belong to the sidebar, not to the chat:
78
+ * DSH's composer (InputBar) listens for file drags on the DOCUMENT and
79
+ * answers with a full-screen "drop image here" mask plus image intake on
80
+ * drop. Both panel-host render sites swallow the whole event quartet —
81
+ * enter/over/leave/drop — so the region is a black hole to that document
82
+ * listener. All four must be stopped: InputBar keeps an enter/leave depth
83
+ * counter, and a leave that escapes without its matching enter unbalances
84
+ * the count (this was the full-screen mask flickering over the sidebar).
85
+ * The conversation column keeps DSH's native overlay and intake untouched;
86
+ * gated on the 'Files' type so in-app drags (tab reorder, split zones)
87
+ * propagate exactly as before.
88
+ */
89
+ const swallowOsFileDrag = (event: DragEvent): void => {
90
+ if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
91
+ event.preventDefault()
92
+ event.stopPropagation()
93
+ }
94
+
95
+ /** The four drag events a file drag must never carry past the panel host. */
96
+ const osFileDragShield = {
97
+ onDragEnter: swallowOsFileDrag,
98
+ onDragOver: swallowOsFileDrag,
99
+ onDragLeave: swallowOsFileDrag,
100
+ onDrop: swallowOsFileDrag,
101
+ }
102
+
103
+ /**
104
+ * Append one user-space stylesheet (preset or custom CSS) as a tagged
105
+ * `<style>` element. The tag attribute carries the source identity so the
106
+ * running configuration is inspectable in DevTools; the returned tag is
107
+ * removed by the caller's effect cleanup.
108
+ */
109
+ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
110
+ const tag = document.createElement('style')
111
+ tag.setAttribute(attr, id)
112
+ tag.textContent = cssText
113
+ document.head.appendChild(tag)
114
+ return tag
115
+ }
116
+
117
+ /** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
118
+ * the runtime objects/callbacks the cell renders with. The memo comparator
119
+ * is the pure `tabContentCompare`; anything in the key decides a re-render
120
+ * must propagate, anything outside it must be a stable object (ctx/store)
121
+ * or covered by a compared field (paneId covers onOpenDiff's captured
122
+ * pane; sessionId/cwd cover onReferenceFile). */
123
+ interface TabContentProps extends TabContentMemoKey {
124
+ onToggleDir: (path: string) => void
125
+ onReferenceFile: (path: string) => void
126
+ ctx: Context
127
+ store: SidebarStore
128
+ /** Fired before a topology node jumps to its child session (see Sidebar). */
129
+ onSubagentJump: (childSessionId: string) => void
130
+ /** Open a diff tab from the git panel (placement handled by the store). */
131
+ onOpenDiff: (tab: SidebarTab) => void
132
+ }
133
+
134
+ /** Render the content of one tab (dispatched by type). */
135
+ const TabContent = memo(function TabContent(props: TabContentProps) {
136
+ const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
137
+ const scope = { sessionId, cwd }
138
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
139
+ if (descriptor === undefined) {
140
+ return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
141
+ }
142
+ // One boundary per tab: a render crash in a viewer/editor shows a strip in
143
+ // THIS tab's pane only — the toggle cluster, the other tabs, and the panel
144
+ // stay alive (issue #31). The tab strip (close button) lives outside, so a
145
+ // crashing tab stays closable; the root boundary in index.tsx remains the
146
+ // last resort for errors in the sidebar shell itself. The descriptor is
147
+ // rendered as a REAL element (not called directly): a direct call would
148
+ // throw inside TabContent's own render, which the boundary cannot catch —
149
+ // as a child fiber, every render error (top-level or deep) lands in it.
150
+ return createElement(
151
+ RenderBoundary,
152
+ { className: css.tabBoundaryError },
153
+ createElement(descriptor.component, {
154
+ ctx, store, scope, tab, visible, expanded,
155
+ onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
156
+ }),
157
+ )
158
+ }, tabContentCompare)
159
+
160
+ /** The + menu options for the current state, driven by the tab registry.
161
+ * Hidden tabs (editor/diff) never show; `available` returning false shows
162
+ * a disabled row (e.g. terminal at capacity) instead of hiding the option.
163
+ * Tabs the user disabled in the side card settings are filtered out
164
+ * entirely — re-enabling them is the settings page's job. */
165
+ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
166
+ const service = ctx.betterSidebar
167
+ if (service === undefined) return []
168
+ return service.getTabs()
169
+ .filter(d => !d.hidden && service.isTabEnabled(d.id))
170
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
171
+ .map(d => ({
172
+ id: d.id,
173
+ label: typeof d.title === 'function' ? d.title() : d.title,
174
+ disabled: !(d.available?.(ctx, scope, state) ?? true),
175
+ icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
176
+ }))
177
+ }
178
+
179
+ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
180
+ const { ctx, store } = props
181
+
182
+ // Copy freshness: re-render the whole tree when the DSH locale switches.
183
+ // The module-level t() reads the active locale at call time, so a root
184
+ // re-render alone re-localizes every panel (no memo barriers below).
185
+ const localeRevision = useSyncExternalStore(
186
+ useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
187
+ useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
188
+ )
189
+ void localeRevision
190
+
191
+ // Tab-registry revision: TabContent memo cells must pick up a descriptor
192
+ // a plugin registers/disposes after mount (the + menu / icons already read
193
+ // the registry at render). Rare events (plugin (un)mount), so one full
194
+ // re-render per change is fine — this is what keeps the memoized cells
195
+ // from going stale, mirroring the localeRevision mechanism above.
196
+ const [tabsVersion, setTabsVersion] = useState(0)
197
+ useEffect(() => {
198
+ const service = ctx.betterSidebar
199
+ if (service === undefined) return
200
+ return service.subscribe(() => setTabsVersion(version => version + 1))
201
+ }, [ctx])
202
+
203
+ // Narrow (mobile) viewports collapse the two panels into one: the right
204
+ // panel becomes a full-width drawer holding BOTH workbenches, the bottom
205
+ // panel (and its toggle button) disappears, and the layout push is
206
+ // disabled (the drawer floats over the app shell). Entering narrow
207
+ // MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
208
+ // — the merged display is the right sidebar alone, the bottom tabs thrown
209
+ // into its strips. Widening never rewrites the migrated state: the tabs
210
+ // keep living in the right tree.
211
+ const narrow = useNarrowViewport()
212
+
213
+ // On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
214
+ // when the visual viewport shrinks below the layout viewport, bottom-
215
+ // anchored panels would hide under the keyboard. Track the inset and
216
+ // offset the bottom-anchored surfaces by it. The obscured bottom strip is
217
+ // innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
218
+ // the visual viewport is scrolled/zoomed under browser chrome, so
219
+ // omitting it would over-lift the panels (CR #232 P2). offsetTop changes
220
+ // through the viewport's scroll event too, so both events are listened.
221
+ // Guarded: browsers without visualViewport (older WebViews, jsdom) stay
222
+ // at 0. rAF-throttled, same pattern as useNarrowViewport.
223
+ const [keyboardInset, setKeyboardInset] = useState(0)
224
+ useEffect(() => {
225
+ const vv = window.visualViewport
226
+ if (vv === null || vv === undefined) return
227
+ let frame: number | null = null
228
+ const measure = (): void => {
229
+ frame = null
230
+ const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
231
+ setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
232
+ }
233
+ const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
234
+ vv.addEventListener('resize', onResize)
235
+ vv.addEventListener('scroll', onResize)
236
+ measure()
237
+ return () => {
238
+ vv.removeEventListener('resize', onResize)
239
+ vv.removeEventListener('scroll', onResize)
240
+ if (frame !== null) cancelAnimationFrame(frame)
241
+ }
242
+ }, [])
243
+
244
+ // Current conversation (the sessions list feed).
245
+ const sessionList = useSyncExternalStore(
246
+ useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
247
+ useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
248
+ )
249
+ const current = sessionList.current
250
+
251
+ // Per-session sidebar state.
252
+ const snapshot = useSyncExternalStore(
253
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
254
+ useCallback(() => store.getSnapshot(), [store]),
255
+ )
256
+ useEffect(() => { store.setSession(current) }, [current, store])
257
+
258
+ const state = snapshot.state
259
+ const sessionId = snapshot.sessionId
260
+ const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
261
+
262
+ // The collapsed toggle cluster reclaims the top-right corner, so the DSH
263
+ // session header's right-aligned utilities (the "Session log" download
264
+ // capsule) must yield. layout.css keys off this body attribute to push the
265
+ // header's right padding out past the cluster. Only the CLOSED panel needs
266
+ // it — an open panel already squeezes `#root` left, moving the header clear.
267
+ const collapsed = state === undefined || !state.panelOpen
268
+ useEffect(() => {
269
+ if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
270
+ else document.body.removeAttribute('data-dsh-sidebar-collapsed')
271
+ return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
272
+ }, [collapsed])
273
+
274
+ // Title-bar / shell compatibility (the "位置兼容模式" scheme):
275
+ // auto — CONSERVATIVE: only the standard Window Controls Overlay
276
+ // geometry contributes (the real caption-overlay height,
277
+ // reactive to maximize/restore). No URL stamp, no preset, no
278
+ // guess — plain browsers see zero modification.
279
+ // preset — an opt-in built-in shell preset (shell-presets.ts) adds its
280
+ // per-shell strip as the no-WCO fallback.
281
+ // custom — the user's own CSS (injected below) + the legacy manual
282
+ // strip px.
283
+ // The resolved strip drives the SAME body attribute + CSS variable as the
284
+ // legacy boolean did, so the CSS contract is unchanged (layout.css /
285
+ // sidebar.module.css); only the value source changed. The cleanup removes
286
+ // both on unmount/boundary swap so a crashed sidebar never leaves them
287
+ // behind.
288
+ const desktopEnv = parseDesktopEnv()
289
+ const wco = useSyncExternalStore(
290
+ useMemo(() => subscribeWco, []),
291
+ getWcoSnapshot,
292
+ )
293
+ const scheme = snapshot.prefs.titleBarScheme
294
+ const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
295
+ const titleBarStrip = computeTitleBarStrip(
296
+ desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
297
+ )
298
+ const titleBarCompat = titleBarStrip > 0
299
+ useEffect(() => {
300
+ const root = document.documentElement
301
+ if (titleBarCompat) {
302
+ document.body.setAttribute('data-dsh-title-bar-compat', '')
303
+ root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
304
+ } else {
305
+ document.body.removeAttribute('data-dsh-title-bar-compat')
306
+ root.style.removeProperty('--dsh-title-bar-strip')
307
+ }
308
+ return () => {
309
+ document.body.removeAttribute('data-dsh-title-bar-compat')
310
+ root.style.removeProperty('--dsh-title-bar-strip')
311
+ }
312
+ }, [titleBarCompat, titleBarStrip])
313
+
314
+ // User-space CSS injection (the escape hatch): preset CSS (scheme
315
+ // `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
316
+ // the plugin's own styles — later in the cascade wins ties, and
317
+ // `!important` can override the JS-written inline strip variable. Each
318
+ // source gets its own tagged <style> so the running configuration stays
319
+ // inspectable; tags are removed on change/unmount so a stale stylesheet
320
+ // never outlives its fiber (HMR-safe).
321
+ const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
322
+ const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
323
+ useEffect(() => {
324
+ const tags: HTMLStyleElement[] = []
325
+ if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
326
+ if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
327
+ return () => { for (const tag of tags) tag.remove() }
328
+ }, [presetCss, customCss, preset?.id])
329
+
330
+ /**
331
+ * Bottom-panel merge on narrow viewports: whenever a session is current
332
+ * while narrow (mount, session switch, or a desktop→narrow transition),
333
+ * throw the bottom tree's tabs into the right tree. Idempotent — after
334
+ * the first migration the bottom tree is empty and the reducer returns
335
+ * the same reference, so this effect settles immediately.
336
+ */
337
+ useEffect(() => {
338
+ if (!narrow || sessionId === undefined) return
339
+ store.reduce(migrateBottomTabs)
340
+ }, [narrow, sessionId, store])
341
+
342
+ // While the session's header is still hydrating (or the session is blank),
343
+ // the list summary may carry no cwd; ask the host once (it falls back to
344
+ // the process cwd) so the explorer root and terminal cwd are real from
345
+ // first paint instead of showing "no session".
346
+ const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
347
+ useEffect(() => {
348
+ setFetchedCwd(undefined)
349
+ if (sessionId === undefined || summaryCwd !== undefined) return
350
+ let cancelled = false
351
+ api.sessionCwd({ sessionId })
352
+ .then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
353
+ .catch(() => { /* the explorer/git rows surface their own errors */ })
354
+ return () => { cancelled = true }
355
+ }, [sessionId, summaryCwd])
356
+ const cwd = summaryCwd ?? fetchedCwd
357
+
358
+ /**
359
+ * Agent terminals push: subscribe to the host's live list of agent-owned
360
+ * terminals for this session (created by the model through the
361
+ * `terminal_create` tool). The host pushes a JSON array on every
362
+ * create / close / exit; the sidebar reconciles the list into tabs
363
+ * (id `agent:<uuid>`, title from the agent). A disconnected socket
364
+ * retries with a short backoff so a refresh or transient drop reattaches
365
+ * the same shell without losing the agent's work — capped like the
366
+ * terminal view's own reconnect loop, so a refused endpoint never spins
367
+ * forever (the next session switch restarts the loop).
368
+ * While the terminal tab type is disabled in settings, pushes are
369
+ * ignored (no auto-added tabs); re-enabling makes the next push converge.
370
+ */
371
+ useEffect(() => {
372
+ if (sessionId === undefined) return
373
+ let socket: WebSocket | null = null
374
+ let retry: number | undefined
375
+ let closed = false
376
+ let failures = 0
377
+ const connect = (): void => {
378
+ if (closed) return
379
+ const url = new URL('/sidebar/ws/agent-terminals', location.origin)
380
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
381
+ url.search = new URLSearchParams({ sessionId }).toString()
382
+ socket = new WebSocket(url.toString())
383
+ socket.onmessage = (event) => {
384
+ if (typeof event.data !== 'string') return
385
+ try {
386
+ const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
387
+ if (!Array.isArray(list)) return
388
+ store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
389
+ ? s
390
+ : reconcileAgentTerminals(s, list))
391
+ } catch {
392
+ // Malformed push: ignore (the next push will reconcile).
393
+ }
394
+ }
395
+ socket.onclose = () => {
396
+ if (closed) return
397
+ failures += 1
398
+ if (failures >= FAILURE_LIMIT) {
399
+ console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
400
+ return
401
+ }
402
+ retry = window.setTimeout(connect, 2000)
403
+ }
404
+ socket.onerror = () => { socket?.close() }
405
+ }
406
+ connect()
407
+ return () => {
408
+ closed = true
409
+ window.clearTimeout(retry)
410
+ socket?.close()
411
+ }
412
+ }, [sessionId, store])
413
+
414
+ /**
415
+ * Subagent auto-activation: the moment the current conversation spawns its
416
+ * FIRST direct subagent (a 0 → N transition on the list feed), the "auto
417
+ * open" pref is on, and the Subagent tab type is enabled in settings,
418
+ * open the panel (if collapsed) and focus the Subagent page
419
+ * (single-instance: an existing tab is focused, never duplicated).
420
+ * Switching to a session that already has subagents never triggers — its
421
+ * baseline starts at the current count — so a deliberate layout is never
422
+ * fought.
423
+ *
424
+ * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
425
+ * is also a subagent-origin child, and its 'Side: ' title lands one frame
426
+ * after its origin — an immediate check would misread that first frame as
427
+ * a new subagent and pop this page on every thread creation. The timer
428
+ * re-evaluates the ORIGINAL baseline against the live snapshot; by then
429
+ * the title filter (isSideThreadSummary) sees the settled label.
430
+ */
431
+ const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
432
+ const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
433
+ useEffect(() => {
434
+ const prev = listBaselineRef.current
435
+ listBaselineRef.current = sessionList
436
+ if (sessionId === undefined || prev === undefined) return
437
+ if (autoOpenPendingRef.current !== null) return
438
+ if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
439
+ const baseline = prev
440
+ const timer = window.setTimeout(() => {
441
+ autoOpenPendingRef.current = null
442
+ if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
443
+ if (!store.getPrefs().autoOpenSubagent) return
444
+ if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
445
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
446
+ // Pin the landing to the right panel: the auto-opened Subagent page must
447
+ // appear where the panel just expanded, not in a bottom-panel pane the
448
+ // user last touched.
449
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
450
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
451
+ }, AUTO_OPEN_DEBOUNCE_MS)
452
+ autoOpenPendingRef.current = { baseline, timer }
453
+ }, [sessionList, sessionId, store, ctx])
454
+
455
+ // A session switch (or unmount) voids any armed auto-open recheck.
456
+ useEffect(() => () => {
457
+ const pending = autoOpenPendingRef.current
458
+ if (pending !== null) window.clearTimeout(pending.timer)
459
+ autoOpenPendingRef.current = null
460
+ }, [sessionId])
461
+
462
+ /**
463
+ * Job auto-activation: the moment a NEW background job appears for the
464
+ * current conversation (a job id the previous snapshot lacked), the
465
+ * auto-open pref is on, and the Jobs tab type is enabled, open the panel
466
+ * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
467
+ * (0 → N only), ANY new job id triggers: the agent may start several
468
+ * jobs in one session, and each should surface. A fresh page load never
469
+ * triggers — its baseline starts at the current snapshot.
470
+ */
471
+ const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
472
+ useEffect(() => {
473
+ const prev = jobBaselineRef.current
474
+ jobBaselineRef.current = sessionList
475
+ if (sessionId === undefined || prev === undefined) return
476
+ if (!detectNewJob(prev, sessionList, sessionId)) return
477
+ if (!store.getPrefs().autoOpenJobs) return
478
+ if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
479
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
480
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
481
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
482
+ }, [sessionList, sessionId, store, ctx])
483
+
484
+ /**
485
+ * Topology jump-back: clicking a subagent node on the Subagent page calls
486
+ * the official `openSubagent`, which switches the sidebar to that child
487
+ * session's OWN layout (a fresh child session defaults to the explorer).
488
+ * The README contract says the Subagent page must stay open with the jumped
489
+ * node highlighted — so once the current session becomes the recorded jump
490
+ * target, re-open the Subagent page on top of the child's layout (expanding
491
+ * the panel first if it is collapsed). Only this explicit node click arms
492
+ * the flag, so switching to a subagent session by any other means keeps
493
+ * that session's own layout untouched.
494
+ */
495
+ const subagentJumpRef = useRef<string | undefined>(undefined)
496
+ useEffect(() => {
497
+ const pending = subagentJumpRef.current
498
+ if (pending === undefined || sessionId !== pending) return
499
+ subagentJumpRef.current = undefined
500
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
501
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
502
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
503
+ }, [sessionId, store, ctx])
504
+
505
+ // The app shell's center column: the bottom panel spans ONLY that column
506
+ // ("squeezes the agent output area") — it starts at the app sidebar's
507
+ // right edge and ends at the details column's left edge (the details
508
+ // column sits between the center and the right panel). Measured directly
509
+ // from the AppFrame's center column DOM (the parent of the
510
+ // [data-slot="conversation"] wrapper — layout.css's center column) so the
511
+ // bottom panel tracks the column's real
512
+ // horizontal edges — including the animated margin-right push while the
513
+ // right panel opens/closes; a frame that never appears keeps the initial
514
+ // zero-size fallback (the panel renders at 0 width until measured).
515
+ // The rect lives in a REF (not state): the open/close transition resizes
516
+ // the center column EVERY frame for its duration, and reacting per frame
517
+ // with setState re-renders the whole Sidebar (every mounted tab) at
518
+ // animation cadence — the visible toggle jank (#315). measureCenter
519
+ // writes the bottom panel's edges directly (same DOM-write pattern as
520
+ // applyDrag), so the panel still tracks the column per frame with zero
521
+ // React work; `centerMeasured` flips ONCE to gate the hidden→visible
522
+ // first-paint fallback.
523
+ const centerRectRef = useRef({ left: 0, right: 0 })
524
+ const [centerMeasured, setCenterMeasured] = useState(false)
525
+ // Refs keep the measure step stable across renders and let it skip work
526
+ // mid-drag: during a width/corner drag the layout push resizes the center
527
+ // column every frame, and reacting (setCenterRect → re-render) would
528
+ // re-introduce the drag lag this shell deliberately avoids. applyDrag
529
+ // writes the bottom panel's edges directly, so measurement pauses then.
530
+ const centerColRef = useRef<HTMLElement | null>(null)
531
+ const draggingRef = useRef(false)
532
+ const measureCenter = useCallback((): void => {
533
+ if (draggingRef.current) return
534
+ const col = centerColRef.current
535
+ if (col === null) return
536
+ if (!col.isConnected) {
537
+ // The observed column was detached (HMR re-render swapped the node
538
+ // in place): its rect is stale garbage. Drop the ref — the locate
539
+ // chain re-runs on the next mutation/interval tick and picks up the
540
+ // new column node (issue #248).
541
+ centerColRef.current = null
542
+ return
543
+ }
544
+ const rect = col.getBoundingClientRect()
545
+ // Ref + direct DOM write (see the centerRectRef comment): the bottom
546
+ // panel keeps tracking the center column per frame during the right
547
+ // panel's open/close animation without re-rendering the shell. The
548
+ // one-shot measured flip renders the panel visible once (a stale
549
+ // {0,0} fallback would flash full-width).
550
+ centerRectRef.current = { left: rect.left, right: rect.right }
551
+ const bottom = bottomRef.current
552
+ if (bottom !== null) {
553
+ bottom.style.setProperty('left', `${rect.left}px`)
554
+ bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
555
+ }
556
+ setCenterMeasured(prev => (prev ? prev : true))
557
+ }, [])
558
+ useEffect(() => {
559
+ let disposed = false
560
+ let observer: ResizeObserver | undefined
561
+ // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
562
+ // [data-slot] containers: the conversation slot wrapper
563
+ // ([data-slot="conversation"]) sits directly inside the center column,
564
+ // so its parent IS that column — no hashed-class or positional
565
+ // dependency (layout.css uses the same anchor). The shell swaps the
566
+ // boot page for the AppFrame only AFTER boot settles, so the first
567
+ // query may miss it. Never give up: watch #root's subtree (the swap and
568
+ // HMR re-renders mutate it) and re-run this locator — querying once and
569
+ // bailing would strand the panel at the zero-size fallback forever
570
+ // (observed: a 1px sliver at the viewport's left edge).
571
+ const locate = (): void => {
572
+ if (disposed) return
573
+ const col = document.querySelector('#root [data-slot="conversation"]')
574
+ ?.parentElement as HTMLElement | undefined
575
+ if (col === undefined || !col.isConnected) {
576
+ if (centerColRef.current !== null) {
577
+ centerColRef.current = null
578
+ observer?.disconnect()
579
+ observer = undefined
580
+ }
581
+ return
582
+ }
583
+ if (centerColRef.current !== col) {
584
+ // A NEW column node (boot swap, HMR re-render, or a previous locate
585
+ // that found nothing): attach the ResizeObserver to THIS node and
586
+ // measure it once. Same-node size changes are the ResizeObserver's
587
+ // job — no forced measurement here, because a forced
588
+ // getBoundingClientRect per mutation would reflow the shell at
589
+ // mutation cadence.
590
+ centerColRef.current = col
591
+ observer?.disconnect()
592
+ observer = new ResizeObserver(measureCenter)
593
+ observer.observe(col)
594
+ measureCenter()
595
+ }
596
+ }
597
+ locate()
598
+ // rAF-debounce the mutation watchers: #root's subtree changes at chat
599
+ // cadence (streaming turns), and locate() itself must stay cheap.
600
+ let locateFrame: number | null = null
601
+ const scheduleLocate = (): void => {
602
+ if (locateFrame !== null) return
603
+ // Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
604
+ // attribute, which is the mutation this watcher observes — relocating
605
+ // per drag frame is pointless (the center column node cannot change
606
+ // while the pointer is captured) and adds a querySelector to every
607
+ // frame's budget (#315). The 1.5s retry below still covers any node
608
+ // swap that somehow lands mid-drag.
609
+ if (draggingRef.current) return
610
+ locateFrame = requestAnimationFrame(() => {
611
+ locateFrame = null
612
+ locate()
613
+ })
614
+ }
615
+ const watcher = new MutationObserver(scheduleLocate)
616
+ const root = document.getElementById('root')
617
+ if (root !== null) watcher.observe(root, { childList: true, subtree: true })
618
+ // The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
619
+ // clears those variables on teardown and re-writes them on setup — and
620
+ // that is also the moment the shell may have re-created the center
621
+ // column under a REUSED #root child (React swaps nodes in place, so
622
+ // #root's childList never changes and the watcher above never fires).
623
+ // Watching <html>'s style attribute catches that re-sync: the push
624
+ // rewrite re-locates and re-measures, so the bottom panel recovers
625
+ // instead of staying hidden on a stale {0,0} center rect.
626
+ const htmlStyleWatcher = new MutationObserver(scheduleLocate)
627
+ htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
628
+ // Last-resort safety net (issue #248): no watcher is guaranteed to fire
629
+ // for every HMR teardown/setup interleaving (e.g. the style attribute
630
+ // may end up byte-identical, and the col may be swapped before the
631
+ // subtree watcher attaches). A slow unconditional re-locate makes the
632
+ // panel converge on the real column within a couple of seconds no
633
+ // matter what sequence the shell used. locate() is cheap when nothing
634
+ // changed (one querySelector + an identity compare; no forced layout).
635
+ const retry = window.setInterval(locate, 1500)
636
+ return () => {
637
+ disposed = true
638
+ if (locateFrame !== null) cancelAnimationFrame(locateFrame)
639
+ window.clearInterval(retry)
640
+ observer?.disconnect()
641
+ watcher.disconnect()
642
+ htmlStyleWatcher.disconnect()
643
+ centerColRef.current = null
644
+ }
645
+ // Opening the bottom panel re-runs the whole locate/measure chain: a
646
+ // panel opened before the center column was ever found must not stay
647
+ // invisible forever (the HMR recovery path depends on the observers
648
+ // above, this is the belt-and-braces retry for the open moment itself).
649
+ }, [measureCenter, state?.bottomOpen])
650
+
651
+ // Panel drags: the right panel's width (left edge strip), the bottom
652
+ // panel's height (top edge strip), and the shared corner (both at once).
653
+ // Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
654
+ // variables) instead of round-tripping the store on every pointer move —
655
+ // a store reduce re-renders both workbenches (terminals, editors…) per
656
+ // move, which is the visible drag lag. The store is committed once on
657
+ // pointer up (clamping + persistence).
658
+ const panelRef = useRef<HTMLDivElement | null>(null)
659
+ const bottomRef = useRef<HTMLDivElement | null>(null)
660
+ const widthDrag = useRef({ startX: 0, startWidth: 0 })
661
+ const [draggingWidth, setDraggingWidth] = useState(false)
662
+ const bottomDrag = useRef({ startY: 0, startHeight: 0 })
663
+ const [draggingBottom, setDraggingBottom] = useState(false)
664
+ const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
665
+ const [draggingCorner, setDraggingCorner] = useState(false)
666
+ const anyDragging = draggingWidth || draggingBottom || draggingCorner
667
+
668
+ // Pause center-column measurement while dragging, and re-measure once the
669
+ // drag settles at its committed size. The store commit lands on release and
670
+ // the final width equals the last drag width, so no ResizeObserver event
671
+ // fires to refresh centerRect — this explicit re-measure covers that gap.
672
+ useEffect(() => {
673
+ draggingRef.current = anyDragging
674
+ if (!anyDragging) measureCenter()
675
+ }, [anyDragging, measureCenter])
676
+
677
+ // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
678
+ // re-clamps on commit; these keep the panels from overshooting mid-drag).
679
+ const clampWidth = (width: number): number =>
680
+ Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
681
+ const clampHeight = (height: number): number =>
682
+ Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
683
+
684
+ /** Single writer for the layout-push variables: the app shell gives up
685
+ * the panel's width/height while open (0 while collapsed) through
686
+ * layout.css's margins. Every size change — drag frames and committed
687
+ * state — flows through here so the push never forks between paths. */
688
+ const writeGeometry = (width: number, height: number): void => {
689
+ document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
690
+ document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
691
+ // The corner handle positions itself relative to the panel (CSS
692
+ // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
693
+ // variables are all it needs — no viewport coordinates written here
694
+ // (issue #106: skins that inset the panels must not fight JS coords).
695
+ }
696
+
697
+ /** Last size a drag actually applied to the DOM (updated by applyDrag).
698
+ * When a pointer stream dies without any position info (issue #247: an
699
+ * ultra-fast flick whose release events carried no usable coordinates),
700
+ * the abort path adopts this instead of rolling back to the pre-drag
701
+ * value — the DOM's current size is the only truthful record left. */
702
+ const lastDragSize = useRef<{ width: number; height: number } | null>(null)
703
+
704
+ /** Apply a drag size to the DOM without touching React state or the store.
705
+ * The bottom panel's right edge tracks the right panel's left edge HERE
706
+ * too — React state only updates on release, so the inline right must be
707
+ * written directly or the bottom panel would lag the sidebar mid-drag.
708
+ * The layout push rides the shared writer (writeGeometry). */
709
+ const applyDrag = (width: number, height: number): void => {
710
+ lastDragSize.current = { width, height }
711
+ panelRef.current?.style.setProperty('width', `${width}px`)
712
+ bottomRef.current?.style.setProperty('height', `${height}px`)
713
+ // centerRect.right is the center column's right edge at the committed
714
+ // width (innerWidth - state.width - detailsWidth), so this equals
715
+ // `width + detailsWidth` — derived from the measured column, keeping the
716
+ // drag write-only (no React re-render mid-drag).
717
+ bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
718
+ writeGeometry(width, height)
719
+ }
720
+
721
+ // Drags write at most once per frame: pointer events fire several times
722
+ // faster than the display refresh, and each write reflows the app shell
723
+ // (the layout push) plus the panels — batching to one write per frame is
724
+ // what keeps the drag smooth. The store is still committed once on release.
725
+ const dragFrame = useRef<number | null>(null)
726
+ const pendingDrag = useRef<{ width: number; height: number } | null>(null)
727
+ const scheduleDrag = (width: number, height: number): void => {
728
+ pendingDrag.current = { width, height }
729
+ if (dragFrame.current !== null) return
730
+ dragFrame.current = requestAnimationFrame(() => {
731
+ dragFrame.current = null
732
+ const pending = pendingDrag.current
733
+ if (pending !== null) {
734
+ pendingDrag.current = null
735
+ applyDrag(pending.width, pending.height)
736
+ }
737
+ })
738
+ }
739
+
740
+ /** Flush any pending drag write and stop scheduling (the store commit on
741
+ * pointer up applies the final clamped values). */
742
+ const stopDragScheduling = (): void => {
743
+ if (dragFrame.current !== null) {
744
+ cancelAnimationFrame(dragFrame.current)
745
+ dragFrame.current = null
746
+ }
747
+ pendingDrag.current = null
748
+ }
749
+
750
+ /**
751
+ * Finalize a drag on pointer up: flush the LAST drag frame to the DOM
752
+ * synchronously, then commit the SAME clamped values to the store. A fast
753
+ * release cancels the rAF before it ran — without the flush the DOM would
754
+ * sit at the pre-drag size until React re-renders with the committed
755
+ * value, and a value that never made it into a move handler would never
756
+ * be applied at all. The measurement pause ends here too: the center
757
+ * column is re-measured BEFORE the committed re-render lands, so the
758
+ * bottom panel's React-rendered right edge already reflects the new
759
+ * width (otherwise the re-render would re-apply the stale rect — the
760
+ * bottom panel visibly jumps for one frame).
761
+ */
762
+ const commitDrag = (
763
+ width: number,
764
+ height: number,
765
+ reduce: (state: SidebarState) => SidebarState,
766
+ ): void => {
767
+ stopDragScheduling()
768
+ applyDrag(width, height)
769
+ draggingRef.current = false
770
+ measureCenter()
771
+ store.reduce(reduce)
772
+ }
773
+
774
+ /** Set once a drag's pointerup handler commits — premature capture loss
775
+ * (pointercancel / lostpointercapture without pointerup) must then be told
776
+ * apart from a normal release. */
777
+ const dragCommitted = useRef(false)
778
+ /**
779
+ * Abort a drag whose pointer stream was interrupted (pointercancel, or
780
+ * capture lost before pointerup): no pointerup will arrive, so without
781
+ * this the dragging state would stick true and center-column measurement
782
+ * would stay paused forever — the bottom panel freezes at stale edges and
783
+ * stops tracking sidebar/app-rail layout changes.
784
+ *
785
+ * A FAST release is the common trigger: browsers merge pointermove bursts,
786
+ * and an ultra-fast flick can cancel the stream before ANY move lands.
787
+ * The commit order is therefore: the LAST KNOWN dragged size (the rAF
788
+ * pending value) first, then the interrupting event's own pointer
789
+ * position (only pointercancel is trusted to carry coordinates —
790
+ * lostpointercapture's coordinates are not guaranteed, so the handlers
791
+ * pass the event only from pointercancel), and finally the size the drag
792
+ * last APPLIED to the DOM (lastDragSize). A drag that produced none of
793
+ * those (pure down+up at the same spot) commits the store's own sizes —
794
+ * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
795
+ * an interrupted fast flick; the abort path added in the unified-host
796
+ * refactor did, and that regression is what this ordering removes).
797
+ *
798
+ * Every commit path marks the drag committed, so the interrupt
799
+ * double-fire (pointercancel → lostpointercapture) cannot commit once
800
+ * and then roll the same drag back.
801
+ */
802
+ const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
803
+ if (dragCommitted.current) return
804
+ const pending = pendingDrag.current
805
+ let width: number | undefined
806
+ let height: number | undefined
807
+ if (pending !== null) {
808
+ width = pending.width
809
+ height = pending.height
810
+ } else if (event !== undefined) {
811
+ // No move ever landed: the cancel position is all we have — commit it
812
+ // (clamped) instead of rolling back the flick.
813
+ if (draggingWidth) {
814
+ width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
815
+ height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
816
+ } else if (draggingBottom) {
817
+ width = Math.min(state?.width ?? 0, window.innerWidth)
818
+ height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
819
+ } else if (draggingCorner) {
820
+ width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
821
+ height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
822
+ }
823
+ }
824
+ if (width !== undefined && height !== undefined) {
825
+ dragCommitted.current = true
826
+ pendingDrag.current = null
827
+ if (dragFrame.current !== null) {
828
+ cancelAnimationFrame(dragFrame.current)
829
+ dragFrame.current = null
830
+ }
831
+ applyDrag(width, height)
832
+ draggingRef.current = false
833
+ measureCenter()
834
+ store.reduce(s => setBottomHeight(setWidth(s, width), height))
835
+ } else {
836
+ // No pending write and no usable event coordinates: keep the size the
837
+ // drag last applied instead of rolling back to the pre-drag value
838
+ // (the flick's moves may have been consumed by the rAF just before
839
+ // the stream died — the DOM already shows the dragged size). Clamp to
840
+ // the current geometry like the layout-push effect (closed/narrow
841
+ // panels are written 0 by the push).
842
+ dragCommitted.current = true
843
+ stopDragScheduling()
844
+ const last = lastDragSize.current
845
+ const adoptedWidth = !narrow && state?.panelOpen === true
846
+ ? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
847
+ : 0
848
+ const adoptedHeight = !narrow && state?.bottomOpen === true
849
+ ? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
850
+ : 0
851
+ applyDrag(adoptedWidth, adoptedHeight)
852
+ draggingRef.current = false
853
+ measureCenter()
854
+ store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
855
+ }
856
+ reset()
857
+ }
858
+
859
+ // Layout push: the app shell gives up the panel's width/height while the
860
+ // panels are open (0 while collapsed), so the conversation and input bar
861
+ // are squeezed instead of covered. The margins are capped at the viewport
862
+ // so a stale persisted size (e.g. fullscreen on a bigger window) can never
863
+ // crush the app shell to zero. Dragging disables the layout transition.
864
+ // On NARROW viewports the drawer FLOATS over the app shell — no push, the
865
+ // conversation keeps the full width behind the drawer.
866
+ useEffect(() => {
867
+ const width = !narrow && snapshot.state?.panelOpen === true
868
+ ? Math.min(snapshot.state.width, window.innerWidth)
869
+ : 0
870
+ const height = !narrow && snapshot.state?.bottomOpen === true
871
+ ? Math.min(snapshot.state.bottomHeight, window.innerHeight)
872
+ : 0
873
+ writeGeometry(width, height)
874
+ }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
875
+ // Unmount must release the push (issue #31): when the boundary swaps the
876
+ // whole sidebar after a render crash (or the plugin fiber is disposed /
877
+ // HMR), the CSS variables would otherwise stay on <html> and layout.css
878
+ // keeps squeezing #root with a stale margin — "the sidebar cannot be
879
+ // hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
880
+ // in the push effect's cleanup: React can yield between a passive
881
+ // effect's cleanup and setup phases, and removing the variables on a
882
+ // dependency change used to paint the push-less layout for a frame (the
883
+ // center column went full width) while the re-add restarted the margin
884
+ // transition — the bottom panel flashed full width after every width
885
+ // drag (issue #258). Keeping the variables continuously valid while
886
+ // mounted makes the push invisible to mid-flush style recals.
887
+ useEffect(() => {
888
+ return () => {
889
+ document.documentElement.style.removeProperty('--dsh-sidebar-width')
890
+ document.documentElement.style.removeProperty('--dsh-sidebar-height')
891
+ }
892
+ }, [])
893
+ useEffect(() => {
894
+ if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
895
+ else document.body.removeAttribute('data-dsh-sidebar-dragging')
896
+ }, [anyDragging])
897
+
898
+
899
+ const actions: WorkbenchActions = useMemo(() => ({
900
+ closeTab: (paneId, tabId) => {
901
+ // A closed terminal releases its pty immediately — including when its
902
+ // socket is mid-reconnect, where the unmount close frame never reaches
903
+ // the host and the process would hold the quota until the grace ends.
904
+ // Agent terminals (tabId `agent:<uuid>`) close through a different
905
+ // host route: the WS close frame is the primary path (sent by
906
+ // TerminalView on unmount), and the agent-pty.close HTTP route is the
907
+ // fallback when the WS is down.
908
+ const current = store.getSnapshot().state
909
+ // Terminal tabs may live in EITHER tree (the bottom panel hosts them
910
+ // too) — the pty-release lookup covers both, or the HTTP fallback is
911
+ // skipped for a bottom-panel terminal whose WS frame never arrived.
912
+ const leaf = current === undefined
913
+ ? undefined
914
+ : leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
915
+ const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
916
+ // Route through the service: the tab-bar close is the canonical close
917
+ // path (finds the pane itself, fires descriptor.onClose); the session
918
+ // scope (with its cwd) rides to the callback.
919
+ ctx.betterSidebar?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
920
+ if (tab?.type === 'terminal') {
921
+ if (isAgentTabId(tabId)) {
922
+ const uuid = agentUuidOf(tabId)
923
+ void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
924
+ } else if (sessionId !== undefined) {
925
+ void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
926
+ }
927
+ }
928
+ },
929
+ activateTab: (paneId, tabId) => {
930
+ // Route through the service: same reducer (finds the pane in EITHER
931
+ // tree, sets the active pane) and fires descriptor.onActivate; the
932
+ // session scope (with its cwd) rides to the callback.
933
+ ctx.betterSidebar?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
934
+ },
935
+ focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
936
+ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
937
+ store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
938
+ },
939
+ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
940
+ store.reduce((s) => {
941
+ let index = -1
942
+ const source = leafWithTab(s.splits, beforeTabId)
943
+ if (source !== undefined && source.id === toPane) {
944
+ index = source.tabs.findIndex(tab => tab.id === beforeTabId)
945
+ }
946
+ return moveTab(s, payload.paneId, payload.tabId, toPane, index)
947
+ })
948
+ },
949
+ resizeSplit: (splitId, index, deltaFrac) => {
950
+ store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
951
+ },
952
+ }), [store, sessionId, cwd])
953
+
954
+ /**
955
+ * The explorer's @-reference button: append `@<relative path>` to the
956
+ * session's composer draft (space-separated). The conversation service is
957
+ * resolved lazily through `ctx.get` (the inject-free read — the app's own
958
+ * plugins read 'conversation' the same way); a missing service or scope
959
+ * degrades to a logged no-op, never a crash. Defined above the no-session
960
+ * early return — a hook must never sit behind a conditional return
961
+ * (React counts hooks per render).
962
+ */
963
+ const referenceInChat = useCallback((path: string): void => {
964
+ if (sessionId === undefined) return
965
+ appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
966
+ }, [ctx, sessionId, cwd])
967
+
968
+ if (state === undefined || sessionId === undefined) {
969
+ return (
970
+ <div data-dsh-panel-host {...osFileDragShield}>
971
+ <div className={css.toggleCluster} data-dsh-toggle-cluster>
972
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
973
+ <button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
974
+ <IconPanelRightOutline16 />
975
+ </button>
976
+ </Tooltip>
977
+ </div>
978
+ </div>
979
+ )
980
+ }
981
+
982
+ const onNewTab = (optionId: string): void => {
983
+ const service = ctx.betterSidebar
984
+ const descriptor = service?.getTab(optionId)
985
+ if (descriptor === undefined) return
986
+ const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
987
+ // The session scope rides along: lifecycle callbacks receive it (and
988
+ // the open stays in the current session, as before).
989
+ service.openTab({ type: optionId, title }, { sessionId, cwd })
990
+ }
991
+
992
+ /**
993
+ * The explorer's @-reference button: append `@<relative path>` to the
994
+ * session's composer draft (space-separated). Resolves the session-scope
995
+ * ctx and the conversation input service at click time; a missing service
996
+ * or scope degrades to a logged no-op, never a crash.
997
+ */
998
+ /** The tab icon from the tab-type registry (shared by every workbench). */
999
+ const tabIconOf = (tab: SidebarTab): ReactNode => {
1000
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
1001
+ if (descriptor === undefined) return null
1002
+ return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
1003
+ }
1004
+
1005
+ /**
1006
+ * The tab badge from the tab-type registry: a count (99+ capped) or a
1007
+ * short text pill. A throwing badge is swallowed (no pill) — the tab
1008
+ * strip must never break because a plugin's badge computation failed.
1009
+ */
1010
+ const tabBadgeOf = (tab: SidebarTab): ReactNode => {
1011
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
1012
+ if (descriptor?.badge === undefined) return null
1013
+ let value: string | number | null | undefined
1014
+ try {
1015
+ value = descriptor.badge(ctx, { sessionId, cwd }, state)
1016
+ } catch (error) {
1017
+ console.error('[dsh-better-sidebar] tab badge error:', error)
1018
+ return null
1019
+ }
1020
+ if (value === null || value === undefined || value === '') return null
1021
+ const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
1022
+ return <span className={css.tabBadge}>{text}</span>
1023
+ }
1024
+
1025
+ /**
1026
+ * Render one tab's content. `active` (from the workbench) tells whether
1027
+ * this tab is the active one in its pane; combined with the panel's
1028
+ * open/closed state it gates live views (the Subagent topology pauses its
1029
+ * polling while the page is not actually visible). The pane id travels
1030
+ * with the tab so diff tabs can split below their source pane.
1031
+ */
1032
+ const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
1033
+ <TabContent
1034
+ tab={tab}
1035
+ paneId={paneId}
1036
+ sessionId={sessionId}
1037
+ cwd={cwd}
1038
+ expanded={state.expanded}
1039
+ onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
1040
+ onReferenceFile={referenceInChat}
1041
+ ctx={ctx}
1042
+ store={store}
1043
+ visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
1044
+ onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
1045
+ onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
1046
+ localeRevision={localeRevision}
1047
+ tabsVersion={tabsVersion}
1048
+ />
1049
+ )
1050
+
1051
+ return (
1052
+ <div data-dsh-panel-host {...osFileDragShield}>
1053
+ {/*
1054
+ The persistent toggle cluster at the top-right corner: the bottom
1055
+ panel's button (bottom glyph) LEFT of the right panel's (side glyph).
1056
+ Always pinned to the viewport corner — inside the right panel's
1057
+ top-right while it is open, sitting flush in the tab strip whose
1058
+ right end it really squeezes (the strip reserves its width via CSS),
1059
+ so the tabs genuinely yield space to it.
1060
+ */}
1061
+ <div className={css.toggleCluster} data-dsh-toggle-cluster>
1062
+ <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
1063
+ <button
1064
+ type="button"
1065
+ className={css.toggleButton}
1066
+ aria-label={state.panelOpen ? t('collapse') : t('expand')}
1067
+ onClick={() => { store.reduce(togglePanel) }}
1068
+ >
1069
+ <IconPanelRightOutline16 />
1070
+ </button>
1071
+ </Tooltip>
1072
+ </div>
1073
+ {/*
1074
+ The right panel stays mounted while collapsed (hidden off-screen) so
1075
+ the slide in/out can animate; visibility hides it after the slide
1076
+ settles. Its bottom edge follows the bottom panel's height (0 while
1077
+ the bottom panel is closed) — the VSCode-style "sidebar above panel".
1078
+ On NARROW viewports it is a full-width drawer holding both
1079
+ workbenches (see MobileWorkbench); the width drag strip is not
1080
+ offered there — a full-screen sheet has nothing to drag.
1081
+ */}
1082
+ <div
1083
+ ref={panelRef}
1084
+ className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
1085
+ data-dsh-panel
1086
+ style={{
1087
+ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
1088
+ // Narrow drawer: keep the bottom-anchored sheet above the on-screen
1089
+ // keyboard (visualViewport inset); desktop panels are full-height
1090
+ // and unaffected.
1091
+ bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
1092
+ }}
1093
+
1094
+ data-dragging={anyDragging || undefined}
1095
+ >
1096
+ {!narrow && (
1097
+ <div
1098
+ className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
1099
+
1100
+ onPointerDown={(event) => {
1101
+ event.preventDefault()
1102
+ event.currentTarget.setPointerCapture(event.pointerId)
1103
+ dragCommitted.current = false
1104
+ widthDrag.current = { startX: event.clientX, startWidth: state.width }
1105
+ setDraggingWidth(true)
1106
+ }}
1107
+ onPointerMove={(event) => {
1108
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1109
+ const { startX, startWidth } = widthDrag.current
1110
+ const width = clampWidth(startWidth + (startX - event.clientX))
1111
+ const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
1112
+ scheduleDrag(width, height)
1113
+ }}
1114
+ onPointerUp={(event) => {
1115
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1116
+ if (dragCommitted.current) return
1117
+ dragCommitted.current = true
1118
+ event.currentTarget.releasePointerCapture(event.pointerId)
1119
+ const { startX, startWidth } = widthDrag.current
1120
+ // The up position is the pointer's FINAL position — a fast
1121
+ // flick's tail is coalesced into the up event, so the last
1122
+ // pointermove (the rAF pending value) can be stale. Commit
1123
+ // from the up position (v0.13.1 semantics; issue #247).
1124
+ const width = clampWidth(startWidth + (startX - event.clientX))
1125
+ const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
1126
+ commitDrag(width, height, s => setWidth(s, width))
1127
+ setDraggingWidth(false)
1128
+ }}
1129
+ onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
1130
+ onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
1131
+ />
1132
+ )}
1133
+ <div className={css.panelBody}>
1134
+ <Workbench
1135
+ state={state}
1136
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
1137
+ actions={actions}
1138
+ onNewTab={onNewTab}
1139
+ renderTab={renderTab}
1140
+ getTabIcon={tabIconOf}
1141
+ getTabBadge={tabBadgeOf}
1142
+ />
1143
+ </div>
1144
+ </div>
1145
+ </div>
1146
+ )
1147
+ }
1148
+