dsh-better-sidebar 0.10.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -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 +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,861 @@
1
+ /**
2
+ * The sidebar shell: fixed-position panels portalled onto document.body
3
+ * (the core AppFrame owns the left sidebar / center / details columns and
4
+ * has no right-side hole for plugins). The right panel hosts the original
5
+ * workbench; the bottom panel hosts a second, independent workbench. The
6
+ * bottom panel squeezes ONLY the center column (the agent output area): it
7
+ * spans from the app shell's own left sidebar to the right panel's left
8
+ * edge, so neither sidebar gives up any position (the right panel keeps its
9
+ * full height). A persistent two-button cluster at the top-right corner
10
+ * toggles each panel; the right panel's width drags from its left edge, the
11
+ * bottom panel's height from its top edge, and the shared corner drags both
12
+ * at once. The whole layout lives in the per-session store, so switching
13
+ * conversations swaps the sidebar.
14
+ *
15
+ * The shell binds the workbench actions to the store and dispatches tab
16
+ * content to the views. New tabs come from the + menu (explorer / git /
17
+ * terminal; editors open from the explorer). Tabs live in one tree only —
18
+ * they never cross panels; only the panel sizes drag against each other.
19
+ *
20
+ * Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
21
+ * narrow migrates the bottom panel's tabs INTO the right tree
22
+ * (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
23
+ * strips. The right panel becomes a full-width drawer, the bottom panel
24
+ * and its toggle button disappear, and the layout push is disabled (the
25
+ * drawer floats). Widening does not migrate back: the tabs keep living in
26
+ * the right tree.
27
+ */
28
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
29
+ import { useSyncExternalStore } from 'react'
30
+ import clsx from 'clsx'
31
+ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
32
+ import type { Context, SidebarSessionList } from '../context-types.ts'
33
+ import { appendToDraft } from './conversation-draft.ts'
34
+ import {
35
+ BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
36
+ reconcileAgentTerminals,
37
+ resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
38
+ type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
39
+ } from './state.ts'
40
+ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
41
+ import { Workbench, type WorkbenchActions } from './split-pane.tsx'
42
+ import { useNarrowViewport } from './breakpoints.ts'
43
+ import type { NewTabOption } from './TabBar.tsx'
44
+ import type { TabDragPayload } from './TabBar.tsx'
45
+ import { relativeTo } from './paths.ts'
46
+ import { OrphanedTab } from './OrphanedTab.tsx'
47
+ import { detectNewDirectSubagent } from './subagent-detect.ts'
48
+ import { detectNewJob } from './subagent-jobs.ts'
49
+ import { t } from './locales.ts'
50
+ import { api, type SessionScope } from './api.ts'
51
+ import css from './sidebar.module.css'
52
+
53
+ /** How many consecutive reconnect failures stop the agent-terminals push loop
54
+ * (mirror of the terminal view's own cap; the loop restarts on session switch). */
55
+ const FAILURE_LIMIT = 3
56
+
57
+ /** Render the content of one tab (dispatched by type). */
58
+ function TabContent(props: {
59
+ tab: SidebarTab
60
+ sessionId: string
61
+ cwd: string | undefined
62
+ expanded: string[]
63
+ onToggleDir: (path: string) => void
64
+ onReferenceFile: (path: string) => void
65
+ ctx: Context
66
+ store: SidebarStore
67
+ /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
68
+ visible: boolean
69
+ /** Fired before a topology node jumps to its child session (see Sidebar). */
70
+ onSubagentJump: (childSessionId: string) => void
71
+ /** Open a diff tab from the git panel (placement handled by the store). */
72
+ onOpenDiff: (tab: SidebarTab) => void
73
+ }) {
74
+ const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
75
+ const scope = { sessionId, cwd }
76
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
77
+ if (descriptor === undefined) {
78
+ return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
79
+ }
80
+ return descriptor.component({
81
+ ctx, store, scope, tab, visible, expanded,
82
+ onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
83
+ })
84
+ }
85
+
86
+ /** The + menu options for the current state, driven by the tab registry.
87
+ * Hidden tabs (editor/diff) never show; `available` returning false shows
88
+ * a disabled row (e.g. terminal at capacity) instead of hiding the option.
89
+ * Tabs the user disabled in the side card settings are filtered out
90
+ * entirely — re-enabling them is the settings page's job. */
91
+ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
92
+ const service = ctx.betterSidebar
93
+ if (service === undefined) return []
94
+ return service.getTabs()
95
+ .filter(d => !d.hidden && service.isTabEnabled(d.id))
96
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
97
+ .map(d => ({
98
+ id: d.id,
99
+ label: typeof d.title === 'function' ? d.title() : d.title,
100
+ disabled: !(d.available?.(ctx, scope, state) ?? true),
101
+ icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
102
+ }))
103
+ }
104
+
105
+ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
106
+ const { ctx, store } = props
107
+
108
+ // Copy freshness: re-render the whole tree when the DSH locale switches.
109
+ // The module-level t() reads the active locale at call time, so a root
110
+ // re-render alone re-localizes every panel (no memo barriers below).
111
+ const localeRevision = useSyncExternalStore(
112
+ useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
113
+ useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
114
+ )
115
+ void localeRevision
116
+
117
+ // Narrow (mobile) viewports collapse the two panels into one: the right
118
+ // panel becomes a full-width drawer holding BOTH workbenches, the bottom
119
+ // panel (and its toggle button) disappears, and the layout push is
120
+ // disabled (the drawer floats over the app shell). Entering narrow
121
+ // MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
122
+ // — the merged display is the right sidebar alone, the bottom tabs thrown
123
+ // into its strips. Widening never rewrites the migrated state: the tabs
124
+ // keep living in the right tree.
125
+ const narrow = useNarrowViewport()
126
+
127
+ // Current conversation (the sessions list feed).
128
+ const sessionList = useSyncExternalStore(
129
+ useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
130
+ useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
131
+ )
132
+ const current = sessionList.current
133
+
134
+ // Per-session sidebar state.
135
+ const snapshot = useSyncExternalStore(
136
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
137
+ useCallback(() => store.getSnapshot(), [store]),
138
+ )
139
+ useEffect(() => { store.setSession(current) }, [current, store])
140
+
141
+ const state = snapshot.state
142
+ const sessionId = snapshot.sessionId
143
+ const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
144
+
145
+ // The collapsed toggle cluster reclaims the top-right corner, so the DSH
146
+ // session header's right-aligned utilities (the "Session log" download
147
+ // capsule) must yield. layout.css keys off this body attribute to push the
148
+ // header's right padding out past the cluster. Only the CLOSED panel needs
149
+ // it — an open panel already squeezes `#root` left, moving the header clear.
150
+ const collapsed = state === undefined || !state.panelOpen
151
+ useEffect(() => {
152
+ if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
153
+ else document.body.removeAttribute('data-dsh-sidebar-collapsed')
154
+ return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
155
+ }, [collapsed])
156
+
157
+ /**
158
+ * Bottom-panel merge on narrow viewports: whenever a session is current
159
+ * while narrow (mount, session switch, or a desktop→narrow transition),
160
+ * throw the bottom tree's tabs into the right tree. Idempotent — after
161
+ * the first migration the bottom tree is empty and the reducer returns
162
+ * the same reference, so this effect settles immediately.
163
+ */
164
+ useEffect(() => {
165
+ if (!narrow || sessionId === undefined) return
166
+ store.reduce(migrateBottomTabs)
167
+ }, [narrow, sessionId, store])
168
+
169
+ // While the session's header is still hydrating (or the session is blank),
170
+ // the list summary may carry no cwd; ask the host once (it falls back to
171
+ // the process cwd) so the explorer root and terminal cwd are real from
172
+ // first paint instead of showing "no session".
173
+ const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
174
+ useEffect(() => {
175
+ setFetchedCwd(undefined)
176
+ if (sessionId === undefined || summaryCwd !== undefined) return
177
+ let cancelled = false
178
+ api.sessionCwd({ sessionId })
179
+ .then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
180
+ .catch(() => { /* the explorer/git rows surface their own errors */ })
181
+ return () => { cancelled = true }
182
+ }, [sessionId, summaryCwd])
183
+ const cwd = summaryCwd ?? fetchedCwd
184
+
185
+ /**
186
+ * Agent terminals push: subscribe to the host's live list of agent-owned
187
+ * terminals for this session (created by the model through the
188
+ * `terminal_create` tool). The host pushes a JSON array on every
189
+ * create / close / exit; the sidebar reconciles the list into tabs
190
+ * (id `agent:<uuid>`, title from the agent). A disconnected socket
191
+ * retries with a short backoff so a refresh or transient drop reattaches
192
+ * the same shell without losing the agent's work — capped like the
193
+ * terminal view's own reconnect loop, so a refused endpoint never spins
194
+ * forever (the next session switch restarts the loop).
195
+ * While the terminal tab type is disabled in settings, pushes are
196
+ * ignored (no auto-added tabs); re-enabling makes the next push converge.
197
+ */
198
+ useEffect(() => {
199
+ if (sessionId === undefined) return
200
+ let socket: WebSocket | null = null
201
+ let retry: number | undefined
202
+ let closed = false
203
+ let failures = 0
204
+ const connect = (): void => {
205
+ if (closed) return
206
+ const url = new URL('/sidebar/ws/agent-terminals', location.origin)
207
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
208
+ url.search = new URLSearchParams({ sessionId }).toString()
209
+ socket = new WebSocket(url.toString())
210
+ socket.onmessage = (event) => {
211
+ if (typeof event.data !== 'string') return
212
+ try {
213
+ const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
214
+ if (!Array.isArray(list)) return
215
+ store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
216
+ ? s
217
+ : reconcileAgentTerminals(s, list))
218
+ } catch {
219
+ // Malformed push: ignore (the next push will reconcile).
220
+ }
221
+ }
222
+ socket.onclose = () => {
223
+ if (closed) return
224
+ failures += 1
225
+ if (failures >= FAILURE_LIMIT) {
226
+ console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
227
+ return
228
+ }
229
+ retry = window.setTimeout(connect, 2000)
230
+ }
231
+ socket.onerror = () => { socket?.close() }
232
+ }
233
+ connect()
234
+ return () => {
235
+ closed = true
236
+ window.clearTimeout(retry)
237
+ socket?.close()
238
+ }
239
+ }, [sessionId, store])
240
+
241
+ /**
242
+ * Subagent auto-activation: the moment the current conversation spawns its
243
+ * FIRST direct subagent (a 0 → N transition on the list feed), the "auto
244
+ * open" pref is on, and the Subagent tab type is enabled in settings,
245
+ * open the panel (if collapsed) and focus the Subagent page
246
+ * (single-instance: an existing tab is focused, never duplicated).
247
+ * Switching to a session that already has subagents never triggers — its
248
+ * baseline starts at the current count — so a deliberate layout is never
249
+ * fought.
250
+ */
251
+ const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
252
+ useEffect(() => {
253
+ const prev = listBaselineRef.current
254
+ listBaselineRef.current = sessionList
255
+ if (sessionId === undefined || prev === undefined) return
256
+ if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
257
+ if (!store.getPrefs().autoOpenSubagent) return
258
+ if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
259
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
260
+ // Pin the landing to the right panel: the auto-opened Subagent page must
261
+ // appear where the panel just expanded, not in a bottom-panel pane the
262
+ // user last touched.
263
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
264
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
265
+ }, [sessionList, sessionId, store, ctx])
266
+
267
+ /**
268
+ * Job auto-activation: the moment a NEW background job appears for the
269
+ * current conversation (a job id the previous snapshot lacked), the
270
+ * auto-open pref is on, and the Jobs tab type is enabled, open the panel
271
+ * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
272
+ * (0 → N only), ANY new job id triggers: the agent may start several
273
+ * jobs in one session, and each should surface. A fresh page load never
274
+ * triggers — its baseline starts at the current snapshot.
275
+ */
276
+ const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
277
+ useEffect(() => {
278
+ const prev = jobBaselineRef.current
279
+ jobBaselineRef.current = sessionList
280
+ if (sessionId === undefined || prev === undefined) return
281
+ if (!detectNewJob(prev, sessionList, sessionId)) return
282
+ if (!store.getPrefs().autoOpenJobs) return
283
+ if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
284
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
285
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
286
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
287
+ }, [sessionList, sessionId, store, ctx])
288
+
289
+ /**
290
+ * Topology jump-back: clicking a subagent node on the Subagent page calls
291
+ * the official `openSubagent`, which switches the sidebar to that child
292
+ * session's OWN layout (a fresh child session defaults to the explorer).
293
+ * The README contract says the Subagent page must stay open with the jumped
294
+ * node highlighted — so once the current session becomes the recorded jump
295
+ * target, re-open the Subagent page on top of the child's layout (expanding
296
+ * the panel first if it is collapsed). Only this explicit node click arms
297
+ * the flag, so switching to a subagent session by any other means keeps
298
+ * that session's own layout untouched.
299
+ */
300
+ const subagentJumpRef = useRef<string | undefined>(undefined)
301
+ useEffect(() => {
302
+ const pending = subagentJumpRef.current
303
+ if (pending === undefined || sessionId !== pending) return
304
+ subagentJumpRef.current = undefined
305
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
306
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
307
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
308
+ }, [sessionId, store, ctx])
309
+
310
+ // The app shell's center column: the bottom panel spans ONLY that column
311
+ // ("squeezes the agent output area") — it starts at the app sidebar's
312
+ // right edge and ends at the details column's left edge (the details
313
+ // column sits between the center and the right panel). Measured directly
314
+ // from the AppFrame's center column DOM (children[1], the layout.css
315
+ // nth-child(2) column) so the bottom panel tracks the column's real
316
+ // horizontal edges — including the animated margin-right push while the
317
+ // right panel opens/closes; a frame that never appears keeps the initial
318
+ // zero-size fallback (the panel renders at 0 width until measured).
319
+ const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
320
+ // Refs keep the measure step stable across renders and let it skip work
321
+ // mid-drag: during a width/corner drag the layout push resizes the center
322
+ // column every frame, and reacting (setCenterRect → re-render) would
323
+ // re-introduce the drag lag this shell deliberately avoids. applyDrag
324
+ // writes the bottom panel's edges directly, so measurement pauses then.
325
+ const centerColRef = useRef<HTMLElement | null>(null)
326
+ const draggingRef = useRef(false)
327
+ const measureCenter = useCallback((): void => {
328
+ if (draggingRef.current) return
329
+ const col = centerColRef.current
330
+ if (col === null) return
331
+ const rect = col.getBoundingClientRect()
332
+ // The bottom panel only cares about the horizontal edges: a pure height
333
+ // change (the bottom panel itself opening/closing) must not re-render,
334
+ // so keep the previous object when left/right are unchanged.
335
+ setCenterRect(prev =>
336
+ prev.left === rect.left && prev.right === rect.right
337
+ ? prev
338
+ : { left: rect.left, right: rect.right })
339
+ }, [])
340
+ useEffect(() => {
341
+ let disposed = false
342
+ let observer: ResizeObserver | undefined
343
+ // Locate the AppFrame's center column (children[1] of the frame div —
344
+ // layout.css's nth-child(2)). The shell swaps the boot page for the
345
+ // AppFrame only AFTER boot settles, so the first query may miss it.
346
+ // Never give up: watch #root's children (the swap mutates them) and
347
+ // re-run this locator — querying once and bailing would strand the
348
+ // panel at the zero-size fallback forever (observed: a 1px sliver at
349
+ // the viewport's left edge).
350
+ const locate = (): void => {
351
+ if (disposed) return
352
+ const frame = document.querySelector('#root > div')
353
+ const col = frame?.children[1] as HTMLElement | undefined
354
+ if (col === undefined) {
355
+ if (centerColRef.current !== null) {
356
+ centerColRef.current = null
357
+ observer?.disconnect()
358
+ observer = undefined
359
+ }
360
+ return
361
+ }
362
+ if (centerColRef.current !== col) {
363
+ centerColRef.current = col
364
+ observer?.disconnect()
365
+ observer = new ResizeObserver(measureCenter)
366
+ observer.observe(col)
367
+ }
368
+ measureCenter()
369
+ }
370
+ locate()
371
+ const watcher = new MutationObserver(locate)
372
+ const root = document.getElementById('root')
373
+ if (root !== null) watcher.observe(root, { childList: true })
374
+ return () => {
375
+ disposed = true
376
+ observer?.disconnect()
377
+ watcher.disconnect()
378
+ centerColRef.current = null
379
+ }
380
+ }, [measureCenter])
381
+
382
+ /**
383
+ * Bottom-panel first-expansion auto terminal: the FIRST time the user
384
+ * expands the bottom panel in a session, try to open a fresh terminal tab
385
+ * there. "Try" is literal — the terminal's own quota and enable switch
386
+ * gate the attempt (a full quota or a disabled terminal type makes it a
387
+ * no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
388
+ * nested settings toggle, default on). Only a false→true TRANSITION fires
389
+ * (a panel persisted open never counts as an expansion), and the session's
390
+ * bottomOpenedOnce flag is set atomically with the first fire so later
391
+ * expansions never repeat it.
392
+ */
393
+ const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
394
+ useEffect(() => {
395
+ // The bottom panel does not exist on narrow viewports (the two
396
+ // workbenches merge into one panel), so the first-expansion auto
397
+ // terminal is a desktop-only behavior.
398
+ if (narrow) return
399
+ if (state === undefined) return
400
+ const wasOpen = bottomWasOpenRef.current
401
+ bottomWasOpenRef.current = state.bottomOpen
402
+ if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
403
+ if (state.bottomOpenedOnce) return
404
+ if (store.getPrefs().bottomPanelAutoTerminal === false) return
405
+ if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
406
+ // Land the tab in the bottom panel's first pane; the once-flag is set
407
+ // atomically so later expansions never repeat the auto-open.
408
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
409
+ ctx.betterSidebar?.openTab({ type: 'terminal' })
410
+ }, [state, store, ctx, narrow])
411
+
412
+ // Panel drags: the right panel's width (left edge strip), the bottom
413
+ // panel's height (top edge strip), and the shared corner (both at once).
414
+ // Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
415
+ // variables) instead of round-tripping the store on every pointer move —
416
+ // a store reduce re-renders both workbenches (terminals, editors…) per
417
+ // move, which is the visible drag lag. The store is committed once on
418
+ // pointer up (clamping + persistence).
419
+ const panelRef = useRef<HTMLDivElement | null>(null)
420
+ const bottomRef = useRef<HTMLDivElement | null>(null)
421
+ const cornerRef = useRef<HTMLDivElement | null>(null)
422
+ const widthDrag = useRef({ startX: 0, startWidth: 0 })
423
+ const [draggingWidth, setDraggingWidth] = useState(false)
424
+ const bottomDrag = useRef({ startY: 0, startHeight: 0 })
425
+ const [draggingBottom, setDraggingBottom] = useState(false)
426
+ const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
427
+ const [draggingCorner, setDraggingCorner] = useState(false)
428
+ const anyDragging = draggingWidth || draggingBottom || draggingCorner
429
+
430
+ // Pause center-column measurement while dragging, and re-measure once the
431
+ // drag settles at its committed size. The store commit lands on release and
432
+ // the final width equals the last drag width, so no ResizeObserver event
433
+ // fires to refresh centerRect — this explicit re-measure covers that gap.
434
+ useEffect(() => {
435
+ draggingRef.current = anyDragging
436
+ if (!anyDragging) measureCenter()
437
+ }, [anyDragging, measureCenter])
438
+
439
+ // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
440
+ // re-clamps on commit; these keep the panels from overshooting mid-drag).
441
+ const clampWidth = (width: number): number =>
442
+ Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
443
+ const clampHeight = (height: number): number =>
444
+ Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
445
+
446
+ /** Apply a drag size to the DOM without touching React state or the store.
447
+ * The bottom panel's right edge tracks the right panel's left edge HERE
448
+ * too — React state only updates on release, so the inline right must be
449
+ * written directly or the bottom panel would lag the sidebar mid-drag. */
450
+ const applyDrag = (width: number, height: number): void => {
451
+ panelRef.current?.style.setProperty('width', `${width}px`)
452
+ bottomRef.current?.style.setProperty('height', `${height}px`)
453
+ // centerRect.right is the center column's right edge at the committed
454
+ // width (innerWidth - state.width - detailsWidth), so this equals
455
+ // `width + detailsWidth` — derived from the measured column, keeping the
456
+ // drag write-only (no React re-render mid-drag).
457
+ bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
458
+ document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
459
+ document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
460
+ if (cornerRef.current !== null) {
461
+ cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
462
+ cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
463
+ }
464
+ }
465
+
466
+ // Drags write at most once per frame: pointer events fire several times
467
+ // faster than the display refresh, and each write reflows the app shell
468
+ // (the layout push) plus the panels — batching to one write per frame is
469
+ // what keeps the drag smooth. The store is still committed once on release.
470
+ const dragFrame = useRef<number | null>(null)
471
+ const pendingDrag = useRef<{ width: number; height: number } | null>(null)
472
+ const scheduleDrag = (width: number, height: number): void => {
473
+ pendingDrag.current = { width, height }
474
+ if (dragFrame.current !== null) return
475
+ dragFrame.current = requestAnimationFrame(() => {
476
+ dragFrame.current = null
477
+ const pending = pendingDrag.current
478
+ if (pending !== null) {
479
+ pendingDrag.current = null
480
+ applyDrag(pending.width, pending.height)
481
+ }
482
+ })
483
+ }
484
+
485
+ /** Flush any pending drag write and stop scheduling (the store commit on
486
+ * pointer up applies the final clamped values). */
487
+ const stopDragScheduling = (): void => {
488
+ if (dragFrame.current !== null) {
489
+ cancelAnimationFrame(dragFrame.current)
490
+ dragFrame.current = null
491
+ }
492
+ pendingDrag.current = null
493
+ }
494
+
495
+ // Layout push: the app shell gives up the panel's width/height while the
496
+ // panels are open (0 while collapsed), so the conversation and input bar
497
+ // are squeezed instead of covered. The margins are capped at the viewport
498
+ // so a stale persisted size (e.g. fullscreen on a bigger window) can never
499
+ // crush the app shell to zero. Dragging disables the layout transition.
500
+ // On NARROW viewports the drawer FLOATS over the app shell — no push, the
501
+ // conversation keeps the full width behind the drawer.
502
+ useEffect(() => {
503
+ const width = !narrow && snapshot.state?.panelOpen === true
504
+ ? Math.min(snapshot.state.width, window.innerWidth)
505
+ : 0
506
+ const height = !narrow && snapshot.state?.bottomOpen === true
507
+ ? Math.min(snapshot.state.bottomHeight, window.innerHeight)
508
+ : 0
509
+ document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
510
+ document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
511
+ }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
512
+ useEffect(() => {
513
+ if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
514
+ else document.body.removeAttribute('data-dsh-sidebar-dragging')
515
+ }, [anyDragging])
516
+
517
+
518
+ const actions: WorkbenchActions = useMemo(() => ({
519
+ closeTab: (paneId, tabId) => {
520
+ // A closed terminal releases its pty immediately — including when its
521
+ // socket is mid-reconnect, where the unmount close frame never reaches
522
+ // the host and the process would hold the quota until the grace ends.
523
+ // Agent terminals (tabId `agent:<uuid>`) close through a different
524
+ // host route: the WS close frame is the primary path (sent by
525
+ // TerminalView on unmount), and the agent-pty.close HTTP route is the
526
+ // fallback when the WS is down.
527
+ const current = store.getSnapshot().state
528
+ const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
529
+ const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
530
+ store.reduce(s => closeTab(s, paneId, tabId))
531
+ if (tab?.type === 'terminal') {
532
+ if (isAgentTabId(tabId)) {
533
+ const uuid = agentUuidOf(tabId)
534
+ void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
535
+ } else if (sessionId !== undefined) {
536
+ void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
537
+ }
538
+ }
539
+ },
540
+ activateTab: (paneId, tabId) => {
541
+ store.reduce(s => ({
542
+ ...s,
543
+ activePane: paneId,
544
+ splits: mapLeaf(s.splits, paneId, (leaf) => {
545
+ if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
546
+ }),
547
+ }))
548
+ },
549
+ focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
550
+ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
551
+ store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
552
+ },
553
+ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
554
+ store.reduce((s) => {
555
+ let index = -1
556
+ const source = leafWithTab(s.splits, beforeTabId)
557
+ if (source !== undefined && source.id === toPane) {
558
+ index = source.tabs.findIndex(tab => tab.id === beforeTabId)
559
+ }
560
+ return moveTab(s, payload.paneId, payload.tabId, toPane, index)
561
+ })
562
+ },
563
+ resizeSplit: (splitId, index, deltaFrac) => {
564
+ store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
565
+ },
566
+ }), [store, sessionId, cwd])
567
+
568
+ /**
569
+ * The explorer's @-reference button: append `@<relative path>` to the
570
+ * session's composer draft (space-separated). The conversation service is
571
+ * resolved lazily through `ctx.get` (the inject-free read — the app's own
572
+ * plugins read 'conversation' the same way); a missing service or scope
573
+ * degrades to a logged no-op, never a crash. Defined above the no-session
574
+ * early return — a hook must never sit behind a conditional return
575
+ * (React counts hooks per render).
576
+ */
577
+ const referenceInChat = useCallback((path: string): void => {
578
+ if (sessionId === undefined) return
579
+ appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
580
+ }, [ctx, sessionId, cwd])
581
+
582
+ if (state === undefined || sessionId === undefined) {
583
+ return (
584
+ <div className={css.toggleCluster}>
585
+ {!narrow && (
586
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
587
+ <button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
588
+ <IconPanelBottomOutline16 />
589
+ </button>
590
+ </Tooltip>
591
+ )}
592
+ <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
593
+ <button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
594
+ <IconPanelRightOutline16 />
595
+ </button>
596
+ </Tooltip>
597
+ </div>
598
+ )
599
+ }
600
+
601
+ const onNewTab = (optionId: string): void => {
602
+ const service = ctx.betterSidebar
603
+ const descriptor = service?.getTab(optionId)
604
+ if (descriptor === undefined) return
605
+ const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
606
+ service.openTab({ type: optionId, title })
607
+ }
608
+
609
+ /**
610
+ * The explorer's @-reference button: append `@<relative path>` to the
611
+ * session's composer draft (space-separated). Resolves the session-scope
612
+ * ctx and the conversation input service at click time; a missing service
613
+ * or scope degrades to a logged no-op, never a crash.
614
+ */
615
+ /** The tab icon from the tab-type registry (shared by every workbench). */
616
+ const tabIconOf = (tab: SidebarTab): ReactNode => {
617
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
618
+ if (descriptor === undefined) return null
619
+ return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
620
+ }
621
+
622
+ /**
623
+ * Render one tab's content. `active` (from the workbench) tells whether
624
+ * this tab is the active one in its pane; combined with the panel's
625
+ * open/closed state it gates live views (the Subagent topology pauses its
626
+ * polling while the page is not actually visible). The pane id travels
627
+ * with the tab so diff tabs can split below their source pane.
628
+ */
629
+ const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
630
+ <TabContent
631
+ tab={tab}
632
+ sessionId={sessionId}
633
+ cwd={cwd}
634
+ expanded={state.expanded}
635
+ onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
636
+ onReferenceFile={referenceInChat}
637
+ ctx={ctx}
638
+ store={store}
639
+ visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
640
+ onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
641
+ onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
642
+ />
643
+ )
644
+
645
+ return (
646
+ <>
647
+ {/*
648
+ The persistent toggle cluster at the top-right corner: the bottom
649
+ panel's button (bottom glyph) LEFT of the right panel's (side glyph).
650
+ Always pinned to the viewport corner — inside the right panel's
651
+ top-right while it is open, sitting flush in the tab strip whose
652
+ right end it really squeezes (the strip reserves its width via CSS),
653
+ so the tabs genuinely yield space to it.
654
+ */}
655
+ <div className={css.toggleCluster}>
656
+ {/*
657
+ Narrow viewports merge the two workbenches into the one drawer —
658
+ there is no bottom panel, so its toggle button is not offered.
659
+ */}
660
+ {!narrow && (
661
+ <Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
662
+ <button
663
+ type="button"
664
+ className={css.toggleButton}
665
+ aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
666
+ onClick={() => { store.reduce(toggleBottomPanel) }}
667
+ >
668
+ <IconPanelBottomOutline16 />
669
+ </button>
670
+ </Tooltip>
671
+ )}
672
+ <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
673
+ <button
674
+ type="button"
675
+ className={css.toggleButton}
676
+ aria-label={state.panelOpen ? t('collapse') : t('expand')}
677
+ onClick={() => { store.reduce(togglePanel) }}
678
+ >
679
+ <IconPanelRightOutline16 />
680
+ </button>
681
+ </Tooltip>
682
+ </div>
683
+ {/*
684
+ The right panel stays mounted while collapsed (hidden off-screen) so
685
+ the slide in/out can animate; visibility hides it after the slide
686
+ settles. Its bottom edge follows the bottom panel's height (0 while
687
+ the bottom panel is closed) — the VSCode-style "sidebar above panel".
688
+ On NARROW viewports it is a full-width drawer holding both
689
+ workbenches (see MobileWorkbench); the width drag strip is not
690
+ offered there — a full-screen sheet has nothing to drag.
691
+ */}
692
+ <div
693
+ ref={panelRef}
694
+ className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
695
+ style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
696
+ data-dragging={anyDragging || undefined}
697
+ >
698
+ {!narrow && (
699
+ <div
700
+ className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
701
+ onPointerDown={(event) => {
702
+ event.preventDefault()
703
+ event.currentTarget.setPointerCapture(event.pointerId)
704
+ widthDrag.current = { startX: event.clientX, startWidth: state.width }
705
+ setDraggingWidth(true)
706
+ }}
707
+ onPointerMove={(event) => {
708
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
709
+ const { startX, startWidth } = widthDrag.current
710
+ const width = clampWidth(startWidth + (startX - event.clientX))
711
+ const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
712
+ scheduleDrag(width, height)
713
+ }}
714
+ onPointerUp={(event) => {
715
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
716
+ event.currentTarget.releasePointerCapture(event.pointerId)
717
+ const { startX, startWidth } = widthDrag.current
718
+ stopDragScheduling()
719
+ store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
720
+ setDraggingWidth(false)
721
+ }}
722
+ />
723
+ )}
724
+ <div className={css.panelBody}>
725
+ <Workbench
726
+ state={state}
727
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
728
+ actions={actions}
729
+ onNewTab={onNewTab}
730
+ renderTab={renderTab}
731
+ getTabIcon={tabIconOf}
732
+ />
733
+ </div>
734
+ </div>
735
+ {/*
736
+ The bottom panel: a second, independent workbench. It squeezes ONLY
737
+ the center column (the agent output area): it starts at the app
738
+ shell's own left sidebar and ends at the right panel's left edge —
739
+ neither sidebar gives up any position (the right panel keeps its
740
+ full height). Its resize strip is the top edge; hidden by sliding
741
+ down like the right panel. On NARROW viewports it does not exist —
742
+ the bottom workbench lives inside the drawer (MobileWorkbench).
743
+ */}
744
+ {!narrow && (
745
+ <div
746
+ ref={bottomRef}
747
+ className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
748
+ style={{
749
+ height: Math.min(state.bottomHeight, window.innerHeight),
750
+ left: centerRect.left,
751
+ // Direct from the center column's measured right edge: the bottom
752
+ // panel spans ONLY the center column, ending exactly at the
753
+ // details column's left edge (the details column sits between the
754
+ // center and the right panel, and the right panel's margin-right
755
+ // push is already baked into centerRect.right).
756
+ right: window.innerWidth - centerRect.right,
757
+ // The seam against the open right panel needs its own hairline
758
+ // (the right panel's border-left alone is covered by this panel's
759
+ // fill — without it the corner looks cut off).
760
+ borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
761
+ }}
762
+ data-dragging={(draggingBottom || draggingCorner) || undefined}
763
+ >
764
+ <div
765
+ className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
766
+ onPointerDown={(event) => {
767
+ event.preventDefault()
768
+ event.currentTarget.setPointerCapture(event.pointerId)
769
+ bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
770
+ setDraggingBottom(true)
771
+ }}
772
+ onPointerMove={(event) => {
773
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
774
+ const { startY, startHeight } = bottomDrag.current
775
+ const height = clampHeight(startHeight + (startY - event.clientY))
776
+ scheduleDrag(Math.min(state.width, window.innerWidth), height)
777
+ }}
778
+ onPointerUp={(event) => {
779
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
780
+ event.currentTarget.releasePointerCapture(event.pointerId)
781
+ const { startY, startHeight } = bottomDrag.current
782
+ stopDragScheduling()
783
+ store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
784
+ setDraggingBottom(false)
785
+ }}
786
+ />
787
+ {/*
788
+ The bottom panel's own close control at its tab strip's right end
789
+ (the strip reserves the width via CSS so the + menu never hides
790
+ under it): one tap collapses the panel.
791
+ */}
792
+ <Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
793
+ <button
794
+ type="button"
795
+ className={css.bottomClose}
796
+ aria-label={t('collapseBottomPanel')}
797
+ onClick={() => { store.reduce(toggleBottomPanel) }}
798
+ >
799
+ <IconCloseFill14 />
800
+ </button>
801
+ </Tooltip>
802
+ <div className={css.panelBody}>
803
+ <Workbench
804
+ state={state}
805
+ tree={state.bottomSplits}
806
+ newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
807
+ actions={actions}
808
+ onNewTab={onNewTab}
809
+ renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
810
+ getTabIcon={tabIconOf}
811
+ />
812
+ </div>
813
+ </div>
814
+ )}
815
+ {/*
816
+ The shared corner (only while BOTH panels are open): the intersection
817
+ of the right panel's left edge and the bottom panel's top edge.
818
+ Horizontal drags resize the right panel's width, vertical drags the
819
+ bottom panel's height — the two panels drag against each other.
820
+ (Never on narrow viewports: the bottom panel does not exist there.)
821
+ */}
822
+ {!narrow && state.panelOpen && state.bottomOpen && (
823
+ <div
824
+ ref={cornerRef}
825
+ className={css.cornerHandle}
826
+ style={{
827
+ left: window.innerWidth - state.width - 6,
828
+ top: window.innerHeight - state.bottomHeight - 6,
829
+ }}
830
+ data-dragging={draggingCorner || undefined}
831
+ onPointerDown={(event) => {
832
+ event.preventDefault()
833
+ event.currentTarget.setPointerCapture(event.pointerId)
834
+ cornerDrag.current = {
835
+ startX: event.clientX,
836
+ startY: event.clientY,
837
+ startWidth: state.width,
838
+ startHeight: state.bottomHeight,
839
+ }
840
+ setDraggingCorner(true)
841
+ }}
842
+ onPointerMove={(event) => {
843
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
844
+ const { startX, startY, startWidth, startHeight } = cornerDrag.current
845
+ const width = clampWidth(startWidth + (startX - event.clientX))
846
+ const height = clampHeight(startHeight + (startY - event.clientY))
847
+ scheduleDrag(width, height)
848
+ }}
849
+ onPointerUp={(event) => {
850
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
851
+ event.currentTarget.releasePointerCapture(event.pointerId)
852
+ const { startX, startY, startWidth, startHeight } = cornerDrag.current
853
+ stopDragScheduling()
854
+ store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
855
+ setDraggingCorner(false)
856
+ }}
857
+ />
858
+ )}
859
+ </>
860
+ )
861
+ }