@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,1175 @@
1
+ /**
2
+ * Per-session sidebar state: the panel geometry, the split-pane workbench
3
+ * tree, open tabs, and the explorer expansion set. One state instance per
4
+ * conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
5
+ * a reload restores the exact layout of the session it belongs to — switching
6
+ * conversations swaps the whole state (memory + isolation).
7
+ *
8
+ * The split tree is a recursive structure: a leaf holds a tab group, a split
9
+ * divides the space row- or column-wise with fractional sizes. All tree
10
+ * operations are pure functions over the node, unit-tested in tests/state.spec.ts.
11
+ */
12
+ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
13
+ import { isNarrowWidth } from './breakpoints.ts'
14
+
15
+ /**
16
+ * Tab type identifier. Builtins register their ids (editor / git / terminal
17
+ * / subagent / browser / diff) through the sidebar service; external
18
+ * plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
19
+ * the registry stays open.
20
+ */
21
+ export type TabType = string
22
+
23
+ /** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
24
+ export type SidebarDiffRef =
25
+ | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
26
+ | { kind: 'commit'; hash: string; hashFull: string; subject: string }
27
+
28
+ /** One open tab. `path` carries the file (editor) or is absent (git/terminal);
29
+ * `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
30
+ * plugin-owned JSON-serializable state, preserved across reloads. */
31
+ export interface SidebarTab {
32
+ id: string
33
+ type: TabType
34
+ title: string
35
+ path?: string
36
+ diff?: SidebarDiffRef
37
+ /** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
38
+ * persisted with the layout and restored verbatim on reload. */
39
+ meta?: unknown
40
+ }
41
+
42
+ /** A tab group. */
43
+ export interface SidebarLeaf {
44
+ kind: 'leaf'
45
+ id: string
46
+ tabs: SidebarTab[]
47
+ active: string | null
48
+ }
49
+
50
+ /** A recursive split between child panes (fractional sizes summing to 1). */
51
+ export interface SidebarSplit {
52
+ kind: 'split'
53
+ id: string
54
+ dir: 'row' | 'col'
55
+ sizes: number[]
56
+ children: SplitNode[]
57
+ }
58
+
59
+ export type SplitNode = SidebarLeaf | SidebarSplit
60
+
61
+ /** The full per-session state. */
62
+ export interface SidebarState {
63
+ panelOpen: boolean
64
+ width: number
65
+ /** The pane receiving newly opened tabs (last pane the user touched).
66
+ * Pane ids are globally unique across BOTH trees (shared uid counter), so
67
+ * one field resolves into either tree — see {@link treeOf}. */
68
+ activePane: string | null
69
+ /** Monotonic terminal tab counter (ids survive reloads). */
70
+ nextTerminal: number
71
+ /** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
72
+ nextBrowser: number
73
+ /** Explorer expansion set (absolute directory paths). */
74
+ expanded: string[]
75
+ /** The right sidebar's split tree (the original workbench). */
76
+ splits: SplitNode
77
+ /** Whether the bottom panel (a second, independent workbench) is open. */
78
+ bottomOpen: boolean
79
+ /** The bottom panel's height (clamped to the contract range). */
80
+ bottomHeight: number
81
+ /**
82
+ * Whether the bottom panel has been expanded at least once in this
83
+ * session — the FIRST expansion tries to auto-open a terminal tab (gated
84
+ * on the bottomPanelAutoTerminal pref); later expansions never do.
85
+ */
86
+ bottomOpenedOnce: boolean
87
+ /** The bottom panel's own split tree (panes/tabs live only in ONE tree;
88
+ * tabs never cross panels — the two panels only share panel-size drags). */
89
+ bottomSplits: SplitNode
90
+ }
91
+
92
+ export const PANEL_MIN = 280
93
+ export const PANEL_MAX = 640
94
+ export const PANEL_DEFAULT = 400
95
+ export const TAB_MAX_WIDTH = 160
96
+ /** Bottom panel geometry contract (mirrors the width contract; the upper
97
+ * bound is the viewport, enforced by {@link setBottomHeight}). */
98
+ export const BOTTOM_MIN = 120
99
+ export const BOTTOM_DEFAULT = 220
100
+
101
+ let nextIdCounter = 0
102
+ /** Unique pane/tab id within one state instance. */
103
+ function uid(prefix: string): string {
104
+ nextIdCounter += 1
105
+ return `${prefix}:${nextIdCounter}`
106
+ }
107
+
108
+ /** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
109
+ * covers openSidebarFile opens (per-path dedupe); opens that must not
110
+ * dedupe (the tree's "open to the side") mint through here. */
111
+ export function mintTabId(): string {
112
+ return uid('tab')
113
+ }
114
+
115
+ /**
116
+ * The largest numeric suffix across a raw persisted state's counter ids
117
+ * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
118
+ * resets on every reload, so a split minted AFTER a reload would collide
119
+ * with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
120
+ * mapLeaf would then visit BOTH leaves and every open would land in both
121
+ * panes of the split. Seeding the counter past the persisted ids keeps
122
+ * fresh ids disjoint.
123
+ */
124
+ function maxCounterId(parsed: unknown): number {
125
+ let max = 0
126
+ const consider = (id: unknown): void => {
127
+ if (typeof id !== 'string') return
128
+ const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
129
+ if (match !== null) max = Math.max(max, Number(match[1]))
130
+ }
131
+ const walk = (node: unknown): void => {
132
+ if (node === null || typeof node !== 'object') return
133
+ const record = node as Record<string, unknown>
134
+ consider(record.id)
135
+ if (Array.isArray(record.tabs)) {
136
+ for (const tab of record.tabs) {
137
+ if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
138
+ }
139
+ }
140
+ if (Array.isArray(record.children)) {
141
+ for (const child of record.children) walk(child)
142
+ }
143
+ }
144
+ walk((parsed as Record<string, unknown> | null)?.splits)
145
+ walk((parsed as Record<string, unknown> | null)?.bottomSplits)
146
+ return max
147
+ }
148
+
149
+ /** The default tab a fresh session seeds. */
150
+ export type DefaultSeed = 'editor-home' | 'none'
151
+
152
+ /** A fresh default state: one seeded tab in one pane, open per the caller's
153
+ * preference. `width` is the caller's preferred panel width (default
154
+ * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
155
+ * true); the store seeds new sessions from the user's side card prefs.
156
+ * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
157
+ * (an editor tab with no path whose tree panel starts open,
158
+ * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
159
+ * file explorer page — and 'none' starts with an empty pane (the store
160
+ * passes it when the user disabled the editor tab type in settings). */
161
+ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
162
+ const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
163
+ if (seed === 'editor-home') {
164
+ // No path: the editor host renders its empty-state hint and the docked
165
+ // tree panel (treeOpen defaults open for path-less tabs; meta pins it).
166
+ leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
167
+ leaf.active = leaf.tabs[0]!.id
168
+ }
169
+ // The bottom panel starts closed with an empty pane (its welcome cards
170
+ // offer the openable types on first use).
171
+ const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
172
+ return {
173
+ panelOpen,
174
+ width,
175
+ activePane: leaf.id,
176
+ nextTerminal: 1,
177
+ nextBrowser: 1,
178
+ expanded: [],
179
+ splits: leaf,
180
+ bottomOpen: false,
181
+ bottomHeight: BOTTOM_DEFAULT,
182
+ bottomOpenedOnce: false,
183
+ bottomSplits: bottomLeaf,
184
+ }
185
+ }
186
+
187
+ /** Whether a tree node (or any descendant) carries the given pane/split id. */
188
+ function treeHasId(node: SplitNode, id: string): boolean {
189
+ if (node.id === id) return true
190
+ if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
191
+ return false
192
+ }
193
+
194
+ /** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
195
+ * bottom panel's tree, else 'splits' (the right panel's tree). Ids are
196
+ * globally unique (the shared uid counter), so an id in neither tree falls
197
+ * back to the right tree, where tree operations no-op on a missing node —
198
+ * the pre-bottom-panel behavior. */
199
+ export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
200
+ return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
201
+ }
202
+
203
+ /** Walk the tree and apply `visit` to the leaf with the given id. */
204
+ export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
205
+ if (node.kind === 'leaf') {
206
+ if (node.id === paneId) {
207
+ const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
208
+ visit(copy)
209
+ return copy
210
+ }
211
+ return node
212
+ }
213
+ const split = node
214
+ return {
215
+ ...split,
216
+ sizes: [...split.sizes],
217
+ children: split.children.map(child => mapLeaf(child, paneId, visit)),
218
+ }
219
+ }
220
+
221
+ /** The first leaf of the tree (fallback pane when activePane is gone). */
222
+ export function firstLeaf(node: SplitNode): SidebarLeaf {
223
+ if (node.kind === 'leaf') return node
224
+ return firstLeaf(node.children[0]!)
225
+ }
226
+
227
+ /** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
228
+ function clearAllTabs(node: SplitNode): SplitNode {
229
+ if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
230
+ return { ...node, children: node.children.map(clearAllTabs) }
231
+ }
232
+
233
+ /**
234
+ * Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
235
+ * right sidebar — the "merged display" on mobile is the right panel alone,
236
+ * whose tab strips now carry the bottom tree's tabs (depth-first order,
237
+ * appended to the right tree's FIRST leaf). The bottom tree is emptied (its
238
+ * structure stays — the desktop bottom panel re-renders its welcome cards)
239
+ * and the panel closes. The active pane moves to the right tree's first
240
+ * leaf so every new tab lands in the visible panel.
241
+ *
242
+ * Idempotent: a bottom tree with no tabs and a closed panel returns the
243
+ * same reference. Runs when the viewport enters narrow (see the Sidebar
244
+ * shell); migrating is permanent for the session — the tabs now live in the
245
+ * right tree, exactly like the user "threw them in".
246
+ */
247
+ export function migrateBottomTabs(state: SidebarState): SidebarState {
248
+ const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
249
+ const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
250
+ if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
251
+ const target = firstLeaf(state.splits)
252
+ return {
253
+ ...state,
254
+ activePane: target.id,
255
+ bottomOpen: false,
256
+ splits: bottomTabs.length > 0
257
+ ? mapLeaf(state.splits, target.id, leaf => {
258
+ leaf.tabs = [...leaf.tabs, ...bottomTabs]
259
+ })
260
+ : state.splits,
261
+ bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
262
+ }
263
+ }
264
+
265
+ /** Find the leaf containing a tab id, if any. */
266
+ export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
267
+ if (node.kind === 'leaf') {
268
+ return node.tabs.some(tab => tab.id === tabId) ? node : undefined
269
+ }
270
+ for (const child of node.children) {
271
+ const found = leafWithTab(child, tabId)
272
+ if (found !== undefined) return found
273
+ }
274
+ return undefined
275
+ }
276
+
277
+ /** All leaves of the tree, depth-first. */
278
+ export function allLeaves(node: SplitNode): SidebarLeaf[] {
279
+ if (node.kind === 'leaf') return [node]
280
+ return node.children.flatMap(allLeaves)
281
+ }
282
+
283
+ /** Whether a tab exists anywhere in a state (either tree, any pane). */
284
+ export function tabOpenIn(state: SidebarState, tabId: string): boolean {
285
+ return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
286
+ || allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
287
+ }
288
+
289
+ /** Replace a leaf with a split of it plus a fresh empty leaf. */
290
+ export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
291
+ const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
292
+ return mapLeaf(node, paneId, (leaf) => {
293
+ const target: SidebarLeaf = { ...leaf }
294
+ const split: SidebarSplit = {
295
+ kind: 'split',
296
+ id: uid('split'),
297
+ dir,
298
+ sizes: [0.5, 0.5],
299
+ children: [target, fresh],
300
+ }
301
+ Object.assign(leaf, split)
302
+ })
303
+ }
304
+
305
+ /**
306
+ * Split a leaf by inserting a fresh leaf holding `tab` beside it — the
307
+ * VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
308
+ * left/right, 'col' for up/down); `front` places the new leaf first (left/
309
+ * up) or second (right/down).
310
+ * @returns the new tree plus the fresh leaf's id (the drop's active pane).
311
+ */
312
+ export function insertLeafAt(
313
+ node: SplitNode,
314
+ paneId: string,
315
+ dir: 'row' | 'col',
316
+ tab: SidebarTab,
317
+ front: boolean,
318
+ ): { node: SplitNode; leafId: string } {
319
+ const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
320
+ const leafId = fresh.id
321
+ const next = mapLeaf(node, paneId, (leaf) => {
322
+ const target: SidebarLeaf = { ...leaf }
323
+ const split: SidebarSplit = {
324
+ kind: 'split',
325
+ id: uid('split'),
326
+ dir,
327
+ sizes: [0.5, 0.5],
328
+ children: front ? [fresh, target] : [target, fresh],
329
+ }
330
+ Object.assign(leaf, split)
331
+ })
332
+ return { node: next, leafId }
333
+ }
334
+
335
+ /** Where a tab drop lands on a pane: an edge creates a split, center merges. */
336
+ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
337
+
338
+ /**
339
+ * The VSCode drag gesture: move a tab out of its pane and either merge it
340
+ * into the target pane (center) or split the target pane with the tab in a
341
+ * fresh leaf (edge). The source pane collapses when it empties.
342
+ *
343
+ * The panes may live in DIFFERENT trees (dragging a tab between the two
344
+ * panels): the tab then leaves its own tree and lands in the other one.
345
+ */
346
+ export function moveTabToEdge(
347
+ state: SidebarState,
348
+ fromPane: string,
349
+ tabId: string,
350
+ toPane: string,
351
+ zone: DropZone,
352
+ ): SidebarState {
353
+ if (fromPane === toPane && zone === 'center') {
354
+ // Dropped back onto its own pane's center: reorder to the end.
355
+ return moveTab(state, fromPane, tabId, toPane, -1)
356
+ }
357
+ const key = treeOf(state, fromPane)
358
+ const toKey = treeOf(state, toPane)
359
+ if (key !== toKey) {
360
+ // Cross-panel drop: remove the tab from its own tree, then merge (center)
361
+ // or split (edge) a pane of the OTHER tree with the tab.
362
+ const source = leafWithTab(state[key], tabId)
363
+ if (source === undefined) return state
364
+ const tab = source.tabs.find(candidate => candidate.id === tabId)!
365
+ let emptied = false
366
+ let sourceNode = mapLeaf(state[key], source.id, (leaf) => {
367
+ leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
368
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
369
+ if (leaf.tabs.length === 0) emptied = true
370
+ })
371
+ if (emptied) sourceNode = removeLeafAt(sourceNode, source.id)
372
+ let targetNode = state[toKey]
373
+ let activePane: string
374
+ if (zone === 'center') {
375
+ targetNode = mapLeaf(targetNode, toPane, (leaf) => {
376
+ leaf.tabs = [...leaf.tabs, tab]
377
+ leaf.active = tab.id
378
+ })
379
+ activePane = toPane
380
+ } else {
381
+ const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
382
+ const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
383
+ targetNode = result.node
384
+ activePane = result.leafId
385
+ }
386
+ return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
387
+ }
388
+ const node = state[key]
389
+ const source = leafWithTab(node, tabId)
390
+ if (source === undefined) return state
391
+ const tab = source.tabs.find(candidate => candidate.id === tabId)!
392
+ let emptied = false
393
+ let splits = mapLeaf(node, source.id, (leaf) => {
394
+ leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
395
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
396
+ if (leaf.tabs.length === 0) emptied = true
397
+ })
398
+ if (emptied) splits = removeLeafAt(splits, source.id)
399
+ if (zone === 'center') {
400
+ splits = mapLeaf(splits, toPane, (leaf) => {
401
+ leaf.tabs = [...leaf.tabs, tab]
402
+ leaf.active = tab.id
403
+ })
404
+ return { ...state, [key]: splits, activePane: toPane }
405
+ }
406
+ const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
407
+ const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
408
+ return { ...state, [key]: result.node, activePane: result.leafId }
409
+ }
410
+
411
+ /**
412
+ * Remove a leaf from the tree. A split left with one child promotes that
413
+ * child; removing the last leaf yields an empty leaf.
414
+ */
415
+ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
416
+ if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
417
+ const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
418
+ if (children.length === node.children.length) {
419
+ return {
420
+ ...node,
421
+ sizes: [...node.sizes],
422
+ children: node.children.map(child => removeLeafAt(child, paneId)),
423
+ }
424
+ }
425
+ if (children.length === 1) return children[0]!
426
+ return { ...node, sizes: [...node.sizes], children }
427
+ }
428
+
429
+ /** Close a tab; an emptied leaf is removed (unless it is the only pane). */
430
+ export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
431
+ const key = treeOf(state, paneId)
432
+ let emptied = false
433
+ const splits = mapLeaf(state[key], paneId, (leaf) => {
434
+ leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
435
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
436
+ if (leaf.tabs.length === 0) emptied = true
437
+ })
438
+ return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
439
+ }
440
+
441
+ /** Activate a tab in its pane (the pane's own tree). */
442
+ export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
443
+ const key = treeOf(state, paneId)
444
+ return {
445
+ ...state,
446
+ activePane: paneId,
447
+ [key]: mapLeaf(state[key], paneId, (leaf) => {
448
+ if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
449
+ }),
450
+ }
451
+ }
452
+
453
+ /** Update the display fields of one open tab (title / path / meta) without
454
+ * re-opening it. The browser tab persists its current URL and hostname
455
+ * title through this reducer so a reload restores the visited page. A
456
+ * missing tab id is a no-op. The tab may live in either tree. */
457
+ export function patchTab(
458
+ state: SidebarState,
459
+ tabId: string,
460
+ patch: { title?: string; path?: string; meta?: unknown },
461
+ ): SidebarState {
462
+ let changed = false
463
+ const walk = (node: SplitNode): SplitNode => {
464
+ if (node.kind === 'leaf') {
465
+ const tabs = node.tabs.map(tab => {
466
+ if (tab.id !== tabId) return tab
467
+ changed = true
468
+ return {
469
+ ...tab,
470
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
471
+ ...(patch.path !== undefined ? { path: patch.path } : {}),
472
+ ...(patch.meta !== undefined ? { meta: patch.meta } : {}),
473
+ }
474
+ })
475
+ return tabs === node.tabs ? node : { ...node, tabs }
476
+ }
477
+ const children = node.children.map(walk)
478
+ return children === node.children ? node : { ...node, children }
479
+ }
480
+ const splits = walk(state.splits)
481
+ const bottomSplits = walk(state.bottomSplits)
482
+ return changed ? { ...state, splits, bottomSplits } : state
483
+ }
484
+
485
+ /**
486
+ * Land a tab in the active pane (or focus its existing instance by id).
487
+ * Dedup strategies (single-instance, per-path, per-change) are owned by the
488
+ * tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
489
+ * this reducer only handles the id-based safety net (reconcile and
490
+ * openDiffTab already check existence before calling) and the landing
491
+ * itself — the service's dedupe path delegates here after its dedupeKey
492
+ * check misses.
493
+ *
494
+ * The active pane may live in EITHER tree (pane ids are globally unique):
495
+ * a stale id that survives in neither tree falls back to the right tree's
496
+ * first pane instead of swallowing the open.
497
+ */
498
+ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
499
+ let targetId = state.activePane ?? firstLeaf(state.splits).id
500
+ // A stale activePane (its pane was closed since) must not swallow the
501
+ // open: fall back to the first pane of the right tree instead of dropping
502
+ // the tab.
503
+ if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
504
+ targetId = firstLeaf(state.splits).id
505
+ }
506
+ const targetKey = treeOf(state, targetId)
507
+ // Id-based safety net: if a tab with the same id exists, focus it.
508
+ for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
509
+ const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
510
+ if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
511
+ }
512
+ return {
513
+ ...state,
514
+ activePane: targetId,
515
+ [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
516
+ leaf.tabs = [...leaf.tabs, tab]
517
+ leaf.active = tab.id
518
+ }),
519
+ }
520
+ }
521
+
522
+ /** Move a tab from one pane to another (insert at index; -1 appends).
523
+ * The panes may live in DIFFERENT trees — dragging a tab between the two
524
+ * panels removes it from its own tree and lands it in the other one. */
525
+ export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
526
+ const fromKey = treeOf(state, fromPane)
527
+ const toKey = treeOf(state, toPane)
528
+ if (fromKey !== toKey) {
529
+ let moved: SidebarTab | undefined
530
+ let emptied = false
531
+ const source = mapLeaf(state[fromKey], fromPane, (leaf) => {
532
+ const found = leaf.tabs.find(tab => tab.id === tabId)
533
+ if (found === undefined) return
534
+ moved = found
535
+ leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
536
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
537
+ if (leaf.tabs.length === 0) emptied = true
538
+ })
539
+ if (moved === undefined) return state
540
+ const target = mapLeaf(state[toKey], toPane, (leaf) => {
541
+ const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
542
+ leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
543
+ leaf.active = moved!.id
544
+ })
545
+ return {
546
+ ...state,
547
+ [fromKey]: emptied ? removeLeafAt(source, fromPane) : source,
548
+ [toKey]: target,
549
+ activePane: toPane,
550
+ }
551
+ }
552
+ let moved: SidebarTab | undefined
553
+ let emptied = false
554
+ let splits = mapLeaf(state[fromKey], fromPane, (leaf) => {
555
+ const found = leaf.tabs.find(tab => tab.id === tabId)
556
+ if (found === undefined) return
557
+ moved = found
558
+ leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
559
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
560
+ if (leaf.tabs.length === 0) emptied = true
561
+ })
562
+ if (moved === undefined) return state
563
+ if (emptied) splits = removeLeafAt(splits, fromPane)
564
+ splits = mapLeaf(splits, toPane, (leaf) => {
565
+ const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
566
+ leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
567
+ leaf.active = moved!.id
568
+ })
569
+ return { ...state, [fromKey]: splits, activePane: toPane }
570
+ }
571
+
572
+ /** Split the active pane (or the pane containing the active tab). */
573
+ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
574
+ const paneId = state.activePane ?? firstLeaf(state.splits).id
575
+ const key = treeOf(state, paneId)
576
+ return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
577
+ }
578
+
579
+ /**
580
+ * Open a diff tab the VSCode way: an existing instance of the same change is
581
+ * focused wherever it lives; otherwise the tab joins the first pane that
582
+ * already holds diff tabs (diff panes are sticky — repeated clicks stack
583
+ * there); on the FIRST diff of a layout the source pane splits vertically so
584
+ * the diff lands in a fresh pane below it ("默认在下半栏新增一个").
585
+ *
586
+ * This is split-tree placement surgery, not registry dispatch: the diff tab
587
+ * descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
588
+ * check below is exactly that rule — the two agree by construction (asserted
589
+ * in tests). Diff tabs minted by the Git view carry change-derived ids, so
590
+ * the id check is the per-change dedupe.
591
+ * @returns the new state, with the diff pane active.
592
+ */
593
+ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
594
+ const existingLeaf = leafWithTab(state.splits, tab.id)
595
+ if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
596
+ const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
597
+ if (diffLeaf !== undefined) {
598
+ return {
599
+ ...state,
600
+ activePane: diffLeaf.id,
601
+ splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
602
+ leaf.tabs = [...leaf.tabs, tab]
603
+ leaf.active = tab.id
604
+ }),
605
+ }
606
+ }
607
+ // First diff: split the source pane, the diff tab in the new LOWER leaf.
608
+ // (A stale sourcePaneId — its pane closed meanwhile — degrades to the
609
+ // regular open path instead of dropping the tab into an orphaned leaf.)
610
+ if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
611
+ return openTabInActivePane(state, tab)
612
+ }
613
+ const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
614
+ return { ...state, splits: result.node, activePane: result.leafId }
615
+ }
616
+
617
+ /** Toggle the panel open/closed (opening restores the previous layout). */
618
+ export function togglePanel(state: SidebarState): SidebarState {
619
+ return { ...state, panelOpen: !state.panelOpen }
620
+ }
621
+
622
+ /** Toggle the bottom panel open/closed (independent of the right panel). */
623
+ export function toggleBottomPanel(state: SidebarState): SidebarState {
624
+ return { ...state, bottomOpen: !state.bottomOpen }
625
+ }
626
+
627
+ /** Set the panel width (clamped to the contract range; the upper bound is
628
+ * the viewport so the fullscreen expansion can fill the window). */
629
+ export function setWidth(state: SidebarState, width: number): SidebarState {
630
+ const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
631
+ return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
632
+ }
633
+
634
+ /** Set the bottom panel height (clamped to the contract range). The upper
635
+ * bound leaves the center column (the agent output area) at least PANEL_MIN
636
+ * tall — without the cap the bottom panel could swallow the whole viewport
637
+ * and squeeze the conversation to zero height. */
638
+ export function setBottomHeight(state: SidebarState, height: number): SidebarState {
639
+ const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
640
+ const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
641
+ return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
642
+ }
643
+
644
+ /** Toggle a directory in the explorer expansion set. */
645
+ export function toggleExpanded(state: SidebarState, path: string): SidebarState {
646
+ const expanded = state.expanded.includes(path)
647
+ ? state.expanded.filter(item => item !== path)
648
+ : [...state.expanded, path]
649
+ return { ...state, expanded }
650
+ }
651
+
652
+ /** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
653
+ export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
654
+ if (node.kind === 'leaf') return node
655
+ if (node.id === splitId) {
656
+ const sizes = [...node.sizes]
657
+ const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
658
+ const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
659
+ sizes[index] = left
660
+ sizes[index + 1] = right
661
+ return { ...node, sizes }
662
+ }
663
+ return {
664
+ ...node,
665
+ sizes: [...node.sizes],
666
+ children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
667
+ }
668
+ }
669
+
670
+ /** State-level {@link resizeSplit} route: the divider may live in either
671
+ * tree (split ids are globally unique). */
672
+ export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
673
+ const key = treeOf(state, splitId)
674
+ return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
675
+ }
676
+
677
+ /** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
678
+ export const AGENT_TAB_PREFIX = 'agent:'
679
+
680
+ /** Whether a tab id refers to an agent-owned terminal. */
681
+ export function isAgentTabId(tabId: string): boolean {
682
+ return tabId.startsWith(AGENT_TAB_PREFIX)
683
+ }
684
+
685
+ /** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
686
+ export function agentUuidOf(tabId: string): string {
687
+ return tabId.slice(AGENT_TAB_PREFIX.length)
688
+ }
689
+
690
+ /** Build the sidebar tab id for one agent terminal uuid. */
691
+ export function agentTabId(uuid: string): string {
692
+ return `${AGENT_TAB_PREFIX}${uuid}`
693
+ }
694
+
695
+ /**
696
+ * Reconcile the sidebar's agent-terminal tabs with the host's live list.
697
+ * The host pushes the current list of agent terminals (created by the model
698
+ * through the `terminal_create` tool) over a dedicated WebSocket; this
699
+ * reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
700
+ * lose theirs. The agent owns the lifetime — the user closing a tab sends a
701
+ * WS close frame that kills the pty, which fires a change, which converges
702
+ * the view. Idempotent: a no-op when the lists already match.
703
+ * @param state - the current per-session sidebar state.
704
+ * @param agentTerminals - the live agent terminal snapshots from the host.
705
+ * @returns the next state (or the same reference if no change was needed).
706
+ */
707
+ export function reconcileAgentTerminals(
708
+ state: SidebarState,
709
+ agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
710
+ ): SidebarState {
711
+ const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
712
+ const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
713
+ const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
714
+ const serverUuids = new Set(agentTerminals.map(t => t.uuid))
715
+ const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
716
+ const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
717
+ if (toAdd.length === 0 && toRemove.length === 0) return state
718
+ // Remove tabs whose uuids vanished from the server list (the agent closed
719
+ // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
720
+ let splits = state.splits
721
+ for (const tab of toRemove) {
722
+ const leaf = leafWithTab(splits, tab.id)
723
+ if (leaf !== undefined) {
724
+ splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
725
+ }
726
+ }
727
+ // Add tabs for new uuids (the agent created a terminal). They land in the
728
+ // active pane via openTabInActivePane; the next reconcile is a no-op for them.
729
+ let next: SidebarState = { ...state, splits }
730
+ for (const terminal of toAdd) {
731
+ const tab: SidebarTab = {
732
+ id: agentTabId(terminal.uuid),
733
+ type: 'terminal',
734
+ title: terminal.title,
735
+ }
736
+ next = openTabInActivePane(next, tab)
737
+ }
738
+ return next
739
+ }
740
+
741
+ // ── The per-session store ──────────────────────────────────────────────────
742
+
743
+ const STORAGE_PREFIX = 'dsh-sidebar:v1'
744
+
745
+ /** Immutable snapshot handed to React (replaced only on real changes). */
746
+ export interface SidebarSnapshot {
747
+ sessionId: string | undefined
748
+ state: SidebarState | undefined
749
+ /**
750
+ * The current side card prefs. Carried IN the snapshot (not a separate
751
+ * subscription) so prefs changes re-render the consumers that gate on
752
+ * them — the + menu hides a tab type the moment its switch flips.
753
+ */
754
+ prefs: SidebarPrefs
755
+ }
756
+
757
+ /** Default panel width for one viewport: the prefs percent of the window,
758
+ * clamped to the panel floor (a tiny percent must stay usable) and to the
759
+ * viewport (a large one must never cover the whole window). */
760
+ export function defaultWidthFor(viewport: number, percent: number): number {
761
+ return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
762
+ }
763
+
764
+ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
765
+ try {
766
+ const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
767
+ if (raw !== null) {
768
+ const parsed = JSON.parse(raw) as unknown
769
+ // Seed the uid counter past the persisted ids (it resets on reload);
770
+ // sanitize re-ids any duplicates the pre-seeding counter left behind.
771
+ nextIdCounter = maxCounterId(parsed)
772
+ const sanitized = sanitizeState(parsed)
773
+ if (sanitized !== undefined) return sanitized
774
+ }
775
+ } catch {
776
+ // Corrupt or unavailable storage: fall through to the default.
777
+ }
778
+ // New sessions seed from the user's side card prefs: the width is the
779
+ // chosen percent of the window (clamped to the panel floor and the
780
+ // viewport so a huge percent can never crush the app shell), the panel
781
+ // starts open only when the preference says so, and the seed tab is the
782
+ // empty files window (tree panel open) in BOTH editorExplorer modes — a
783
+ // disabled editor type seeds nothing. On a NARROW viewport a brand-new
784
+ // session starts collapsed instead — the panel is a full-screen drawer
785
+ // there, and auto-opening it on first paint would cover the conversation
786
+ // before the user asked. Only the first seeding is affected: once the
787
+ // user expands the drawer, `panelOpen: true` persists like any other
788
+ // state.
789
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
790
+ const width = viewport === undefined
791
+ ? PANEL_DEFAULT
792
+ : defaultWidthFor(viewport, prefs.defaultWidthPercent)
793
+ const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
794
+ const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
795
+ return makeDefaultState(width, openByDefault, seed)
796
+ }
797
+
798
+ /**
799
+ * Structural validation of one persisted state. A malformed or stale shape
800
+ * (older layouts, hand-edited storage) must fall back to the default instead
801
+ * of crashing the panel on every reload; the restored width is also clamped
802
+ * to the current viewport so a stale fullscreen width can never crush the
803
+ * app shell (margin-right larger than the window) or cover the whole screen.
804
+ * @returns a clean state, or undefined to fall back to the default.
805
+ */
806
+ export function sanitizeState(parsed: unknown): SidebarState | undefined {
807
+ if (parsed === null || typeof parsed !== 'object') return undefined
808
+ const record = parsed as Record<string, unknown>
809
+ if (typeof record.panelOpen !== 'boolean') return undefined
810
+ if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
811
+ if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
812
+ return undefined
813
+ }
814
+ // nextBrowser arrived in a later build; a missing or malformed value on an
815
+ // OLDER persisted state defaults to 1 so existing layouts keep loading
816
+ // (unlike nextTerminal, which is strict — it predates the v1 shape).
817
+ const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
818
+ ? record.nextBrowser
819
+ : 1
820
+ if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
821
+ if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
822
+ // The seen/reid maps are SHARED across both trees: pane/split ids must be
823
+ // globally unique (the runtime uid counter is shared too), so a duplicate
824
+ // seen first in the right tree gets a fresh id when it reappears in the
825
+ // bottom tree.
826
+ const seen = new Set<string>()
827
+ const reid = new Map<string, string>()
828
+ const restoredSplits = sanitizeNode(record.splits, seen, reid)
829
+ if (restoredSplits === undefined) return undefined
830
+ const splits = pruneEmptyPanes(restoredSplits)
831
+ // Bottom-panel fields arrived in a later build: a missing or malformed
832
+ // value on an OLDER persisted state defaults (closed / default height /
833
+ // empty pane) so existing layouts keep loading, like nextBrowser.
834
+ const bottomOpen = record.bottomOpen === true
835
+ // Cap the persisted height so the center column (the agent output area)
836
+ // keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
837
+ // older build must never squeeze the conversation to zero).
838
+ const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
839
+ const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
840
+ const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
841
+ ? record.bottomHeight
842
+ : BOTTOM_DEFAULT
843
+ const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
844
+ const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
845
+ ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
846
+ const requestedActivePane = typeof record.activePane === 'string'
847
+ ? (reid.get(record.activePane) ?? record.activePane)
848
+ : null
849
+ const activePane = requestedActivePane === null
850
+ ? null
851
+ : treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
852
+ ? requestedActivePane
853
+ : firstLeaf(splits).id
854
+ const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
855
+ return {
856
+ panelOpen: record.panelOpen,
857
+ width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
858
+ // A stale duplicate pane id may have been re-ided; follow the rename so
859
+ // new tabs still land in the pane the user was using.
860
+ activePane,
861
+ nextTerminal: record.nextTerminal,
862
+ nextBrowser,
863
+ expanded: record.expanded as string[],
864
+ splits,
865
+ bottomOpen,
866
+ bottomHeight,
867
+ // An older persisted state never expanded the bottom panel (the field
868
+ // arrived later): defaulting to false gives it the first-expansion
869
+ // auto-terminal exactly once after the upgrade.
870
+ bottomOpenedOnce: record.bottomOpenedOnce === true,
871
+ bottomSplits,
872
+ }
873
+ }
874
+
875
+ /** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
876
+ function pruneEmptyPanes(node: SplitNode): SplitNode {
877
+ const leaves = allLeaves(node)
878
+ if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
879
+ return leaves.reduce(
880
+ (tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
881
+ node,
882
+ )
883
+ }
884
+
885
+ /**
886
+ * One tree node id, deduplicated against the ids already seen in this
887
+ * state. Duplicates are exactly the pre-seeding counter-reset corruption
888
+ * (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
889
+ * keeping both would make mapLeaf visit two leaves at once and every open
890
+ * would land in both panes, so the repeat gets a fresh id.
891
+ * @returns the id to use (the original, or a fresh uid for repeats).
892
+ */
893
+ function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
894
+ if (!seen.has(id)) {
895
+ seen.add(id)
896
+ return id
897
+ }
898
+ const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
899
+ const fresh = uid(prefix)
900
+ seen.add(fresh)
901
+ reid.set(id, fresh)
902
+ return fresh
903
+ }
904
+
905
+ /** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
906
+ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
907
+ if (node === null || typeof node !== 'object') return undefined
908
+ const record = node as Record<string, unknown>
909
+ if (record.kind === 'leaf') {
910
+ if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
911
+ const tabs: SidebarTab[] = []
912
+ let droppedDiff = false
913
+ for (const tab of record.tabs) {
914
+ if (tab === null || typeof tab !== 'object') return undefined
915
+ const candidate = tab as Record<string, unknown>
916
+ if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
917
+ // Diff tabs are ephemeral by nature (like VSCode's diff editors):
918
+ // they never survive a reload, so a stale ref (e.g. a wrong staged
919
+ // side persisted by an older build) cannot resurface as a dead tab
920
+ // showing "no text changes" after every refresh.
921
+ if (candidate.type === 'diff') {
922
+ droppedDiff = true
923
+ continue
924
+ }
925
+ // Tab types are an open set (external plugins register their own);
926
+ // accept any string type here — an unregistered type renders an
927
+ // <OrphanedTab/> at view time and recovers if its plugin loads later.
928
+ if (typeof candidate.type !== 'string') return undefined
929
+ // The standalone explorer tab type merged INTO the editor (the single
930
+ // files window): a persisted explorer tab reopens as an editor home
931
+ // tab — no path, tree panel open (an existing meta object survives).
932
+ if (candidate.type === 'explorer') {
933
+ const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
934
+ ? candidate.meta as Record<string, unknown>
935
+ : undefined
936
+ tabs.push({
937
+ id: candidate.id,
938
+ type: 'editor',
939
+ title: 'Files',
940
+ meta: { treeOpen: true, ...meta },
941
+ })
942
+ continue
943
+ }
944
+ // `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
945
+ // persisted value already went through JSON.parse, so it is inherently
946
+ // serializable — carry it through verbatim (absent on older states).
947
+ tabs.push({
948
+ id: candidate.id,
949
+ type: candidate.type,
950
+ title: candidate.title,
951
+ ...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
952
+ ...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
953
+ })
954
+ }
955
+ const active = typeof record.active === 'string' ? record.active : null
956
+ // An active pointer into a dropped diff tab is expected after the drop;
957
+ // any other missing active is structural corruption → reset the state.
958
+ if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
959
+ return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
960
+ }
961
+ if (record.kind === 'split') {
962
+ if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
963
+ if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
964
+ const children: SplitNode[] = []
965
+ for (const child of record.children) {
966
+ const clean = sanitizeNode(child, seen, reid)
967
+ if (clean === undefined) return undefined
968
+ children.push(clean)
969
+ }
970
+ if (children.length < 2) return undefined
971
+ if (
972
+ record.sizes.length !== children.length
973
+ || record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
974
+ ) {
975
+ return undefined
976
+ }
977
+ return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
978
+ }
979
+ return undefined
980
+ }
981
+
982
+ /** The session-scoped store: one state per conversation, localStorage-backed. */
983
+ export class SidebarStore {
984
+ private readonly bySession = new Map<string, SidebarState>()
985
+ private snapshot: SidebarSnapshot = {
986
+ sessionId: undefined,
987
+ state: undefined,
988
+ prefs: { ...SIDEBAR_PREFS_DEFAULTS },
989
+ }
990
+ private readonly listeners = new Set<() => void>()
991
+ /** Per-session persist debounce timers (v0.12.0+: one per session, so a
992
+ * targeted open never cancels another session's pending write). */
993
+ private readonly persistTimers = new Map<string, number>()
994
+ /** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
995
+ private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
996
+ /**
997
+ * External disable (the dsh-web-ui family's aionui-panel provider choice):
998
+ * while true the sidebar must not mount at all. Not part of the snapshot —
999
+ * nothing renders on it; the mount gate and the intercept predicates read
1000
+ * it directly.
1001
+ */
1002
+ private suspended = false
1003
+
1004
+ /**
1005
+ * Set the external-disable flag (from the settings route) and remember it
1006
+ * for the mount gate and the intercept predicates.
1007
+ */
1008
+ setSuspended(suspended: boolean): void {
1009
+ this.suspended = suspended
1010
+ }
1011
+
1012
+ /** Whether the sidebar is externally disabled (aionui-panel chosen). */
1013
+ getSuspended(): boolean {
1014
+ return this.suspended
1015
+ }
1016
+
1017
+ /**
1018
+ * Replace the side card prefs (the settings RPC result / settings page
1019
+ * write). Notifies like any store change: the snapshot carries the prefs,
1020
+ * so consumers that gate on enable switches (the + menu, derived flows)
1021
+ * re-render with the new values immediately.
1022
+ */
1023
+ setPrefs(prefs: SidebarPrefs): void {
1024
+ this.prefs = { ...prefs }
1025
+ this.snapshot = { ...this.snapshot, prefs: this.prefs }
1026
+ this.notify()
1027
+ }
1028
+
1029
+ /** The current side card prefs (seeds new sessions; persisted states win). */
1030
+ getPrefs(): SidebarPrefs {
1031
+ return { ...this.prefs }
1032
+ }
1033
+
1034
+ /** Select a session (or none); loads its persisted state. */
1035
+ setSession(sessionId: string | undefined): void {
1036
+ if (this.snapshot.sessionId === sessionId) return
1037
+ if (sessionId === undefined) {
1038
+ this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
1039
+ } else {
1040
+ let state = this.bySession.get(sessionId)
1041
+ if (state === undefined) {
1042
+ state = loadState(sessionId, this.prefs)
1043
+ this.bySession.set(sessionId, state)
1044
+ } else {
1045
+ // Cache hit: another session's load/ops may have left the uid
1046
+ // counter below THIS session's persisted ids — re-seed so fresh
1047
+ // pane/split ids can never collide with its tree.
1048
+ nextIdCounter = maxCounterId(state)
1049
+ }
1050
+ this.snapshot = { sessionId, state, prefs: this.prefs }
1051
+ }
1052
+ this.notify()
1053
+ }
1054
+
1055
+ subscribe(listener: () => void): () => void {
1056
+ this.listeners.add(listener)
1057
+ return () => { this.listeners.delete(listener) }
1058
+ }
1059
+
1060
+ getSnapshot(): SidebarSnapshot {
1061
+ return this.snapshot
1062
+ }
1063
+
1064
+ /** Mutate the current session's state (no-op without a session). */
1065
+ update(mutator: (draft: SidebarState) => void): void {
1066
+ const sessionId = this.snapshot.sessionId
1067
+ const state = this.snapshot.state
1068
+ if (sessionId === undefined || state === undefined) return
1069
+ const draft = structuredClone(state)
1070
+ mutator(draft)
1071
+ this.bySession.set(sessionId, draft)
1072
+ this.snapshot = { sessionId, state: draft, prefs: this.prefs }
1073
+ this.schedulePersist(sessionId, draft)
1074
+ this.notify()
1075
+ }
1076
+
1077
+ /**
1078
+ * Whether a tab still exists in its session's state. Views use this on
1079
+ * unmount to tell "the tab was closed" (release the terminal now) from
1080
+ * "the tree re-rendered / the conversation switched" (the tab is still
1081
+ * open — keep the terminal alive through the host's reconnect grace).
1082
+ * Checks the session's own map entry (the current snapshot may already
1083
+ * point at another session when a conversation switch unmounts the old
1084
+ * one's tabs).
1085
+ */
1086
+ tabOpen(sessionId: string, tabId: string): boolean {
1087
+ const state = this.bySession.get(sessionId)
1088
+ ?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
1089
+ return state !== undefined && tabOpenIn(state, tabId)
1090
+ }
1091
+
1092
+ /** Apply a pure reducer (returns the next state). */
1093
+ reduce(reducer: (state: SidebarState) => SidebarState): void {
1094
+ const sessionId = this.snapshot.sessionId
1095
+ const state = this.snapshot.state
1096
+ if (sessionId === undefined || state === undefined) return
1097
+ const next = reducer(state)
1098
+ // A reducer returning the SAME reference means "no change": skip the
1099
+ // persist + notify entirely — strict no-op paths (unknown tab ids,
1100
+ // patchTab on a missing tab) must not churn the state or rewrite
1101
+ // localStorage.
1102
+ if (next === state) return
1103
+ this.bySession.set(sessionId, next)
1104
+ this.snapshot = { sessionId, state: next, prefs: this.prefs }
1105
+ this.schedulePersist(sessionId, next)
1106
+ this.notify()
1107
+ }
1108
+
1109
+ /**
1110
+ * Apply a pure reducer to a TARGET session's state (not the active one),
1111
+ * loading it on demand and persisting the result — WITHOUT switching the
1112
+ * active snapshot or notifying (the UI must not follow along). Used by the
1113
+ * service's targeted `openTab(seed, scope)`: the open lands in the target
1114
+ * session's layout and is visible whenever the user switches to it.
1115
+ */
1116
+ reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
1117
+ // The uid counter is SHARED across sessions, and the ACTIVE session's
1118
+ // safety requires it to never drop below the ids IT minted. Seeding it
1119
+ // from the target's max may LOWER it (a cached target older than the
1120
+ // active session): restoring the pre-call level afterwards keeps the
1121
+ // active session's next mint collision-free — ids minted for the target
1122
+ // only need to exceed the target's own max, which the seed guaranteed.
1123
+ const counterBefore = nextIdCounter
1124
+ let state = this.bySession.get(sessionId)
1125
+ if (state === undefined) {
1126
+ state = loadState(sessionId, this.prefs)
1127
+ this.bySession.set(sessionId, state)
1128
+ } else {
1129
+ // Re-seed the uid counter past THIS session's persisted ids, exactly
1130
+ // like setSession's cache-hit path.
1131
+ nextIdCounter = maxCounterId(state)
1132
+ }
1133
+ const next = reducer(state)
1134
+ // Same-reference result = no change: keep the counter restore (it may
1135
+ // have been seeded down) but skip the write.
1136
+ nextIdCounter = Math.max(nextIdCounter, counterBefore)
1137
+ if (next === state) return
1138
+ this.bySession.set(sessionId, next)
1139
+ this.schedulePersist(sessionId, next)
1140
+ }
1141
+
1142
+ private schedulePersist(sessionId: string, state: SidebarState): void {
1143
+ // Per-session debounce timers: one session's pending write must never
1144
+ // cancel another's (targeted opens schedule writes for INACTIVE
1145
+ // sessions while the active session may already have one pending —
1146
+ // a shared timer would drop the earlier write and the reload would
1147
+ // lose that session's layout).
1148
+ const existing = this.persistTimers.get(sessionId)
1149
+ if (existing !== undefined) window.clearTimeout(existing)
1150
+ const timer = window.setTimeout(() => {
1151
+ this.persistTimers.delete(sessionId)
1152
+ try {
1153
+ localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
1154
+ } catch {
1155
+ // Storage full or unavailable: layout memory is best-effort.
1156
+ }
1157
+ }, 200)
1158
+ this.persistTimers.set(sessionId, timer)
1159
+ }
1160
+
1161
+ private notify(): void {
1162
+ for (const listener of [...this.listeners]) listener()
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Create one sidebar store instance. Production code calls this only from
1168
+ * the client plugin's `apply` (the instance is handed to components as a
1169
+ * prop); tests call it directly. No module-level singleton: the store's
1170
+ * lifetime belongs to the plugin activation, exactly like the official
1171
+ * `createXXXStore()` factory rule.
1172
+ */
1173
+ export function createSidebarStore(): SidebarStore {
1174
+ return new SidebarStore()
1175
+ }