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