@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,321 @@
1
+ /**
2
+ * Side Chat transcript mapping (browser half): turns a thread child's
3
+ * history rows (`session.history` — the generic RPC, which reads the durable
4
+ * log without activating the child) into compact display rows.
5
+ *
6
+ * A thread child's log starts with the ENTIRE inherited parent log as its
7
+ * fork seed. The mapping therefore cuts everything up to the LAST
8
+ * `session/end-seed` marker and maps context injections (the "Side
9
+ * conversation boundary" prompt, plugin-sourced context) onto a collapsible
10
+ * injection row, so the view shows only the thread's own conversation.
11
+ *
12
+ * Live streaming: `assistant/message` events only land when a step
13
+ * completes, but `assistant/chunk` events stream token-level text and
14
+ * reasoning deltas. The mapping accumulates both per block and supersedes
15
+ * them with the assembled message once it lands (settled rows).
16
+ */
17
+ import type { SidebarHistoryEntry } from '../context-types.ts'
18
+ import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
19
+
20
+ /** One compact transcript row rendered in the thread view. `seq` is the
21
+ * source event's log sequence — stable row identity for React keys across
22
+ * polls (streaming caches ride the key, so window slides must not re-key
23
+ * rows). */
24
+ export type SidechatTranscriptRow =
25
+ | { kind: 'user'; seq: number; text: string }
26
+ /** A context injection (the side boundary prompt + the parked in-progress
27
+ * snapshot, or any plugin-sourced context): rendered as one collapsible
28
+ * row, never as a user bubble. */
29
+ | { kind: 'injection'; seq: number; text: string }
30
+ /** `settled` distinguishes an assembled message from a still-streaming
31
+ * chunk accumulation (streaming rows are superseded by the settle). */
32
+ | { kind: 'assistant'; seq: number; text: string; settled: boolean }
33
+ | { kind: 'reasoning'; seq: number; text: string; settled: boolean }
34
+ | {
35
+ kind: 'tool'
36
+ seq: number
37
+ name: string
38
+ failed: boolean
39
+ /** Raw arguments JSON as the model produced it. */
40
+ args?: string
41
+ /** Plain text of the paired result. */
42
+ resultText?: string
43
+ /** True while the call's result has not landed yet. */
44
+ executing?: boolean
45
+ }
46
+
47
+ /** Extract the visible text of a content-block list (`text` blocks verbatim,
48
+ * joined by blank lines); empty reads `…` so rows never render blank. */
49
+ export function blockText(content: readonly unknown[]): string {
50
+ const parts: string[] = []
51
+ for (const block of content) {
52
+ if (block === null || typeof block !== 'object') continue
53
+ const candidate = block as { type?: unknown; text?: unknown }
54
+ if (candidate.type === 'text' && typeof candidate.text === 'string') {
55
+ parts.push(candidate.text)
56
+ }
57
+ }
58
+ const text = parts.join('\n\n')
59
+ return text === '' ? '…' : text
60
+ }
61
+
62
+ /** Cap for a tool row's one-line argument summary (display only). */
63
+ const ARGS_SUMMARY_MAX = 80
64
+
65
+ /** The most identifying argument keys, in priority order (bash's command,
66
+ * fs tools' paths, search's pattern, …). */
67
+ const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
68
+
69
+ function flatTruncate(text: string): string {
70
+ const flat = text.replace(/\s+/g, ' ').trim()
71
+ return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
72
+ }
73
+
74
+ /**
75
+ * One-line summary of a tool call's raw arguments JSON for the collapsed
76
+ * row: the first identifying string field when the JSON parses, else the
77
+ * flattened raw text; empty when there is nothing worth showing.
78
+ */
79
+ export function toolArgsSummary(args: string | undefined): string {
80
+ if (args === undefined) return ''
81
+ try {
82
+ const parsed = JSON.parse(args) as Record<string, unknown> | null
83
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
84
+ for (const key of ARGS_SUMMARY_KEYS) {
85
+ const value = parsed[key]
86
+ if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
87
+ }
88
+ }
89
+ } catch {
90
+ // Raw text fallthrough.
91
+ }
92
+ return flatTruncate(args)
93
+ }
94
+
95
+ /** The plain text of a tool/result message (text blocks inside its
96
+ * `tool-result` content block). */
97
+ function resultTextOf(data: Record<string, unknown>): string {
98
+ const message = data.message as { content?: unknown } | undefined
99
+ const content = message?.content
100
+ if (!Array.isArray(content)) return ''
101
+ const parts: string[] = []
102
+ for (const block of content) {
103
+ if (block === null || typeof block !== 'object') continue
104
+ const candidate = block as { type?: unknown; content?: unknown }
105
+ if (candidate.type !== 'tool-result') continue
106
+ const inner = candidate.content
107
+ if (!Array.isArray(inner)) continue
108
+ for (const item of inner) {
109
+ if (item === null || typeof item !== 'object') continue
110
+ const textItem = item as { type?: unknown; text?: unknown }
111
+ if (textItem.type === 'text' && typeof textItem.text === 'string') {
112
+ parts.push(textItem.text)
113
+ }
114
+ }
115
+ }
116
+ return parts.join('\n')
117
+ }
118
+
119
+ /** Index of the last `session/end-seed` event (fork seed marker), or -1. */
120
+ function lastSeedEnd(events: readonly { type: string }[]): number {
121
+ for (let index = events.length - 1; index >= 0; index--) {
122
+ if (events[index]?.type === 'session/end-seed') return index
123
+ }
124
+ return -1
125
+ }
126
+
127
+ /**
128
+ * Collect the thread's OWN events on first attach: walk backward from the
129
+ * log tail (oldest-first accumulation) until the `session/end-seed` marker
130
+ * surfaces, then keep everything after it.
131
+ *
132
+ * Page size matters: cold reads re-expand persisted chunk-rows into one
133
+ * `assistant/chunk` event per delta, so a single streamed answer can be
134
+ * HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
135
+ * earlier `tool/call` events fall out of the loaded window — the tool rows
136
+ * vanished on re-entry while the settled text survived. The walk therefore
137
+ * pages big; tail polls stay small.
138
+ *
139
+ * Exhaustion (log start reached without a marker — a thread created before
140
+ * seeding existed, or a pathological log) returns `seedBoundary: 0` so the
141
+ * caller stops re-walking and renders the window as-is.
142
+ *
143
+ * @param fetchPage - one history page (newest-first window ending at
144
+ * `beforeSeq`, exclusive; omit for the tail page).
145
+ * @param pageCap - safety bound on backward pages.
146
+ */
147
+ export async function collectOwnEvents(
148
+ fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
149
+ pageCap = 40,
150
+ ): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
151
+ const collected: SidebarHistoryEntry[] = []
152
+ let beforeSeq: number | undefined
153
+ for (let page = 0; page < pageCap; page++) {
154
+ const events = await fetchPage(beforeSeq)
155
+ if (events.length === 0) {
156
+ // Log start reached without a marker: the window IS the whole log.
157
+ return { seedBoundary: 0, entries: collected }
158
+ }
159
+ const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
160
+ const fresh = olderThan === undefined
161
+ ? [...events]
162
+ : events.filter(entry => entry.event.seq < olderThan)
163
+ const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
164
+ if (seedEnd >= 0) {
165
+ collected.unshift(...fresh.slice(seedEnd + 1))
166
+ return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
167
+ }
168
+ collected.unshift(...fresh)
169
+ if (fresh.length === 0) {
170
+ // The page overlaps entirely with what we have: nothing older exists.
171
+ return { seedBoundary: 0, entries: collected }
172
+ }
173
+ beforeSeq = fresh[0]!.event.seq
174
+ }
175
+ // Cap hit: accept the window (it is overwhelmingly the thread's own tail)
176
+ // rather than re-walking on every poll.
177
+ return { seedBoundary: 0, entries: collected }
178
+ }
179
+
180
+ /**
181
+ * Map a thread child's history rows onto compact transcript rows: the
182
+ * inherited fork seed is cut at the last `session/end-seed`, context
183
+ * injections map onto a collapsible injection row, `assistant/chunk`
184
+ * deltas accumulate into streaming rows per (turn, step, block) and are
185
+ * superseded by the assembled `assistant/message`, and tool invocations
186
+ * render one expandable line each (arguments, paired result text, failure
187
+ * marker; a still-executing call is marked until its result lands).
188
+ * @param entries - history rows (event + host-computed view) in seq order.
189
+ * @returns display rows in log order.
190
+ */
191
+ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
192
+ const events = entries.map(entry => entry.event)
193
+ const seedEnd = lastSeedEnd(events)
194
+ const rows: SidechatTranscriptRow[] = []
195
+ /** (turn, step, index, kind) key → index of its accumulating stream row. */
196
+ const streamRows = new Map<string, number>()
197
+ /** tool callId → index of its tool row in `rows` (result pairing). */
198
+ const callRows = new Map<string, number>()
199
+ for (let index = 0; index < events.length; index++) {
200
+ if (index <= seedEnd) continue
201
+ const event = events[index]
202
+ if (event === undefined) continue
203
+ const data = event.data as Record<string, unknown>
204
+ switch (event.type) {
205
+ case 'user/message': {
206
+ const text = blockText(Array.isArray(data.content) ? data.content : [])
207
+ // Context injections (the boundary prompt + snapshot, plugin-sourced
208
+ // context) collapse into an injection row; genuine user messages —
209
+ // including the FIRST one, which the host now delivers as its own
210
+ // event — render as user rows.
211
+ if (isContextInjectionMessage(data)) {
212
+ const source = data.source as { kind?: unknown } | undefined
213
+ // Threads logged BEFORE the host split carry boundary(+snapshot)+
214
+ // question in ONE 'user' message. The boundary prompt is a known
215
+ // constant, so the message splits THERE: the injection row keeps
216
+ // the prompt, the remainder (snapshot + question if any — pure
217
+ // question in the common case) renders as the user's real message.
218
+ if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
219
+ rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
220
+ const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
221
+ if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
222
+ break
223
+ }
224
+ rows.push({ kind: 'injection', seq: event.seq, text })
225
+ break
226
+ }
227
+ rows.push({ kind: 'user', seq: event.seq, text })
228
+ break
229
+ }
230
+ case 'assistant/chunk': {
231
+ const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
232
+ if (chunk === null || typeof chunk !== 'object') break
233
+ const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
234
+ if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
235
+ const turn = data.turn
236
+ const step = data.step
237
+ const blockIndex = (chunk as { index?: unknown }).index
238
+ const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
239
+ const existing = streamRows.get(key)
240
+ if (existing !== undefined) {
241
+ const row = rows[existing]
242
+ if (row !== undefined && row.kind === kind && !row.settled) {
243
+ rows[existing] = { ...row, text: row.text + chunk.text }
244
+ }
245
+ } else {
246
+ streamRows.set(key, rows.length)
247
+ rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
248
+ }
249
+ break
250
+ }
251
+ case 'assistant/message': {
252
+ const prefix = `${String(data.turn)}:${String(data.step)}:`
253
+ const streamed = [...streamRows.entries()]
254
+ .filter(([key]) => key.startsWith(prefix))
255
+ .map(([, rowIndex]) => rowIndex)
256
+ for (const key of [...streamRows.keys()]) {
257
+ if (key.startsWith(prefix)) streamRows.delete(key)
258
+ }
259
+ const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
260
+ ? (data.message as { content: readonly unknown[] }).content
261
+ : []
262
+ const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
263
+ if (block === null || typeof block !== 'object') return []
264
+ const candidate = block as { type?: unknown; text?: unknown }
265
+ if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
266
+ return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
267
+ }
268
+ if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
269
+ return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
270
+ }
271
+ return []
272
+ })
273
+ if (streamed.length === 0) rows.push(...settled)
274
+ else rows.splice(Math.min(...streamed), streamed.length, ...settled)
275
+ break
276
+ }
277
+ case 'tool/call': {
278
+ const callId = data.callId
279
+ const name = typeof data.name === 'string' ? data.name : 'tool'
280
+ const args = typeof data.arguments === 'string' ? data.arguments : undefined
281
+ const rowIndex = rows.length
282
+ if (typeof callId === 'string') callRows.set(callId, rowIndex)
283
+ rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
284
+ break
285
+ }
286
+ case 'tool/result': {
287
+ const source = data.message as { source?: { callId?: unknown } } | undefined
288
+ const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
289
+ const rowIndex = callId === undefined ? undefined : callRows.get(callId)
290
+ const failed = data.error !== undefined
291
+ const resultText = resultTextOf(data)
292
+ if (rowIndex !== undefined) {
293
+ const row = rows[rowIndex]
294
+ if (row !== undefined && row.kind === 'tool') {
295
+ rows[rowIndex] = {
296
+ ...row,
297
+ failed: row.failed || failed,
298
+ resultText: resultText === '' ? row.resultText : resultText,
299
+ executing: false,
300
+ }
301
+ }
302
+ } else if (failed || resultText !== '') {
303
+ // Orphan result (no call row in the window): surface it so the row
304
+ // stays informative and expandable.
305
+ rows.push({
306
+ kind: 'tool',
307
+ seq: event.seq,
308
+ name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
309
+ failed,
310
+ resultText: resultText === '' ? undefined : resultText,
311
+ })
312
+ }
313
+ break
314
+ }
315
+ default: {
316
+ break
317
+ }
318
+ }
319
+ }
320
+ return rows
321
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The split-pane workbench: renders the recursive split tree. A split lays
3
+ * children out row- or column-wise with draggable dividers (fractional
4
+ * sizes); a leaf renders its tab strip plus the active tab's content.
5
+ *
6
+ * Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
7
+ * over a pane, a drop overlay shows five zones — four edges (left/right/up/
8
+ * down) that split the pane with the tab in a fresh leaf, and the center
9
+ * that merges the tab into the pane. The tree and all operations live in
10
+ * state.ts; this file is pure presentation over them.
11
+ */
12
+ import { Fragment, useEffect, useRef, useState } from 'react'
13
+ import type { ReactNode } from 'react'
14
+ import clsx from 'clsx'
15
+ import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
16
+ import type { DropZone } from './state.ts'
17
+ import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
18
+ import { createFrameBatcher } from './frame-batcher.ts'
19
+ import css from './sidebar.module.css'
20
+
21
+ /** Actions the workbench needs (bound to the store by the sidebar shell). */
22
+ export interface WorkbenchActions {
23
+ closeTab: (paneId: string, tabId: string) => void
24
+ activateTab: (paneId: string, tabId: string) => void
25
+ /** Make a pane the target of newly opened tabs (click focus). */
26
+ focusPane: (paneId: string) => void
27
+ /** VSCode drag gesture: edge → split the target pane, center → merge. */
28
+ moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
29
+ /** Reorder within a pane (drop onto another tab inserts before it). */
30
+ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
31
+ resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
32
+ }
33
+
34
+ /** One divider: pointer-capture drag translating px deltas into fractions.
35
+ * Deltas are incremental — each move reports the displacement since the
36
+ * previous move — because the store adds every reported delta to the pane
37
+ * sizes; a cumulative (since-pointer-down) delta would be re-added on each
38
+ * move and the divider would run away from the cursor.
39
+ *
40
+ * The moves are BATCHED per frame (createFrameBatcher): a pointer stream
41
+ * fires faster than the display refresh, and applying each move is a store
42
+ * reduce that re-renders both workbenches (terminals, editors, trees) per
43
+ * event — the visible drag lag on slower CPUs (#315). The batch accumulates
44
+ * the incremental deltas in a ref and applies the summed fraction at most
45
+ * once per frame; the sum equals what the per-event application would have
46
+ * produced (the reducer clamps each application, and at a settled position
47
+ * a clamped sum is clamped to the same boundary), so the result is
48
+ * indistinguishable at rest and at most one frame behind the cursor.
49
+ */
50
+ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
51
+ const { dir, onResize } = props
52
+ // `last` is the previous pointer position while dragging; `size` is the
53
+ // container size at pointer-down (constant during the drag), used to
54
+ // normalize the px delta into a fraction.
55
+ const last = useRef({ x: 0, y: 0, size: 0 })
56
+ const [dragging, setDragging] = useState(false)
57
+ const pendingDelta = useRef(0)
58
+ const batcher = useRef(createFrameBatcher()).current
59
+ useEffect(() => () => batcher.dispose(), [batcher])
60
+
61
+ return (
62
+ <div
63
+ className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
64
+ onPointerDown={(event) => {
65
+ event.preventDefault()
66
+ event.currentTarget.setPointerCapture(event.pointerId)
67
+ const box = event.currentTarget.parentElement?.getBoundingClientRect()
68
+ last.current = {
69
+ x: event.clientX,
70
+ y: event.clientY,
71
+ size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
72
+ }
73
+ setDragging(true)
74
+ }}
75
+ onPointerMove={(event) => {
76
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
77
+ const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
78
+ pendingDelta.current += delta
79
+ batcher.schedule(() => {
80
+ const accumulated = pendingDelta.current
81
+ pendingDelta.current = 0
82
+ if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
83
+ })
84
+ last.current.x = event.clientX
85
+ last.current.y = event.clientY
86
+ }}
87
+ onPointerUp={(event) => {
88
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
89
+ // Flush any pending baked delta so the final pointer position lands
90
+ // (the release event is not a move; dropping it would lose the tail).
91
+ batcher.flushNow()
92
+ event.currentTarget.releasePointerCapture(event.pointerId)
93
+ setDragging(false)
94
+ }}
95
+ />
96
+ )
97
+ }
98
+
99
+ /** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
100
+ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
101
+ const rect = pane.getBoundingClientRect()
102
+ if (rect.width === 0 || rect.height === 0) return 'center'
103
+ const x = (event.clientX - rect.left) / rect.width
104
+ const y = (event.clientY - rect.top) / rect.height
105
+ if (x < 0.25) return 'left'
106
+ if (x > 0.75) return 'right'
107
+ if (y < 0.25) return 'up'
108
+ if (y > 0.75) return 'down'
109
+ return 'center'
110
+ }
111
+
112
+ /** The icon of one openable type card (mirror of the + menu options). */
113
+ /**
114
+ * An empty pane's welcome cards: the openable types as cards, clicked to
115
+ * open (instead of a bare "this pane is empty" message).
116
+ */
117
+ function PaneEmptyCards(props: {
118
+ newTabOptions: NewTabOption[]
119
+ onNewTab: (optionId: string) => void
120
+ }) {
121
+ const { newTabOptions, onNewTab } = props
122
+ return (
123
+ <div className={css.paneEmptyCards}>
124
+ {newTabOptions.map(option => (
125
+ <button
126
+ key={option.id}
127
+ type="button"
128
+ className={css.paneCard}
129
+ disabled={option.disabled === true}
130
+ title={option.label}
131
+ onClick={() => { onNewTab(option.id) }}
132
+ >
133
+ {option.icon ?? null}
134
+ <span>{option.label}</span>
135
+ </button>
136
+ ))}
137
+ </div>
138
+ )
139
+ }
140
+
141
+ /** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
142
+ function LeafView(props: {
143
+ leaf: { id: string; tabs: SidebarTab[]; active: string | null }
144
+ newTabOptions: NewTabOption[]
145
+ actions: WorkbenchActions
146
+ onNewTab: (optionId: string) => void
147
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
148
+ getTabIcon?: (tab: SidebarTab) => ReactNode
149
+ getTabBadge?: (tab: SidebarTab) => ReactNode
150
+ }) {
151
+ const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
152
+ const [dropZone, setDropZone] = useState<DropZone | null>(null)
153
+ const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
154
+
155
+ useEffect(() => {
156
+ const clear = (): void => { setDropZone(null) }
157
+ window.addEventListener('dragend', clear, true)
158
+ window.addEventListener('drop', clear, true)
159
+ window.addEventListener('blur', clear)
160
+ return () => {
161
+ window.removeEventListener('dragend', clear, true)
162
+ window.removeEventListener('drop', clear, true)
163
+ window.removeEventListener('blur', clear)
164
+ }
165
+ }, [])
166
+
167
+ return (
168
+ <div
169
+ className={clsx(css.pane, dropZone !== null && css.paneDrop)}
170
+ onPointerDown={() => { actions.focusPane(leaf.id) }}
171
+ onDragOver={(event) => {
172
+ event.preventDefault()
173
+ const zone = zoneAt(event, event.currentTarget)
174
+ setDropZone(zone)
175
+ }}
176
+ onDragLeave={(event) => {
177
+ // Only clear when the pointer left the pane entirely (not onto a child).
178
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
179
+ setDropZone(null)
180
+ }
181
+ }}
182
+ onDrop={(event) => {
183
+ event.preventDefault()
184
+ const zone = dropZone ?? zoneAt(event, event.currentTarget)
185
+ setDropZone(null)
186
+ const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
187
+ if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
188
+ }}
189
+ >
190
+ {dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
191
+ {/*
192
+ The tab strip renders even for an empty pane: the + menu must stay
193
+ reachable when the pane has no tabs (fresh split, or the last tab was
194
+ dragged out), so a new tab can always be created or dragged in.
195
+ */}
196
+ <TabBar
197
+ paneId={leaf.id}
198
+ tabs={leaf.tabs}
199
+ active={leaf.active}
200
+ onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
201
+ onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
202
+ onNewTab={onNewTab}
203
+ newTabOptions={newTabOptions}
204
+ getTabIcon={getTabIcon}
205
+ getTabBadge={getTabBadge}
206
+ onDropTab={(payload, before) => {
207
+ if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
208
+ else actions.moveTabBefore(payload, leaf.id, before)
209
+ }}
210
+ />
211
+ {leaf.tabs.length > 0 ? (
212
+ /*
213
+ Every tab stays MOUNTED (inactive ones hidden), so switching tabs
214
+ never tears down the content: a terminal keeps its pty connection
215
+ and scrollback, an editor keeps its CodeMirror view and unsaved
216
+ draft, explorer/git keep their loaded data. The unmount (and the
217
+ terminal's close frame) happens only when a tab is truly closed.
218
+ */
219
+ <div className={css.paneContent}>
220
+ {leaf.tabs.map(tab => (
221
+ <div
222
+ key={tab.id}
223
+ className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
224
+ >
225
+ {renderTab(tab, tab.id === activeTab?.id, leaf.id)}
226
+ </div>
227
+ ))}
228
+ </div>
229
+ ) : (
230
+ <PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
231
+ )}
232
+ </div>
233
+ )
234
+ }
235
+
236
+ /** Recursive node renderer. */
237
+ function NodeView(props: {
238
+ node: SplitNode
239
+ state: SidebarState
240
+ newTabOptions: NewTabOption[]
241
+ actions: WorkbenchActions
242
+ onNewTab: (optionId: string) => void
243
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
244
+ getTabIcon?: (tab: SidebarTab) => ReactNode
245
+ getTabBadge?: (tab: SidebarTab) => ReactNode
246
+ }) {
247
+ const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
248
+ if (node.kind === 'leaf') {
249
+ return (
250
+ <LeafView
251
+ leaf={node}
252
+ newTabOptions={newTabOptions}
253
+ actions={actions}
254
+ onNewTab={onNewTab}
255
+ renderTab={renderTab}
256
+ getTabIcon={getTabIcon}
257
+ getTabBadge={getTabBadge}
258
+ />
259
+ )
260
+ }
261
+ const isRow = node.dir === 'row'
262
+ return (
263
+ <div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
264
+ {node.children.map((child, index) => (
265
+ <Fragment key={child.id}>
266
+ {index > 0 && (
267
+ <Divider
268
+ dir={node.dir}
269
+ onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
270
+ />
271
+ )}
272
+ <div
273
+ className={css.splitChild}
274
+ style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
275
+ >
276
+ <NodeView
277
+ node={child}
278
+ state={state}
279
+ newTabOptions={newTabOptions}
280
+ actions={actions}
281
+ onNewTab={onNewTab}
282
+ renderTab={renderTab}
283
+ getTabIcon={getTabIcon}
284
+ getTabBadge={getTabBadge}
285
+ />
286
+ </div>
287
+ </Fragment>
288
+ ))}
289
+ </div>
290
+ )
291
+ }
292
+
293
+ /** The workbench: the split tree filling the sidebar body. `tree` selects
294
+ * which tree renders (the right panel's by default; the bottom panel passes
295
+ * `state.bottomSplits` — the actions route by pane id, so one action set
296
+ * serves both). */
297
+ export function Workbench(props: {
298
+ state: SidebarState
299
+ tree?: SplitNode
300
+ newTabOptions: NewTabOption[]
301
+ actions: WorkbenchActions
302
+ onNewTab: (optionId: string) => void
303
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
304
+ getTabIcon?: (tab: SidebarTab) => ReactNode
305
+ getTabBadge?: (tab: SidebarTab) => ReactNode
306
+ }) {
307
+ const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
308
+ return (
309
+ <div className={css.workbench}>
310
+ <NodeView
311
+ node={tree ?? state.splits}
312
+ state={state}
313
+ newTabOptions={newTabOptions}
314
+ actions={actions}
315
+ onNewTab={onNewTab}
316
+ renderTab={renderTab}
317
+ getTabIcon={getTabIcon}
318
+ getTabBadge={getTabBadge}
319
+ />
320
+ </div>
321
+ )
322
+ }