@agimon-ai/doompi-web 0.0.1-alpha.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 (344) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +196 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/bin/serve.cjs +3 -0
  5. package/dist/bin/serve.cjs.map +1 -0
  6. package/dist/bin/serve.d.cts +1 -0
  7. package/dist/bin/serve.d.mts +1 -0
  8. package/dist/bin/serve.mjs +3 -0
  9. package/dist/bin/serve.mjs.map +1 -0
  10. package/dist/bundler.cjs +1 -0
  11. package/dist/bundler.d.cts +2 -0
  12. package/dist/bundler.d.mts +2 -0
  13. package/dist/bundler.mjs +1 -0
  14. package/dist/index.cjs +1 -0
  15. package/dist/index.d.cts +7 -0
  16. package/dist/index.d.mts +7 -0
  17. package/dist/index.mjs +1 -0
  18. package/dist/src/adapters/authRoutes.cjs +2 -0
  19. package/dist/src/adapters/authRoutes.cjs.map +1 -0
  20. package/dist/src/adapters/authRoutes.mjs +2 -0
  21. package/dist/src/adapters/authRoutes.mjs.map +1 -0
  22. package/dist/src/adapters/bundledServer.cjs +2 -0
  23. package/dist/src/adapters/bundledServer.cjs.map +1 -0
  24. package/dist/src/adapters/bundledServer.mjs +2 -0
  25. package/dist/src/adapters/bundledServer.mjs.map +1 -0
  26. package/dist/src/adapters/directoryListing.cjs +2 -0
  27. package/dist/src/adapters/directoryListing.cjs.map +1 -0
  28. package/dist/src/adapters/directoryListing.mjs +2 -0
  29. package/dist/src/adapters/directoryListing.mjs.map +1 -0
  30. package/dist/src/adapters/directorySearch.cjs +3 -0
  31. package/dist/src/adapters/directorySearch.cjs.map +1 -0
  32. package/dist/src/adapters/directorySearch.mjs +3 -0
  33. package/dist/src/adapters/directorySearch.mjs.map +1 -0
  34. package/dist/src/adapters/gitStatus.cjs +2 -0
  35. package/dist/src/adapters/gitStatus.cjs.map +1 -0
  36. package/dist/src/adapters/gitStatus.mjs +2 -0
  37. package/dist/src/adapters/gitStatus.mjs.map +1 -0
  38. package/dist/src/adapters/httpServer.cjs +2 -0
  39. package/dist/src/adapters/httpServer.cjs.map +1 -0
  40. package/dist/src/adapters/httpServer.d.cts +14 -0
  41. package/dist/src/adapters/httpServer.d.cts.map +1 -0
  42. package/dist/src/adapters/httpServer.d.mts +14 -0
  43. package/dist/src/adapters/httpServer.d.mts.map +1 -0
  44. package/dist/src/adapters/httpServer.mjs +2 -0
  45. package/dist/src/adapters/httpServer.mjs.map +1 -0
  46. package/dist/src/adapters/hubProbe.cjs +2 -0
  47. package/dist/src/adapters/hubProbe.cjs.map +1 -0
  48. package/dist/src/adapters/hubProbe.mjs +2 -0
  49. package/dist/src/adapters/hubProbe.mjs.map +1 -0
  50. package/dist/src/adapters/packageApiProxy.cjs +2 -0
  51. package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
  52. package/dist/src/adapters/packageApiProxy.mjs +2 -0
  53. package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
  54. package/dist/src/adapters/piHubService.cjs +2 -0
  55. package/dist/src/adapters/piHubService.cjs.map +1 -0
  56. package/dist/src/adapters/piHubService.mjs +2 -0
  57. package/dist/src/adapters/piHubService.mjs.map +1 -0
  58. package/dist/src/adapters/piWebSocketListener.cjs +2 -0
  59. package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
  60. package/dist/src/adapters/piWebSocketListener.mjs +2 -0
  61. package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
  62. package/dist/src/adapters/providerAuth.cjs +2 -0
  63. package/dist/src/adapters/providerAuth.cjs.map +1 -0
  64. package/dist/src/adapters/providerAuth.mjs +2 -0
  65. package/dist/src/adapters/providerAuth.mjs.map +1 -0
  66. package/dist/src/adapters/registryWatcher.cjs +2 -0
  67. package/dist/src/adapters/registryWatcher.cjs.map +1 -0
  68. package/dist/src/adapters/registryWatcher.mjs +2 -0
  69. package/dist/src/adapters/registryWatcher.mjs.map +1 -0
  70. package/dist/src/adapters/serverSpawner.cjs +2 -0
  71. package/dist/src/adapters/serverSpawner.cjs.map +1 -0
  72. package/dist/src/adapters/serverSpawner.mjs +2 -0
  73. package/dist/src/adapters/serverSpawner.mjs.map +1 -0
  74. package/dist/src/adapters/sessionFiles.cjs +3 -0
  75. package/dist/src/adapters/sessionFiles.cjs.map +1 -0
  76. package/dist/src/adapters/sessionFiles.mjs +3 -0
  77. package/dist/src/adapters/sessionFiles.mjs.map +1 -0
  78. package/dist/src/adapters/sessionHub.cjs +2 -0
  79. package/dist/src/adapters/sessionHub.cjs.map +1 -0
  80. package/dist/src/adapters/sessionHub.mjs +2 -0
  81. package/dist/src/adapters/sessionHub.mjs.map +1 -0
  82. package/dist/src/adapters/sessionSocketClient.cjs +2 -0
  83. package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
  84. package/dist/src/adapters/sessionSocketClient.mjs +2 -0
  85. package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
  86. package/dist/src/adapters/syncGuard.cjs +2 -0
  87. package/dist/src/adapters/syncGuard.cjs.map +1 -0
  88. package/dist/src/adapters/syncGuard.mjs +2 -0
  89. package/dist/src/adapters/syncGuard.mjs.map +1 -0
  90. package/dist/src/adapters/threadJournals.cjs +2 -0
  91. package/dist/src/adapters/threadJournals.cjs.map +1 -0
  92. package/dist/src/adapters/threadJournals.mjs +2 -0
  93. package/dist/src/adapters/threadJournals.mjs.map +1 -0
  94. package/dist/src/adapters/webBundler.cjs +2 -0
  95. package/dist/src/adapters/webBundler.cjs.map +1 -0
  96. package/dist/src/adapters/webBundler.d.cts +28 -0
  97. package/dist/src/adapters/webBundler.d.cts.map +1 -0
  98. package/dist/src/adapters/webBundler.d.mts +28 -0
  99. package/dist/src/adapters/webBundler.d.mts.map +1 -0
  100. package/dist/src/adapters/webBundler.mjs +2 -0
  101. package/dist/src/adapters/webBundler.mjs.map +1 -0
  102. package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
  103. package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
  104. package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
  105. package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
  106. package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
  107. package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
  108. package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
  109. package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
  110. package/dist/src/adapters/webPluginGenerate.cjs +4 -0
  111. package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
  112. package/dist/src/adapters/webPluginGenerate.mjs +4 -0
  113. package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
  114. package/dist/src/adapters/webPluginScan.cjs +2 -0
  115. package/dist/src/adapters/webPluginScan.cjs.map +1 -0
  116. package/dist/src/adapters/webPluginScan.mjs +2 -0
  117. package/dist/src/adapters/webPluginScan.mjs.map +1 -0
  118. package/dist/src/adapters/webPluginVite.cjs +2 -0
  119. package/dist/src/adapters/webPluginVite.cjs.map +1 -0
  120. package/dist/src/adapters/webPluginVite.mjs +2 -0
  121. package/dist/src/adapters/webPluginVite.mjs.map +1 -0
  122. package/dist/src/services/directoryMatch.cjs +2 -0
  123. package/dist/src/services/directoryMatch.cjs.map +1 -0
  124. package/dist/src/services/directoryMatch.mjs +2 -0
  125. package/dist/src/services/directoryMatch.mjs.map +1 -0
  126. package/dist/src/services/fileMatch.cjs +2 -0
  127. package/dist/src/services/fileMatch.cjs.map +1 -0
  128. package/dist/src/services/fileMatch.mjs +2 -0
  129. package/dist/src/services/fileMatch.mjs.map +1 -0
  130. package/dist/src/services/fileMedia.cjs +2 -0
  131. package/dist/src/services/fileMedia.cjs.map +1 -0
  132. package/dist/src/services/fileMedia.mjs +2 -0
  133. package/dist/src/services/fileMedia.mjs.map +1 -0
  134. package/dist/src/services/frameRing.cjs +2 -0
  135. package/dist/src/services/frameRing.cjs.map +1 -0
  136. package/dist/src/services/frameRing.mjs +2 -0
  137. package/dist/src/services/frameRing.mjs.map +1 -0
  138. package/dist/src/services/journalTail.cjs +3 -0
  139. package/dist/src/services/journalTail.cjs.map +1 -0
  140. package/dist/src/services/journalTail.mjs +3 -0
  141. package/dist/src/services/journalTail.mjs.map +1 -0
  142. package/dist/src/services/loginFlow.cjs +2 -0
  143. package/dist/src/services/loginFlow.cjs.map +1 -0
  144. package/dist/src/services/loginFlow.mjs +2 -0
  145. package/dist/src/services/loginFlow.mjs.map +1 -0
  146. package/dist/src/services/registryStore.cjs +2 -0
  147. package/dist/src/services/registryStore.cjs.map +1 -0
  148. package/dist/src/services/registryStore.mjs +2 -0
  149. package/dist/src/services/registryStore.mjs.map +1 -0
  150. package/dist/src/services/retryPolicy.cjs +2 -0
  151. package/dist/src/services/retryPolicy.cjs.map +1 -0
  152. package/dist/src/services/retryPolicy.mjs +2 -0
  153. package/dist/src/services/retryPolicy.mjs.map +1 -0
  154. package/dist/src/services/serveOptions.cjs +2 -0
  155. package/dist/src/services/serveOptions.cjs.map +1 -0
  156. package/dist/src/services/serveOptions.d.cts +21 -0
  157. package/dist/src/services/serveOptions.d.cts.map +1 -0
  158. package/dist/src/services/serveOptions.d.mts +21 -0
  159. package/dist/src/services/serveOptions.d.mts.map +1 -0
  160. package/dist/src/services/serveOptions.mjs +2 -0
  161. package/dist/src/services/serveOptions.mjs.map +1 -0
  162. package/dist/src/services/sessionFraming.cjs +4 -0
  163. package/dist/src/services/sessionFraming.cjs.map +1 -0
  164. package/dist/src/services/sessionFraming.mjs +4 -0
  165. package/dist/src/services/sessionFraming.mjs.map +1 -0
  166. package/dist/src/services/sessionPresence.cjs +2 -0
  167. package/dist/src/services/sessionPresence.cjs.map +1 -0
  168. package/dist/src/services/sessionPresence.mjs +2 -0
  169. package/dist/src/services/sessionPresence.mjs.map +1 -0
  170. package/dist/src/services/staticAssets.cjs +2 -0
  171. package/dist/src/services/staticAssets.cjs.map +1 -0
  172. package/dist/src/services/staticAssets.mjs +2 -0
  173. package/dist/src/services/staticAssets.mjs.map +1 -0
  174. package/dist/src/services/webDevRoots.cjs +2 -0
  175. package/dist/src/services/webDevRoots.cjs.map +1 -0
  176. package/dist/src/services/webDevRoots.mjs +2 -0
  177. package/dist/src/services/webDevRoots.mjs.map +1 -0
  178. package/dist/src/services/webPluginManifest.cjs +2 -0
  179. package/dist/src/services/webPluginManifest.cjs.map +1 -0
  180. package/dist/src/services/webPluginManifest.mjs +2 -0
  181. package/dist/src/services/webPluginManifest.mjs.map +1 -0
  182. package/dist/src/types/auth.cjs +2 -0
  183. package/dist/src/types/auth.cjs.map +1 -0
  184. package/dist/src/types/auth.d.cts +94 -0
  185. package/dist/src/types/auth.d.cts.map +1 -0
  186. package/dist/src/types/auth.d.mts +94 -0
  187. package/dist/src/types/auth.d.mts.map +1 -0
  188. package/dist/src/types/auth.mjs +2 -0
  189. package/dist/src/types/auth.mjs.map +1 -0
  190. package/dist/src/types/bridge.d.cts +31 -0
  191. package/dist/src/types/bridge.d.cts.map +1 -0
  192. package/dist/src/types/bridge.d.mts +31 -0
  193. package/dist/src/types/bridge.d.mts.map +1 -0
  194. package/dist/src/types/hub.cjs +2 -0
  195. package/dist/src/types/hub.cjs.map +1 -0
  196. package/dist/src/types/hub.d.cts +58 -0
  197. package/dist/src/types/hub.d.cts.map +1 -0
  198. package/dist/src/types/hub.d.mts +58 -0
  199. package/dist/src/types/hub.d.mts.map +1 -0
  200. package/dist/src/types/hub.mjs +2 -0
  201. package/dist/src/types/hub.mjs.map +1 -0
  202. package/dist/src/types/media.cjs +2 -0
  203. package/dist/src/types/media.cjs.map +1 -0
  204. package/dist/src/types/media.mjs +2 -0
  205. package/dist/src/types/media.mjs.map +1 -0
  206. package/dist/src/types/registry.cjs +1 -0
  207. package/dist/src/types/registry.d.cts +2 -0
  208. package/dist/src/types/registry.d.mts +2 -0
  209. package/dist/src/types/registry.mjs +1 -0
  210. package/dist/src/types/session.cjs +2 -0
  211. package/dist/src/types/session.cjs.map +1 -0
  212. package/dist/src/types/session.d.cts +24 -0
  213. package/dist/src/types/session.d.cts.map +1 -0
  214. package/dist/src/types/session.d.mts +24 -0
  215. package/dist/src/types/session.d.mts.map +1 -0
  216. package/dist/src/types/session.mjs +2 -0
  217. package/dist/src/types/session.mjs.map +1 -0
  218. package/dist/web/apple-touch-icon.png +0 -0
  219. package/dist/web/assets/index-Cg52ngKv.css +2 -0
  220. package/dist/web/assets/index-DQO7NTIP.js +90 -0
  221. package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
  222. package/dist/web/favicon-16.png +0 -0
  223. package/dist/web/favicon-32.png +0 -0
  224. package/dist/web/favicon.ico +0 -0
  225. package/dist/web/favicon.svg +16 -0
  226. package/dist/web/icon-512.png +0 -0
  227. package/dist/web/index.html +17 -0
  228. package/llms.txt +8 -0
  229. package/package.json +106 -0
  230. package/src/adapters/authRoutes.ts +95 -0
  231. package/src/adapters/bundledServer.ts +64 -0
  232. package/src/adapters/directoryListing.ts +127 -0
  233. package/src/adapters/directorySearch.ts +124 -0
  234. package/src/adapters/gitStatus.ts +27 -0
  235. package/src/adapters/httpServer.ts +556 -0
  236. package/src/adapters/hubProbe.ts +27 -0
  237. package/src/adapters/packageApiProxy.ts +60 -0
  238. package/src/adapters/piHubService.ts +129 -0
  239. package/src/adapters/piWebSocketListener.ts +98 -0
  240. package/src/adapters/providerAuth.ts +181 -0
  241. package/src/adapters/registryWatcher.ts +112 -0
  242. package/src/adapters/serverSpawner.ts +181 -0
  243. package/src/adapters/sessionFiles.ts +99 -0
  244. package/src/adapters/sessionHub.ts +567 -0
  245. package/src/adapters/sessionSocketClient.ts +105 -0
  246. package/src/adapters/syncGuard.ts +119 -0
  247. package/src/adapters/threadJournals.ts +184 -0
  248. package/src/adapters/webBundler.ts +83 -0
  249. package/src/adapters/webHubPluginLoader.ts +67 -0
  250. package/src/adapters/webHubPlugins.generated.ts +4 -0
  251. package/src/adapters/webPluginGenerate.ts +172 -0
  252. package/src/adapters/webPluginScan.ts +62 -0
  253. package/src/adapters/webPluginVite.ts +51 -0
  254. package/src/bin/serve.ts +64 -0
  255. package/src/exports/bundler.ts +1 -0
  256. package/src/exports/index.ts +9 -0
  257. package/src/services/directoryMatch.ts +71 -0
  258. package/src/services/fileMatch.ts +37 -0
  259. package/src/services/fileMedia.ts +27 -0
  260. package/src/services/frameRing.ts +33 -0
  261. package/src/services/journalTail.ts +47 -0
  262. package/src/services/loginFlow.ts +144 -0
  263. package/src/services/registryStore.ts +83 -0
  264. package/src/services/retryPolicy.ts +15 -0
  265. package/src/services/serveOptions.ts +63 -0
  266. package/src/services/sessionFraming.ts +32 -0
  267. package/src/services/sessionPresence.ts +131 -0
  268. package/src/services/staticAssets.ts +37 -0
  269. package/src/services/webDevRoots.ts +36 -0
  270. package/src/services/webPluginManifest.ts +149 -0
  271. package/src/types/auth.ts +118 -0
  272. package/src/types/bridge.ts +41 -0
  273. package/src/types/hub.ts +290 -0
  274. package/src/types/media.ts +38 -0
  275. package/src/types/registry.ts +2 -0
  276. package/src/types/session.ts +36 -0
  277. package/src/web/app/Providers.tsx +46 -0
  278. package/src/web/app/protocolRuntime.ts +106 -0
  279. package/src/web/app/sessionRuntime.ts +189 -0
  280. package/src/web/app/webPlugins.generated.ts +4 -0
  281. package/src/web/components/MascotMark.tsx +46 -0
  282. package/src/web/components/PluginSurface.tsx +10 -0
  283. package/src/web/components/ToolRendererBoundary.tsx +32 -0
  284. package/src/web/features/activity/ActivityDock.tsx +125 -0
  285. package/src/web/features/connection/RefusedCard.tsx +63 -0
  286. package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
  287. package/src/web/features/leader/CommandPalette.tsx +386 -0
  288. package/src/web/features/selection/SelectionBar.tsx +529 -0
  289. package/src/web/features/session/Composer.tsx +368 -0
  290. package/src/web/features/session/MentionPreviews.tsx +58 -0
  291. package/src/web/features/session/ThreadView.tsx +35 -0
  292. package/src/web/features/session/Timeline.tsx +382 -0
  293. package/src/web/features/session/ToolCard.tsx +79 -0
  294. package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
  295. package/src/web/features/sessions/SessionRail.tsx +411 -0
  296. package/src/web/features/settings/AppearanceSettings.tsx +88 -0
  297. package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
  298. package/src/web/features/settings/PluginSettings.tsx +92 -0
  299. package/src/web/features/settings/ProviderSettings.tsx +238 -0
  300. package/src/web/features/settings/SettingsMenu.tsx +32 -0
  301. package/src/web/features/status/TopBar.tsx +208 -0
  302. package/src/web/index.html +16 -0
  303. package/src/web/lib/authApi.ts +90 -0
  304. package/src/web/lib/commands.ts +61 -0
  305. package/src/web/lib/composition.ts +225 -0
  306. package/src/web/lib/fileMentions.ts +32 -0
  307. package/src/web/lib/hubApi.ts +86 -0
  308. package/src/web/lib/leaderTree.ts +159 -0
  309. package/src/web/lib/piTransport.ts +61 -0
  310. package/src/web/lib/pluginRegistry.ts +488 -0
  311. package/src/web/lib/pluginSlotProps.ts +50 -0
  312. package/src/web/lib/promptFocus.ts +25 -0
  313. package/src/web/lib/protocolTimeline.ts +73 -0
  314. package/src/web/lib/sessionModel.ts +673 -0
  315. package/src/web/lib/sessionSummary.ts +58 -0
  316. package/src/web/lib/settingsSections.ts +21 -0
  317. package/src/web/lib/statusLine.ts +90 -0
  318. package/src/web/lib/threadRenderer.ts +25 -0
  319. package/src/web/lib/toolMessageProps.ts +21 -0
  320. package/src/web/lib/transport.ts +30 -0
  321. package/src/web/lib/wsClient.ts +73 -0
  322. package/src/web/main.tsx +17 -0
  323. package/src/web/public/apple-touch-icon.png +0 -0
  324. package/src/web/public/favicon-16.png +0 -0
  325. package/src/web/public/favicon-32.png +0 -0
  326. package/src/web/public/favicon.ico +0 -0
  327. package/src/web/public/favicon.svg +16 -0
  328. package/src/web/public/icon-512.png +0 -0
  329. package/src/web/routes/CockpitPage.tsx +98 -0
  330. package/src/web/routes/SettingsPage.tsx +60 -0
  331. package/src/web/routes/routeTree.tsx +49 -0
  332. package/src/web/stores/menuStore.ts +78 -0
  333. package/src/web/stores/paletteStore.ts +25 -0
  334. package/src/web/stores/sessionStore.ts +303 -0
  335. package/src/web/stores/sessionsStore.ts +155 -0
  336. package/src/web/stores/themeStore.ts +52 -0
  337. package/src/web/stores/threadStore.ts +68 -0
  338. package/src/web/stores/transientTabsStore.ts +63 -0
  339. package/src/web/stores/uiStore.ts +28 -0
  340. package/src/web/stores/useOpenTab.ts +19 -0
  341. package/src/web/stores/usePluginSlotProps.ts +22 -0
  342. package/src/web/styles/app.css +15 -0
  343. package/src/web/styles/webPluginSources.generated.css +1 -0
  344. package/src/web/vite-env.d.ts +1 -0
@@ -0,0 +1,382 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ ChevronDownIcon,
5
+ EmptyState,
6
+ ExternalLinkIcon,
7
+ Markdown,
8
+ RefreshIcon,
9
+ Separator,
10
+ StreamCursor,
11
+ } from '@agimon-ai/doompi-web-components';
12
+ import { memo, type ReactNode, useLayoutEffect, useRef, useState } from 'react';
13
+ import { useStore } from '@tanstack/react-store';
14
+ import type { Store } from '@tanstack/store';
15
+ import { parseFileMentions } from '../../lib/fileMentions.ts';
16
+ import type { SessionState, TimelineEntry } from '../../lib/sessionModel.ts';
17
+ import { requestOlderHistory, sessionStoreFor, submitMessage, useHasOlderHistory } from '../../stores/sessionStore.ts';
18
+ import { sessionsStore } from '../../stores/sessionsStore.ts';
19
+ import { MentionPreviews } from './MentionPreviews.tsx';
20
+ import { ToolCard } from './ToolCard.tsx';
21
+
22
+ const SUGGESTIONS = [
23
+ 'review the working tree and summarise the diff',
24
+ 'run the affected nx targets and report failures',
25
+ 'explain how the session socket handshake works',
26
+ ];
27
+
28
+ /** Closer than this to the end counts as reading the latest, so new frames keep the view pinned. */
29
+ const PINNED_THRESHOLD_PX = 200;
30
+ /** Closer than this to the top asks for the window above, before the reader hits the edge. */
31
+ const PAGE_BACK_THRESHOLD_PX = 400;
32
+ /**
33
+ * Entries at the end that are never skipped.
34
+ *
35
+ * Containment makes a skipped entry report its placeholder height, so a
36
+ * transcript whose tail is contained has a scroll height that is only an
37
+ * estimate, and following the newest line lands short of it. The tail is also
38
+ * the part a reader is nearly always looking at, so there is nothing to skip
39
+ * there; everything above it is what makes a long session slow.
40
+ */
41
+ const LIVE_TAIL_ENTRIES = 40;
42
+ /** How quickly the viewport catches a moving streaming tail without snapping to it. */
43
+ const FOLLOW_EASING = 0.32;
44
+ const FOLLOW_MIN_STEP_PX = 2;
45
+
46
+ /**
47
+ * The speaker's label.
48
+ *
49
+ * Leading, it holds a fixed column so every entry's text starts at the same
50
+ * left edge and the labels line up down the page. Trailing, that column would
51
+ * only push the label away from the block it names, so it sizes to its own
52
+ * text and sits beside it.
53
+ */
54
+ function Gutter({ label, tone, trailing = false }: { label: string; tone: string; trailing?: boolean }) {
55
+ return (
56
+ <span className={`shrink-0 text-[10px] font-bold ${trailing ? 'text-left' : 'w-11 pt-0.5 text-right'} ${tone}`}>
57
+ {label}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ const Entry = memo(function Entry({ entry, sessionId }: { entry: TimelineEntry; sessionId: string | null }) {
63
+ if (entry.kind === 'user') {
64
+ return (
65
+ // What the reader said sits inboard of what the session said, on its own
66
+ // surface: the transcript is a conversation, and two lanes tell the two
67
+ // voices apart faster than a gutter label alone.
68
+ <div data-testid="entry-user" className="flex items-center gap-3 pl-10">
69
+ <div className="flex min-w-0 flex-1 flex-col gap-2 rounded-md border border-doom-border-soft bg-doom-deep px-3.5 py-2.5 text-[13px] text-doom-hi">
70
+ <Markdown text={entry.text} />
71
+ {sessionId ? <MentionPreviews sessionId={sessionId} mentions={parseFileMentions(entry.text)} /> : null}
72
+ </div>
73
+ {/* The label trails what the reader said and the session's leads what
74
+ it answered, so the two voices read as two lanes at a glance. */}
75
+ <Gutter label="you" tone="text-doom-cyan" trailing />
76
+ </div>
77
+ );
78
+ }
79
+
80
+ if (entry.kind === 'assistant') {
81
+ return (
82
+ <div data-testid="entry-assistant" data-streaming={entry.streaming} className="flex gap-3">
83
+ <Gutter label="pi" tone="text-doom-magenta" />
84
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
85
+ {entry.thinking ? (
86
+ <div
87
+ data-testid="entry-thinking"
88
+ className="text-[11px] text-doom-violet/80 [&_strong]:text-doom-violet [&_p]:whitespace-pre-wrap"
89
+ >
90
+ <Markdown text={entry.thinking} />
91
+ </div>
92
+ ) : null}
93
+ <div className="text-[13px] text-doom-text">
94
+ <Markdown text={entry.text} />
95
+ {entry.streaming ? <StreamCursor /> : null}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ if (entry.kind === 'tool') {
103
+ return (
104
+ <div className="flex gap-3">
105
+ <Gutter label="tool" tone="text-doom-faint" />
106
+ <div className="min-w-0 flex-1">
107
+ <ToolCard entry={entry} sessionId={sessionId} />
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ if (entry.kind === 'settled') {
114
+ return (
115
+ <div data-testid="entry-settled" className="flex items-center gap-3 pl-14">
116
+ <Separator className="flex-1" />
117
+ <span className="text-[10px] text-doom-faint">
118
+ agent settled{entry.tools > 0 ? ` · ${entry.tools} tool${entry.tools === 1 ? '' : 's'}` : ''}
119
+ </span>
120
+ <Separator className="flex-1" />
121
+ </div>
122
+ );
123
+ }
124
+
125
+ if (entry.kind === 'queued') {
126
+ return (
127
+ <div data-testid="entry-queued" className="flex pl-[58px]">
128
+ <Badge size="md" className="self-start bg-doom-panel text-[10px]">
129
+ <RefreshIcon className="h-3 w-3 shrink-0 text-doom-cyan" />
130
+ queued follow-up: &quot;{entry.text}&quot;
131
+ </Badge>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ // A notice: the agent's own aside (a mode switch, a refusal). Only an error
137
+ // shouts; an informational one reads as a quiet system line.
138
+ const isError = entry.tone === 'error';
139
+ return (
140
+ <div data-testid="entry-notice" data-tone={entry.tone} className="flex gap-3">
141
+ <Gutter label={isError ? '!' : '·'} tone={isError ? 'text-doom-red' : 'text-doom-faint'} />
142
+ <p className={`min-w-0 flex-1 break-words text-[12px] ${isError ? 'text-doom-red' : 'text-doom-dim'}`}>
143
+ {entry.text}
144
+ </p>
145
+ </div>
146
+ );
147
+ });
148
+
149
+ /**
150
+ * One transcript, whichever fold it reads: the focused session's own, or a
151
+ * thread of it. Owns the scroll pinning; the caller owns the empty state and
152
+ * the session the entries' tool cards act on.
153
+ */
154
+ export function Transcript({
155
+ store,
156
+ sessionId,
157
+ empty,
158
+ testId = 'timeline',
159
+ }: {
160
+ store: Store<SessionState>;
161
+ sessionId: string | null;
162
+ empty: ReactNode;
163
+ testId?: string;
164
+ }) {
165
+ const entries = useStore(store, (state) => state.entries);
166
+ const scroller = useRef<HTMLDivElement>(null);
167
+ // The transcript's height as of the last entry. Whether to follow the newest
168
+ // line is decided against this rather than against a scroll event, because
169
+ // an event fires after the fact and a fast run can grow the transcript
170
+ // before the browser has reported the reader's scroll.
171
+ const lastHeight = useRef(0);
172
+ const following = useRef(true);
173
+ const followFrame = useRef<number | null>(null);
174
+ const animatingFollow = useRef(false);
175
+ const [unread, setUnread] = useState(false);
176
+ const hasOlder = useHasOlderHistory(sessionId);
177
+ // Where the bottom of the transcript sat before a window was prepended.
178
+ // Restoring against this is what keeps the reader's place: prepending grows
179
+ // the scroll height above them, and the browser would otherwise leave the
180
+ // viewport where it was and the content would appear to jump.
181
+ const anchor = useRef<{ height: number; top: number } | null>(null);
182
+ // The first entry's key. A window prepended above the reader changes it, and
183
+ // nothing else does: an entry arriving at the bottom, or the streaming one
184
+ // growing, leaves the top of the transcript alone. That is the difference
185
+ // between holding the reader's place and refusing to follow a live run.
186
+ const firstId = useRef<string | null>(null);
187
+
188
+ const atBottom = (element: HTMLDivElement, height: number): boolean =>
189
+ element.scrollTop + element.clientHeight >= height - PINNED_THRESHOLD_PX;
190
+
191
+ const cancelFollowAnimation = (): void => {
192
+ if (followFrame.current !== null) cancelAnimationFrame(followFrame.current);
193
+ followFrame.current = null;
194
+ animatingFollow.current = false;
195
+ };
196
+
197
+ const followLatest = (): void => {
198
+ const element = scroller.current;
199
+ if (!element || followFrame.current !== null) return;
200
+ animatingFollow.current = true;
201
+ const step = (): void => {
202
+ const current = scroller.current;
203
+ if (!current || !following.current) {
204
+ followFrame.current = null;
205
+ animatingFollow.current = false;
206
+ return;
207
+ }
208
+ const target = Math.max(0, current.scrollHeight - current.clientHeight);
209
+ const distance = target - current.scrollTop;
210
+ if (Math.abs(distance) <= 1) {
211
+ current.scrollTop = target;
212
+ followFrame.current = null;
213
+ animatingFollow.current = false;
214
+ setUnread(false);
215
+ return;
216
+ }
217
+ const movement = Math.max(FOLLOW_MIN_STEP_PX, Math.abs(distance) * FOLLOW_EASING);
218
+ current.scrollTop += Math.sign(distance) * Math.min(Math.abs(distance), movement);
219
+ followFrame.current = requestAnimationFrame(step);
220
+ };
221
+ followFrame.current = requestAnimationFrame(step);
222
+ };
223
+
224
+ const jumpToLatest = (): void => {
225
+ following.current = true;
226
+ setUnread(false);
227
+ followLatest();
228
+ };
229
+
230
+ /** Reaching the bottom by hand is the same as never having left it. */
231
+ const onScroll = (): void => {
232
+ const element = scroller.current;
233
+ if (!element) return;
234
+ const bottom = atBottom(element, element.scrollHeight);
235
+ if (!animatingFollow.current) following.current = bottom;
236
+ if (bottom) setUnread(false);
237
+ if (element.scrollTop <= PAGE_BACK_THRESHOLD_PX && hasOlder) {
238
+ anchor.current = { height: element.scrollHeight, top: element.scrollTop };
239
+ requestOlderHistory(sessionId);
240
+ }
241
+ };
242
+
243
+ /** Wheel input is explicit intent to leave an in-flight live follow. */
244
+ const onWheel = (): void => {
245
+ cancelFollowAnimation();
246
+ following.current = false;
247
+ };
248
+
249
+ /** Pointer input may become a scrollbar drag; its scroll event decides pinning. */
250
+ const onPointerDown = (): void => {
251
+ cancelFollowAnimation();
252
+ };
253
+
254
+ // A different fold starts at the bottom of its transcript.
255
+ useLayoutEffect(() => {
256
+ cancelFollowAnimation();
257
+ lastHeight.current = 0;
258
+ firstId.current = null;
259
+ anchor.current = null;
260
+ following.current = true;
261
+ setUnread(false);
262
+ return cancelFollowAnimation;
263
+ }, [store]);
264
+
265
+ useLayoutEffect(() => {
266
+ const element = scroller.current;
267
+ if (!element) return;
268
+ // A prepended window grew the transcript upwards; hold the reader where
269
+ // they were by moving down by exactly what appeared above them.
270
+ const held = anchor.current;
271
+ const prepended = entries.length > 0 && firstId.current !== null && entries[0]?.id !== firstId.current;
272
+ firstId.current = entries[0]?.id ?? null;
273
+ if (held !== null && prepended) {
274
+ anchor.current = null;
275
+ const grew = element.scrollHeight - held.height;
276
+ if (grew > 0) {
277
+ element.scrollTop = held.top + grew;
278
+ lastHeight.current = element.scrollHeight;
279
+ return;
280
+ }
281
+ }
282
+ // The first layout starts at the end without animating through the whole
283
+ // transcript. Later growth chases the live tail one frame at a time.
284
+ if (lastHeight.current === 0) {
285
+ element.scrollTop = element.scrollHeight;
286
+ lastHeight.current = element.scrollHeight;
287
+ following.current = true;
288
+ setUnread(false);
289
+ return;
290
+ }
291
+ const shouldFollow = animatingFollow.current || atBottom(element, lastHeight.current);
292
+ lastHeight.current = element.scrollHeight;
293
+ if (shouldFollow) {
294
+ following.current = true;
295
+ setUnread(false);
296
+ followLatest();
297
+ return;
298
+ }
299
+ setUnread(true);
300
+ }, [entries]);
301
+
302
+ if (entries.length === 0) return <>{empty}</>;
303
+
304
+ return (
305
+ <div className="relative flex min-h-0 flex-1 flex-col">
306
+ <div
307
+ ref={scroller}
308
+ onScroll={onScroll}
309
+ onWheel={onWheel}
310
+ onPointerDown={onPointerDown}
311
+ data-testid={testId}
312
+ className="flex flex-1 flex-col gap-[18px] overflow-y-auto px-[26px] py-[22px]"
313
+ >
314
+ {entries.map((entry, index) => (
315
+ // Entries above the live tail are skipped for layout and paint until
316
+ // they are scrolled near. A long transcript is thousands of markdown
317
+ // blocks, diffs and tool cards, and laying all of them out on every
318
+ // scroll is what makes an old session crawl. The browser does this
319
+ // rather than a measured list in JS, because entry heights vary by an
320
+ // order of magnitude and a list that guesses them wrong moves the
321
+ // reader's place under them.
322
+ <div
323
+ key={entry.id}
324
+ className={
325
+ index < entries.length - LIVE_TAIL_ENTRIES
326
+ ? '[contain-intrinsic-size:auto_64px] [content-visibility:auto]'
327
+ : undefined
328
+ }
329
+ >
330
+ <Entry entry={entry} sessionId={sessionId} />
331
+ </div>
332
+ ))}
333
+ </div>
334
+ {unread ? (
335
+ <Button
336
+ variant="subtle"
337
+ size="sm"
338
+ data-testid="timeline-jump"
339
+ onClick={jumpToLatest}
340
+ className="absolute bottom-3 left-1/2 -translate-x-1/2 border border-doom-border shadow-lg animate-doom-rise"
341
+ >
342
+ <ChevronDownIcon className="h-3 w-3" />
343
+ new activity below
344
+ </Button>
345
+ ) : null}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ /** The focused session's conversation; an empty one offers a few openers. */
351
+ export function Timeline() {
352
+ const activeId = useStore(sessionsStore, (state) => state.activeId);
353
+ return (
354
+ <Transcript
355
+ store={sessionStoreFor(activeId)}
356
+ sessionId={activeId}
357
+ empty={
358
+ <EmptyState
359
+ data-testid="timeline"
360
+ title="no messages yet"
361
+ description="this session is attached and waiting. anything you send goes straight to the supervised agent."
362
+ >
363
+ <div data-testid="timeline-empty" className="mt-2 flex w-full flex-col gap-1.5">
364
+ {SUGGESTIONS.map((suggestion, index) => (
365
+ <Button
366
+ key={suggestion}
367
+ variant="outline"
368
+ size="lg"
369
+ data-testid={`suggestion-${index}`}
370
+ onClick={() => submitMessage(suggestion)}
371
+ className="h-auto justify-between bg-doom-panel px-3 py-2 text-left font-normal text-doom-text"
372
+ >
373
+ <span className="flex-1 truncate">{suggestion}</span>
374
+ <ExternalLinkIcon className="h-3 w-3 shrink-0 text-doom-faint" />
375
+ </Button>
376
+ ))}
377
+ </div>
378
+ </EmptyState>
379
+ }
380
+ />
381
+ );
382
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ collapseLines,
3
+ MessageItem,
4
+ MessageItemBody,
5
+ MessageItemHeader,
6
+ MessageItemStatus,
7
+ type StatusTone,
8
+ } from '@agimon-ai/doompi-web-components';
9
+ import { ToolRendererBoundary } from '../../components/ToolRendererBoundary.tsx';
10
+ import { pluginToolRenderer } from '../../lib/pluginRegistry.ts';
11
+ import type { ToolEntry } from '../../lib/sessionModel.ts';
12
+ import { toolMessageProps } from '../../lib/toolMessageProps.ts';
13
+ import { useActiveSession } from '../../stores/sessionStore.ts';
14
+ import { usePluginSlotProps } from '../../stores/usePluginSlotProps.ts';
15
+
16
+ const MAX_PREVIEW_LINES = 12;
17
+
18
+ function toneOf(entry: ToolEntry): StatusTone {
19
+ return entry.running ? 'running' : entry.isError ? 'error' : 'ok';
20
+ }
21
+
22
+ /**
23
+ * The host's own item for a tool no plugin claims: the argument summary in
24
+ * the header and the result's text clipped until expanded, on the same shell
25
+ * every plugin message is built from.
26
+ */
27
+ function HostToolMessage({ entry }: { entry: ToolEntry }) {
28
+ const lines = entry.output.length > 0 ? entry.output.split('\n') : [];
29
+ return (
30
+ <MessageItem tone={toneOf(entry)} expandable={lines.length > MAX_PREVIEW_LINES}>
31
+ {({ expanded }) => {
32
+ const { shown, hidden } = collapseLines(lines, MAX_PREVIEW_LINES, expanded);
33
+ return (
34
+ <>
35
+ <MessageItemHeader title={entry.name}>
36
+ <span className="min-w-0 flex-1 truncate">{entry.argSummary}</span>
37
+ </MessageItemHeader>
38
+ {shown.length > 0 ? (
39
+ <MessageItemBody className="flex flex-col gap-1">
40
+ <pre data-testid="tool-output" className="whitespace-pre-wrap break-words">
41
+ {shown.join('\n')}
42
+ </pre>
43
+ {hidden > 0 ? <MessageItemStatus expands>show {hidden} more line(s)</MessageItemStatus> : null}
44
+ </MessageItemBody>
45
+ ) : null}
46
+ </>
47
+ );
48
+ }}
49
+ </MessageItem>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * One tool call in the timeline. The plugin that registered the tool owns
55
+ * the whole item through its `message` renderer, the web half of the TUI's
56
+ * renderShell 'self'; the host only marks the row, catches a renderer that
57
+ * throws, and stands in for a tool nobody claims.
58
+ */
59
+ export function ToolCard({ entry, sessionId }: { entry: ToolEntry; sessionId: string | null }) {
60
+ const statuses = useActiveSession((state) => state.statuses);
61
+ const slotProps = usePluginSlotProps(sessionId);
62
+ const renderer = pluginToolRenderer(entry.name, statuses);
63
+ const state = toneOf(entry);
64
+ const props = toolMessageProps(slotProps, entry, statuses);
65
+ return (
66
+ <ToolRendererBoundary key={entry.toolCallId} toolName={entry.name}>
67
+ {(failed) => (
68
+ <div
69
+ data-testid="entry-tool"
70
+ data-tool-name={entry.name}
71
+ data-tool-state={state}
72
+ data-tool-renderer={failed ? 'failed' : renderer ? 'plugin' : 'host'}
73
+ >
74
+ {renderer && !failed ? <renderer.message {...props} /> : <HostToolMessage entry={entry} />}
75
+ </div>
76
+ )}
77
+ </ToolRendererBoundary>
78
+ );
79
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ Button,
3
+ Dialog,
4
+ DialogBody,
5
+ DialogContent,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ Input,
10
+ OptionRow,
11
+ } from '@agimon-ai/doompi-web-components';
12
+ import { useNavigate } from '@tanstack/react-router';
13
+ import { useStore } from '@tanstack/react-store';
14
+ import { useEffect, useRef, useState } from 'react';
15
+ import { createSession, searchDirectories } from '../../lib/hubApi.ts';
16
+ import { sessionsStore, waitForSession } from '../../stores/sessionsStore.ts';
17
+
18
+ const PATH_SEPARATOR = '/';
19
+ /** Keystrokes settle for this long before the hub is asked for directories. */
20
+ const SUGGESTION_DEBOUNCE_MS = 120;
21
+ const NO_HIGHLIGHT = -1;
22
+ const TRAILING_SEPARATORS = /\/+$/;
23
+
24
+ /**
25
+ * Creates a session the way ctrl+t promises: pick a directory, get an agent.
26
+ *
27
+ * The directory box completes as it is typed: the hub lists the children of
28
+ * the parent directory whose names match the trailing segment as a regex,
29
+ * and picking one appends a slash so the next level narrows the same way.
30
+ * The recent-cwd chips are just the distinct directories of live sessions;
31
+ * nothing is persisted, because the rail already is the user's working set.
32
+ */
33
+ export function NewSessionDialog({ onClose }: { onClose: () => void }) {
34
+ const navigate = useNavigate();
35
+ const byId = useStore(sessionsStore, (state) => state.byId);
36
+ const activeId = useStore(sessionsStore, (state) => state.activeId);
37
+ const [cwd, setCwd] = useState(() => (activeId !== null ? (byId[activeId]?.summary.cwd ?? '') : ''));
38
+ const [name, setName] = useState('');
39
+ const [error, setError] = useState('');
40
+ const [busy, setBusy] = useState(false);
41
+ const [suggestions, setSuggestions] = useState<string[]>([]);
42
+ const [highlight, setHighlight] = useState(NO_HIGHLIGHT);
43
+ const cwdInput = useRef<HTMLInputElement>(null);
44
+
45
+ const recentCwds = [...new Set(Object.values(byId).map((meta) => meta.summary.cwd))].slice(0, 4);
46
+
47
+ useEffect(() => {
48
+ const typed = cwd.trim();
49
+ // Any typing is worth a lookup now: the hub completes a path being drilled
50
+ // into and searches for anything else, so a bare folder name finds itself.
51
+ if (typed === '') {
52
+ setSuggestions([]);
53
+ return;
54
+ }
55
+ let stale = false;
56
+ const timer = setTimeout(async () => {
57
+ const found = await searchDirectories(typed);
58
+ if (stale) return;
59
+ // The path already typed in full is not a suggestion, only its siblings are.
60
+ setSuggestions(found.filter((directory) => directory !== typed));
61
+ setHighlight(NO_HIGHLIGHT);
62
+ }, SUGGESTION_DEBOUNCE_MS);
63
+ return () => {
64
+ stale = true;
65
+ clearTimeout(timer);
66
+ };
67
+ }, [cwd]);
68
+
69
+ const pick = (directory: string): void => {
70
+ setCwd(`${directory}${PATH_SEPARATOR}`);
71
+ cwdInput.current?.focus();
72
+ };
73
+
74
+ const submit = async (): Promise<void> => {
75
+ const directory = cwd.trim().replace(TRAILING_SEPARATORS, '') || PATH_SEPARATOR;
76
+ if (busy || !cwd.trim()) return;
77
+ setBusy(true);
78
+ setError('');
79
+ const outcome = await createSession({ cwd: directory, name: name.trim() || undefined });
80
+ if ('sessionId' in outcome) {
81
+ // Navigating before the hub's upsert lands would bounce off the
82
+ // unknown-session fallback; wait until the rail knows the session.
83
+ const appeared = await waitForSession(outcome.sessionId);
84
+ if (appeared) {
85
+ onClose();
86
+ await navigate({ to: '/session/$sessionId', params: { sessionId: outcome.sessionId } });
87
+ return;
88
+ }
89
+ setError('The session was created but has not appeared yet; it will show up in the rail.');
90
+ setBusy(false);
91
+ return;
92
+ }
93
+ setError(outcome.error);
94
+ setBusy(false);
95
+ };
96
+
97
+ const onCwdKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
98
+ if (event.key === 'ArrowDown' && suggestions.length > 0) {
99
+ event.preventDefault();
100
+ setHighlight((current) => (current + 1) % suggestions.length);
101
+ return;
102
+ }
103
+ if (event.key === 'ArrowUp' && suggestions.length > 0) {
104
+ event.preventDefault();
105
+ setHighlight((current) => (current <= 0 ? suggestions.length - 1 : current - 1));
106
+ return;
107
+ }
108
+ if (event.key === 'Enter') {
109
+ const chosen = highlight === NO_HIGHLIGHT ? undefined : suggestions[highlight];
110
+ if (chosen !== undefined) pick(chosen);
111
+ else void submit();
112
+ }
113
+ };
114
+
115
+ return (
116
+ <Dialog
117
+ open
118
+ onOpenChange={(next) => {
119
+ if (!next) onClose();
120
+ }}
121
+ >
122
+ <DialogContent width="md" data-testid="new-session-dialog" aria-describedby={undefined} className="w-[440px]">
123
+ <DialogHeader>
124
+ <DialogTitle>new session</DialogTitle>
125
+ </DialogHeader>
126
+ <DialogBody>
127
+ <label className="flex flex-col gap-1">
128
+ <span className="text-[10px] text-doom-faint">
129
+ working directory <span className="text-doom-faint/70">(type a folder name or paste a path)</span>
130
+ </span>
131
+ <Input
132
+ ref={cwdInput}
133
+ data-testid="new-session-cwd"
134
+ value={cwd}
135
+ autoFocus
136
+ onChange={(event) => setCwd(event.target.value)}
137
+ onKeyDown={onCwdKeyDown}
138
+ placeholder="agirepo, or /absolute/path/to/project"
139
+ />
140
+ </label>
141
+ {suggestions.length > 0 ? (
142
+ <div
143
+ role="listbox"
144
+ aria-label="matching directories"
145
+ data-testid="new-session-suggestions"
146
+ className="max-h-40 overflow-y-auto rounded border border-doom-border bg-doom-deep p-1"
147
+ >
148
+ {suggestions.map((directory, index) => (
149
+ <OptionRow
150
+ key={directory}
151
+ density="compact"
152
+ active={index === highlight}
153
+ data-testid="new-session-suggestion"
154
+ data-highlighted={index === highlight}
155
+ title={directory}
156
+ onMouseDown={(event) => event.preventDefault()}
157
+ onMouseEnter={() => setHighlight(index)}
158
+ onClick={() => pick(directory)}
159
+ className="w-full px-2.5 py-1 text-[11px]"
160
+ >
161
+ {directory}
162
+ </OptionRow>
163
+ ))}
164
+ </div>
165
+ ) : null}
166
+ {recentCwds.length > 0 ? (
167
+ <div className="flex flex-wrap gap-1">
168
+ {recentCwds.map((recent) => (
169
+ <Button
170
+ key={recent}
171
+ variant="outline"
172
+ size="xs"
173
+ data-testid="new-session-recent"
174
+ onClick={() => setCwd(recent)}
175
+ className="font-normal"
176
+ >
177
+ {recent}
178
+ </Button>
179
+ ))}
180
+ </div>
181
+ ) : null}
182
+ <label className="flex flex-col gap-1">
183
+ <span className="text-[10px] text-doom-faint">name (optional)</span>
184
+ <Input
185
+ data-testid="new-session-name"
186
+ value={name}
187
+ onChange={(event) => setName(event.target.value)}
188
+ onKeyDown={(event) => {
189
+ if (event.key === 'Enter') void submit();
190
+ }}
191
+ placeholder="untitled"
192
+ />
193
+ </label>
194
+ {error ? (
195
+ // A spawn failure arrives as the child's whole stderr, stack trace
196
+ // and all. It is worth showing (it names the real cause) but not
197
+ // worth resizing the dialog for, so it lives in its own scroller.
198
+ <pre
199
+ data-testid="new-session-error"
200
+ className="max-h-28 overflow-y-auto rounded border border-doom-edge-red bg-doom-tint-red/40 px-2.5 py-2 text-[10px] leading-relaxed whitespace-pre-wrap break-words text-doom-red"
201
+ >
202
+ {error}
203
+ </pre>
204
+ ) : null}
205
+ <DialogFooter>
206
+ <Button variant="outline" data-testid="new-session-cancel" onClick={onClose}>
207
+ cancel
208
+ </Button>
209
+ <Button
210
+ variant="primary"
211
+ data-testid="new-session-create"
212
+ onClick={() => void submit()}
213
+ disabled={busy || !cwd.trim()}
214
+ >
215
+ {busy ? 'creating…' : 'create'}
216
+ </Button>
217
+ </DialogFooter>
218
+ </DialogBody>
219
+ </DialogContent>
220
+ </Dialog>
221
+ );
222
+ }