dsh-coding-sidebar 1.0.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 (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +524 -0
  3. package/cordis.patch.yml +49 -0
  4. package/lib/client-editor.js +48229 -0
  5. package/lib/client-mermaid.js +200745 -0
  6. package/lib/client-registry.js +21867 -0
  7. package/lib/client-terminal.js +9361 -0
  8. package/lib/client.js +21867 -0
  9. package/lib/index.js +4556 -0
  10. package/lib/invariant.js +22 -0
  11. package/lib/types/agent-opens.d.ts +65 -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 +39 -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 +13 -0
  19. package/lib/types/client/FileTree.d.ts +38 -0
  20. package/lib/types/client/FreeWindow.d.ts +29 -0
  21. package/lib/types/client/GitView.d.ts +10 -0
  22. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  23. package/lib/types/client/OrphanedTab.d.ts +11 -0
  24. package/lib/types/client/PdfView.d.ts +6 -0
  25. package/lib/types/client/RenderBoundary.d.ts +32 -0
  26. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  27. package/lib/types/client/SideCardSection.d.ts +74 -0
  28. package/lib/types/client/SideChatView.d.ts +16 -0
  29. package/lib/types/client/Sidebar.d.ts +6 -0
  30. package/lib/types/client/SubagentView.d.ts +16 -0
  31. package/lib/types/client/TabBar.d.ts +54 -0
  32. package/lib/types/client/TerminalView.d.ts +24 -0
  33. package/lib/types/client/TextEditor.d.ts +10 -0
  34. package/lib/types/client/TreePanel.d.ts +24 -0
  35. package/lib/types/client/UploadOverlay.d.ts +20 -0
  36. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  37. package/lib/types/client/api.d.ts +275 -0
  38. package/lib/types/client/binary-download.d.ts +12 -0
  39. package/lib/types/client/breakpoints.d.ts +17 -0
  40. package/lib/types/client/browser.d.ts +60 -0
  41. package/lib/types/client/builtins/index.d.ts +18 -0
  42. package/lib/types/client/builtins/tabs.d.ts +11 -0
  43. package/lib/types/client/builtins/viewers.d.ts +3 -0
  44. package/lib/types/client/chunk-loader.d.ts +131 -0
  45. package/lib/types/client/chunks/editor.d.ts +9 -0
  46. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  47. package/lib/types/client/chunks/terminal.d.ts +9 -0
  48. package/lib/types/client/cm-themes.d.ts +24 -0
  49. package/lib/types/client/conversation-draft.d.ts +14 -0
  50. package/lib/types/client/desktop-env.d.ts +35 -0
  51. package/lib/types/client/editor-load.d.ts +66 -0
  52. package/lib/types/client/frame-batcher.d.ts +24 -0
  53. package/lib/types/client/icons.d.ts +59 -0
  54. package/lib/types/client/image-types.d.ts +3 -0
  55. package/lib/types/client/ime-guard.d.ts +36 -0
  56. package/lib/types/client/index.d.ts +25 -0
  57. package/lib/types/client/intercept.d.ts +46 -0
  58. package/lib/types/client/lang.d.ts +14 -0
  59. package/lib/types/client/layout-push.d.ts +15 -0
  60. package/lib/types/client/lazy-chunk.d.ts +25 -0
  61. package/lib/types/client/link-intercept.d.ts +42 -0
  62. package/lib/types/client/locales-ar.d.ts +18 -0
  63. package/lib/types/client/locales-de.d.ts +4 -0
  64. package/lib/types/client/locales-fr.d.ts +10 -0
  65. package/lib/types/client/locales-hi.d.ts +17 -0
  66. package/lib/types/client/locales-id.d.ts +15 -0
  67. package/lib/types/client/locales-it.d.ts +8 -0
  68. package/lib/types/client/locales-ja.d.ts +17 -0
  69. package/lib/types/client/locales-ko.d.ts +9 -0
  70. package/lib/types/client/locales-nl.d.ts +16 -0
  71. package/lib/types/client/locales-pl.d.ts +19 -0
  72. package/lib/types/client/locales-pt.d.ts +1 -0
  73. package/lib/types/client/locales-ru.d.ts +13 -0
  74. package/lib/types/client/locales-sv.d.ts +1 -0
  75. package/lib/types/client/locales-th.d.ts +17 -0
  76. package/lib/types/client/locales-tr.d.ts +17 -0
  77. package/lib/types/client/locales-vi.d.ts +17 -0
  78. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  79. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  80. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  81. package/lib/types/client/locales.d.ts +452 -0
  82. package/lib/types/client/markdown-html.d.ts +96 -0
  83. package/lib/types/client/markdown-images.d.ts +45 -0
  84. package/lib/types/client/markdown-labels.d.ts +38 -0
  85. package/lib/types/client/md-toc.d.ts +4 -0
  86. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  87. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  88. package/lib/types/client/mermaid.d.ts +12 -0
  89. package/lib/types/client/open-when-sized.d.ts +20 -0
  90. package/lib/types/client/open-with-settings.d.ts +4 -0
  91. package/lib/types/client/open-with.d.ts +87 -0
  92. package/lib/types/client/openpath-intercept.d.ts +85 -0
  93. package/lib/types/client/paths.d.ts +43 -0
  94. package/lib/types/client/pdf-types.d.ts +2 -0
  95. package/lib/types/client/pinned.d.ts +86 -0
  96. package/lib/types/client/plugin-settings.d.ts +8 -0
  97. package/lib/types/client/plugins-shared.d.ts +27 -0
  98. package/lib/types/client/plugins-tabs.d.ts +3 -0
  99. package/lib/types/client/plugins-viewers.d.ts +3 -0
  100. package/lib/types/client/prefs.d.ts +41 -0
  101. package/lib/types/client/produced-files.d.ts +29 -0
  102. package/lib/types/client/selection-payload.d.ts +27 -0
  103. package/lib/types/client/service.d.ts +439 -0
  104. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  105. package/lib/types/client/shell-presets.d.ts +48 -0
  106. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  107. package/lib/types/client/split-pane.d.ts +36 -0
  108. package/lib/types/client/state.d.ts +432 -0
  109. package/lib/types/client/subagent-detect.d.ts +54 -0
  110. package/lib/types/client/subagent-jobs.d.ts +63 -0
  111. package/lib/types/client/tab-content-memo.d.ts +40 -0
  112. package/lib/types/client/terminal-font.d.ts +78 -0
  113. package/lib/types/client/terminal-links.d.ts +119 -0
  114. package/lib/types/client/theme.d.ts +45 -0
  115. package/lib/types/client/titlebar-strip.d.ts +23 -0
  116. package/lib/types/client/upload.d.ts +69 -0
  117. package/lib/types/client/wco.d.ts +47 -0
  118. package/lib/types/config.d.ts +64 -0
  119. package/lib/types/context-types.d.ts +605 -0
  120. package/lib/types/fs-operations.d.ts +28 -0
  121. package/lib/types/fs-search.d.ts +24 -0
  122. package/lib/types/fs-tree.d.ts +51 -0
  123. package/lib/types/git.d.ts +124 -0
  124. package/lib/types/html-route.d.ts +59 -0
  125. package/lib/types/index.d.ts +47 -0
  126. package/lib/types/invariant.d.ts +15 -0
  127. package/lib/types/jobs-routes.d.ts +45 -0
  128. package/lib/types/open-external.d.ts +24 -0
  129. package/lib/types/path-security.d.ts +20 -0
  130. package/lib/types/prefs-shared.d.ts +236 -0
  131. package/lib/types/pty-deps.d.ts +78 -0
  132. package/lib/types/pty-manager.d.ts +168 -0
  133. package/lib/types/sidechat-core.d.ts +172 -0
  134. package/lib/types/sidechat-routes.d.ts +30 -0
  135. package/lib/types/subagent-activity.d.ts +44 -0
  136. package/lib/types/subagent-live-route.d.ts +44 -0
  137. package/lib/types/tools.d.ts +34 -0
  138. package/lib/types/trust-fence.d.ts +24 -0
  139. package/lib/types/wire.d.ts +38 -0
  140. package/package.json +172 -0
  141. package/scripts/install.ps1 +306 -0
  142. package/scripts/install.sh +287 -0
  143. package/src/agent-opens.ts +265 -0
  144. package/src/agent-pty.ts +527 -0
  145. package/src/browser-probe.ts +26 -0
  146. package/src/bundle-route.ts +129 -0
  147. package/src/client/BrowserView.tsx +289 -0
  148. package/src/client/DiffTab.tsx +114 -0
  149. package/src/client/DiffView.tsx +305 -0
  150. package/src/client/EditorHost.tsx +558 -0
  151. package/src/client/FileTree.tsx +669 -0
  152. package/src/client/FreeWindow.tsx +337 -0
  153. package/src/client/GitView.tsx +717 -0
  154. package/src/client/MarkdownHtml.tsx +296 -0
  155. package/src/client/OrphanedTab.tsx +27 -0
  156. package/src/client/PdfView.tsx +110 -0
  157. package/src/client/RenderBoundary.tsx +49 -0
  158. package/src/client/SandboxStatusBar.tsx +60 -0
  159. package/src/client/SideCardSection.module.css +960 -0
  160. package/src/client/SideCardSection.tsx +1163 -0
  161. package/src/client/SideChatView.module.css +437 -0
  162. package/src/client/SideChatView.tsx +645 -0
  163. package/src/client/Sidebar.tsx +1370 -0
  164. package/src/client/SubagentView.module.css +547 -0
  165. package/src/client/SubagentView.tsx +883 -0
  166. package/src/client/TabBar.tsx +365 -0
  167. package/src/client/TerminalView.tsx +424 -0
  168. package/src/client/TextEditor.tsx +468 -0
  169. package/src/client/TreePanel.tsx +275 -0
  170. package/src/client/UploadOverlay.tsx +62 -0
  171. package/src/client/add-plugin-modal.tsx +199 -0
  172. package/src/client/api.ts +357 -0
  173. package/src/client/binary-download.tsx +23 -0
  174. package/src/client/breakpoints.ts +61 -0
  175. package/src/client/browser.ts +167 -0
  176. package/src/client/builtins/index.ts +37 -0
  177. package/src/client/builtins/tabs.tsx +340 -0
  178. package/src/client/builtins/viewers.tsx +125 -0
  179. package/src/client/chunk-loader.ts +358 -0
  180. package/src/client/chunks/editor.tsx +9 -0
  181. package/src/client/chunks/mermaid.tsx +10 -0
  182. package/src/client/chunks/terminal.tsx +9 -0
  183. package/src/client/cm-themes.ts +129 -0
  184. package/src/client/conversation-draft.ts +29 -0
  185. package/src/client/css-modules.d.ts +5 -0
  186. package/src/client/desktop-env.ts +72 -0
  187. package/src/client/editor-load.ts +92 -0
  188. package/src/client/frame-batcher.ts +56 -0
  189. package/src/client/icons.tsx +152 -0
  190. package/src/client/image-types.ts +8 -0
  191. package/src/client/ime-guard.ts +47 -0
  192. package/src/client/index.tsx +427 -0
  193. package/src/client/intercept.tsx +188 -0
  194. package/src/client/lang.ts +225 -0
  195. package/src/client/layout-push.ts +24 -0
  196. package/src/client/layout.css +90 -0
  197. package/src/client/lazy-chunk.tsx +89 -0
  198. package/src/client/link-intercept.ts +73 -0
  199. package/src/client/locales-ar.ts +368 -0
  200. package/src/client/locales-de.ts +353 -0
  201. package/src/client/locales-fr.ts +360 -0
  202. package/src/client/locales-hi.ts +367 -0
  203. package/src/client/locales-id.ts +365 -0
  204. package/src/client/locales-it.ts +358 -0
  205. package/src/client/locales-ja.ts +367 -0
  206. package/src/client/locales-ko.ts +359 -0
  207. package/src/client/locales-nl.ts +365 -0
  208. package/src/client/locales-pl.ts +369 -0
  209. package/src/client/locales-pt.ts +350 -0
  210. package/src/client/locales-ru.ts +365 -0
  211. package/src/client/locales-sv.ts +350 -0
  212. package/src/client/locales-th.ts +367 -0
  213. package/src/client/locales-tr.ts +367 -0
  214. package/src/client/locales-vi.ts +367 -0
  215. package/src/client/locales-zh-HK.ts +382 -0
  216. package/src/client/locales-zh-MO.ts +382 -0
  217. package/src/client/locales-zh-TW.ts +382 -0
  218. package/src/client/locales.ts +894 -0
  219. package/src/client/markdown-html.ts +331 -0
  220. package/src/client/markdown-images.ts +137 -0
  221. package/src/client/markdown-labels.tsx +52 -0
  222. package/src/client/md-toc.tsx +127 -0
  223. package/src/client/mermaid-blocks.ts +110 -0
  224. package/src/client/mermaid-sanitize.ts +93 -0
  225. package/src/client/mermaid.tsx +406 -0
  226. package/src/client/open-when-sized.ts +43 -0
  227. package/src/client/open-with-settings.tsx +135 -0
  228. package/src/client/open-with.ts +221 -0
  229. package/src/client/openpath-intercept.ts +143 -0
  230. package/src/client/paths.ts +63 -0
  231. package/src/client/pdf-types.ts +4 -0
  232. package/src/client/pinned.ts +183 -0
  233. package/src/client/plugin-settings.ts +43 -0
  234. package/src/client/plugins-shared.ts +29 -0
  235. package/src/client/plugins-tabs.ts +96 -0
  236. package/src/client/plugins-viewers.ts +30 -0
  237. package/src/client/prefs.ts +210 -0
  238. package/src/client/produced-files.ts +120 -0
  239. package/src/client/selection-payload.ts +86 -0
  240. package/src/client/service.ts +859 -0
  241. package/src/client/settings-nav-icon.ts +45 -0
  242. package/src/client/shell-presets.ts +83 -0
  243. package/src/client/sidebar.module.css +2788 -0
  244. package/src/client/sidechat-transcript.ts +321 -0
  245. package/src/client/split-pane.tsx +331 -0
  246. package/src/client/state.ts +1497 -0
  247. package/src/client/subagent-detect.ts +137 -0
  248. package/src/client/subagent-jobs.ts +161 -0
  249. package/src/client/tab-content-memo.ts +55 -0
  250. package/src/client/terminal-font.ts +224 -0
  251. package/src/client/terminal-links.ts +194 -0
  252. package/src/client/theme.ts +110 -0
  253. package/src/client/titlebar-strip.ts +37 -0
  254. package/src/client/upload.ts +187 -0
  255. package/src/client/wco.ts +120 -0
  256. package/src/config.ts +154 -0
  257. package/src/context-types.ts +611 -0
  258. package/src/fs-operations.ts +95 -0
  259. package/src/fs-search.ts +113 -0
  260. package/src/fs-tree.ts +158 -0
  261. package/src/git.ts +446 -0
  262. package/src/html-route.ts +106 -0
  263. package/src/index.ts +1291 -0
  264. package/src/invariant.ts +32 -0
  265. package/src/jobs-routes.ts +257 -0
  266. package/src/open-external.ts +90 -0
  267. package/src/path-security.ts +75 -0
  268. package/src/prefs-shared.ts +280 -0
  269. package/src/pty-deps.ts +240 -0
  270. package/src/pty-manager.ts +376 -0
  271. package/src/sidechat-core.ts +468 -0
  272. package/src/sidechat-routes.ts +344 -0
  273. package/src/subagent-activity.ts +91 -0
  274. package/src/subagent-live-route.ts +93 -0
  275. package/src/tools.ts +482 -0
  276. package/src/trust-fence.ts +84 -0
  277. 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,331 @@
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
+ /** Float a docked tab out as a free window (tab context menu entry). */
33
+ floatTab: (tabId: string) => void
34
+ /**
35
+ * Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd
36
+ * at pin time; null clears the pin. Optional: when undefined the tab
37
+ * context menu hides the pin entry (legacy callers).
38
+ */
39
+ pinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
40
+ }
41
+
42
+ /** One divider: pointer-capture drag translating px deltas into fractions.
43
+ * Deltas are incremental — each move reports the displacement since the
44
+ * previous move — because the store adds every reported delta to the pane
45
+ * sizes; a cumulative (since-pointer-down) delta would be re-added on each
46
+ * move and the divider would run away from the cursor.
47
+ *
48
+ * The moves are BATCHED per frame (createFrameBatcher): a pointer stream
49
+ * fires faster than the display refresh, and applying each move is a store
50
+ * reduce that re-renders both workbenches (terminals, editors, trees) per
51
+ * event — the visible drag lag on slower CPUs (#315). The batch accumulates
52
+ * the incremental deltas in a ref and applies the summed fraction at most
53
+ * once per frame; the sum equals what the per-event application would have
54
+ * produced (the reducer clamps each application, and at a settled position
55
+ * a clamped sum is clamped to the same boundary), so the result is
56
+ * indistinguishable at rest and at most one frame behind the cursor.
57
+ */
58
+ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
59
+ const { dir, onResize } = props
60
+ // `last` is the previous pointer position while dragging; `size` is the
61
+ // container size at pointer-down (constant during the drag), used to
62
+ // normalize the px delta into a fraction.
63
+ const last = useRef({ x: 0, y: 0, size: 0 })
64
+ const [dragging, setDragging] = useState(false)
65
+ const pendingDelta = useRef(0)
66
+ const batcher = useRef(createFrameBatcher()).current
67
+ useEffect(() => () => batcher.dispose(), [batcher])
68
+
69
+ return (
70
+ <div
71
+ className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
72
+ onPointerDown={(event) => {
73
+ event.preventDefault()
74
+ event.currentTarget.setPointerCapture(event.pointerId)
75
+ const box = event.currentTarget.parentElement?.getBoundingClientRect()
76
+ last.current = {
77
+ x: event.clientX,
78
+ y: event.clientY,
79
+ size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
80
+ }
81
+ setDragging(true)
82
+ }}
83
+ onPointerMove={(event) => {
84
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
85
+ const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
86
+ pendingDelta.current += delta
87
+ batcher.schedule(() => {
88
+ const accumulated = pendingDelta.current
89
+ pendingDelta.current = 0
90
+ if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
91
+ })
92
+ last.current.x = event.clientX
93
+ last.current.y = event.clientY
94
+ }}
95
+ onPointerUp={(event) => {
96
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
97
+ // Flush any pending baked delta so the final pointer position lands
98
+ // (the release event is not a move; dropping it would lose the tail).
99
+ batcher.flushNow()
100
+ event.currentTarget.releasePointerCapture(event.pointerId)
101
+ setDragging(false)
102
+ }}
103
+ />
104
+ )
105
+ }
106
+
107
+ /** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
108
+ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
109
+ const rect = pane.getBoundingClientRect()
110
+ if (rect.width === 0 || rect.height === 0) return 'center'
111
+ const x = (event.clientX - rect.left) / rect.width
112
+ const y = (event.clientY - rect.top) / rect.height
113
+ if (x < 0.25) return 'left'
114
+ if (x > 0.75) return 'right'
115
+ if (y < 0.25) return 'up'
116
+ if (y > 0.75) return 'down'
117
+ return 'center'
118
+ }
119
+
120
+ /** The icon of one openable type card (mirror of the + menu options). */
121
+ /**
122
+ * An empty pane's welcome cards: the openable types as cards, clicked to
123
+ * open (instead of a bare "this pane is empty" message).
124
+ */
125
+ function PaneEmptyCards(props: {
126
+ newTabOptions: NewTabOption[]
127
+ onNewTab: (optionId: string) => void
128
+ }) {
129
+ const { newTabOptions, onNewTab } = props
130
+ return (
131
+ <div className={css.paneEmptyCards}>
132
+ {newTabOptions.map(option => (
133
+ <button
134
+ key={option.id}
135
+ type="button"
136
+ className={css.paneCard}
137
+ disabled={option.disabled === true}
138
+ title={option.label}
139
+ onClick={() => { onNewTab(option.id) }}
140
+ >
141
+ {option.icon ?? null}
142
+ <span>{option.label}</span>
143
+ </button>
144
+ ))}
145
+ </div>
146
+ )
147
+ }
148
+
149
+ /** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
150
+ function LeafView(props: {
151
+ leaf: { id: string; tabs: SidebarTab[]; active: string | null }
152
+ newTabOptions: NewTabOption[]
153
+ actions: WorkbenchActions
154
+ onNewTab: (optionId: string) => void
155
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
156
+ getTabIcon?: (tab: SidebarTab) => ReactNode
157
+ getTabBadge?: (tab: SidebarTab) => ReactNode
158
+ }) {
159
+ const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
160
+ const [dropZone, setDropZone] = useState<DropZone | null>(null)
161
+ const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
162
+
163
+ useEffect(() => {
164
+ const clear = (): void => { setDropZone(null) }
165
+ window.addEventListener('dragend', clear, true)
166
+ window.addEventListener('drop', clear, true)
167
+ window.addEventListener('blur', clear)
168
+ return () => {
169
+ window.removeEventListener('dragend', clear, true)
170
+ window.removeEventListener('drop', clear, true)
171
+ window.removeEventListener('blur', clear)
172
+ }
173
+ }, [])
174
+
175
+ return (
176
+ <div
177
+ className={clsx(css.pane, dropZone !== null && css.paneDrop)}
178
+ data-dsh-pane={leaf.id}
179
+ onPointerDown={() => { actions.focusPane(leaf.id) }}
180
+ onDragOver={(event) => {
181
+ event.preventDefault()
182
+ const zone = zoneAt(event, event.currentTarget)
183
+ setDropZone(zone)
184
+ }}
185
+ onDragLeave={(event) => {
186
+ // Only clear when the pointer left the pane entirely (not onto a child).
187
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
188
+ setDropZone(null)
189
+ }
190
+ }}
191
+ onDrop={(event) => {
192
+ event.preventDefault()
193
+ const zone = dropZone ?? zoneAt(event, event.currentTarget)
194
+ setDropZone(null)
195
+ const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
196
+ if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
197
+ }}
198
+ >
199
+ {dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
200
+ {/*
201
+ The tab strip renders even for an empty pane: the + menu must stay
202
+ reachable when the pane has no tabs (fresh split, or the last tab was
203
+ dragged out), so a new tab can always be created or dragged in.
204
+ */}
205
+ <TabBar
206
+ paneId={leaf.id}
207
+ tabs={leaf.tabs}
208
+ active={leaf.active}
209
+ onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
210
+ onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
211
+ onNewTab={onNewTab}
212
+ newTabOptions={newTabOptions}
213
+ getTabIcon={getTabIcon}
214
+ getTabBadge={getTabBadge}
215
+ onDropTab={(payload, before) => {
216
+ if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
217
+ else actions.moveTabBefore(payload, leaf.id, before)
218
+ }}
219
+ onFloatTab={actions.floatTab}
220
+ onPinTab={actions.pinTab}
221
+ />
222
+ {leaf.tabs.length > 0 ? (
223
+ /*
224
+ Every tab stays MOUNTED (inactive ones hidden), so switching tabs
225
+ never tears down the content: a terminal keeps its pty connection
226
+ and scrollback, an editor keeps its CodeMirror view and unsaved
227
+ draft, explorer/git keep their loaded data. The unmount (and the
228
+ terminal's close frame) happens only when a tab is truly closed.
229
+ */
230
+ <div className={css.paneContent}>
231
+ {leaf.tabs.map(tab => (
232
+ <div
233
+ key={tab.id}
234
+ className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
235
+ >
236
+ {renderTab(tab, tab.id === activeTab?.id, leaf.id)}
237
+ </div>
238
+ ))}
239
+ </div>
240
+ ) : (
241
+ <PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ /** Recursive node renderer. */
248
+ function NodeView(props: {
249
+ node: SplitNode
250
+ state: SidebarState
251
+ newTabOptions: NewTabOption[]
252
+ actions: WorkbenchActions
253
+ onNewTab: (optionId: string) => void
254
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
255
+ getTabIcon?: (tab: SidebarTab) => ReactNode
256
+ getTabBadge?: (tab: SidebarTab) => ReactNode
257
+ }) {
258
+ const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
259
+ if (node.kind === 'leaf') {
260
+ return (
261
+ <LeafView
262
+ leaf={node}
263
+ newTabOptions={newTabOptions}
264
+ actions={actions}
265
+ onNewTab={onNewTab}
266
+ renderTab={renderTab}
267
+ getTabIcon={getTabIcon}
268
+ getTabBadge={getTabBadge}
269
+ />
270
+ )
271
+ }
272
+ const isRow = node.dir === 'row'
273
+ return (
274
+ <div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
275
+ {node.children.map((child, index) => (
276
+ <Fragment key={child.id}>
277
+ {index > 0 && (
278
+ <Divider
279
+ dir={node.dir}
280
+ onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
281
+ />
282
+ )}
283
+ <div
284
+ className={css.splitChild}
285
+ style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
286
+ >
287
+ <NodeView
288
+ node={child}
289
+ state={state}
290
+ newTabOptions={newTabOptions}
291
+ actions={actions}
292
+ onNewTab={onNewTab}
293
+ renderTab={renderTab}
294
+ getTabIcon={getTabIcon}
295
+ getTabBadge={getTabBadge}
296
+ />
297
+ </div>
298
+ </Fragment>
299
+ ))}
300
+ </div>
301
+ )
302
+ }
303
+
304
+ /** The workbench: the split tree filling the sidebar body (`tree` overrides
305
+ * `state.splits` — the pinned-injection augmented tree). */
306
+ export function Workbench(props: {
307
+ state: SidebarState
308
+ tree?: SplitNode
309
+ newTabOptions: NewTabOption[]
310
+ actions: WorkbenchActions
311
+ onNewTab: (optionId: string) => void
312
+ renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
313
+ getTabIcon?: (tab: SidebarTab) => ReactNode
314
+ getTabBadge?: (tab: SidebarTab) => ReactNode
315
+ }) {
316
+ const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
317
+ return (
318
+ <div className={css.workbench}>
319
+ <NodeView
320
+ node={tree ?? state.splits}
321
+ state={state}
322
+ newTabOptions={newTabOptions}
323
+ actions={actions}
324
+ onNewTab={onNewTab}
325
+ renderTab={renderTab}
326
+ getTabIcon={getTabIcon}
327
+ getTabBadge={getTabBadge}
328
+ />
329
+ </div>
330
+ )
331
+ }