@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,58 @@
1
+ import type { SessionPhase } from '../../types/hub.ts';
2
+
3
+ /** The rail's view of one session's attach state; offline means the page lost the hub. */
4
+ export type AttachPhase = 'offline' | 'connecting' | 'attached' | 'refused' | 'detached' | 'closed';
5
+
6
+ export interface StatusLineInput {
7
+ attach: AttachPhase;
8
+ phase: SessionPhase;
9
+ /** ISO 8601 timestamp of the last phase change. */
10
+ phaseSince: string;
11
+ awaitingInput: boolean;
12
+ everPrompted: boolean;
13
+ /** Set once any run finished; a settled session is not "fresh" even unprompted. */
14
+ lastSettledAt?: string;
15
+ }
16
+
17
+ const MINUTE_MS = 60_000;
18
+ const HOUR_MS = 3_600_000;
19
+
20
+ export function formatRunDuration(elapsedMs: number): string {
21
+ if (elapsedMs < MINUTE_MS) return '<1m';
22
+ if (elapsedMs < HOUR_MS) return `${Math.floor(elapsedMs / MINUTE_MS)}m`;
23
+ const hours = Math.floor(elapsedMs / HOUR_MS);
24
+ const minutes = Math.floor((elapsedMs % HOUR_MS) / MINUTE_MS);
25
+ return `${hours}h ${String(minutes).padStart(2, '0')}m`;
26
+ }
27
+
28
+ /**
29
+ * The one mapping from session facts to the rail's status copy.
30
+ *
31
+ * Priority order: a refusal outranks everything because nothing else the card
32
+ * says can be trusted while another client holds the session; a question to
33
+ * the user outranks "running" because the run is blocked on them.
34
+ */
35
+ export function sessionStatusLine(input: StatusLineInput, now: number): string {
36
+ if (input.attach === 'refused') return 'another cockpit holds this session';
37
+ if (input.awaitingInput) return 'waiting for your input';
38
+ if (input.phase !== 'idle') {
39
+ const since = Date.parse(input.phaseSince);
40
+ return `running · ${formatRunDuration(Number.isFinite(since) ? Math.max(0, now - since) : 0)}`;
41
+ }
42
+ if (!input.everPrompted && input.lastSettledAt === undefined) return 'fresh session · nothing sent yet';
43
+ return 'done · waiting for you';
44
+ }
45
+
46
+ /** Sessions counted as running by the rail header and the top bar chip. */
47
+ export function runningCount(phases: Iterable<SessionPhase>): number {
48
+ let count = 0;
49
+ for (const phase of phases) if (phase !== 'idle') count += 1;
50
+ return count;
51
+ }
52
+
53
+ /** Shortens a home-rooted cwd the way a shell prompt would. */
54
+ export function abbreviateCwd(cwd: string): string {
55
+ const match = /^\/(?:Users|home)\/[^/]+/u.exec(cwd);
56
+ if (!match) return cwd;
57
+ return `~${cwd.slice(match[0].length)}`;
58
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The settings menu, as data: the rail's gear and the settings page both
3
+ * need to name a section, and neither feature may import the other.
4
+ */
5
+ export interface SettingsSection {
6
+ id: string;
7
+ label: string;
8
+ detail: string;
9
+ }
10
+
11
+ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
12
+ { id: 'providers', label: 'providers', detail: 'sign in to the model providers Pi can use' },
13
+ { id: 'appearance', label: 'appearance', detail: 'pick the theme the cockpit renders with' },
14
+ { id: 'plugins', label: 'plugins', detail: 'the web plugins this bundle carries and what their install resolved' },
15
+ ];
16
+
17
+ export const DEFAULT_SETTINGS_SECTION = SETTINGS_SECTIONS[0].id;
18
+
19
+ export function settingsSection(id: string | undefined): SettingsSection | undefined {
20
+ return SETTINGS_SECTIONS.find((section) => section.id === id);
21
+ }
@@ -0,0 +1,90 @@
1
+ export interface Selection {
2
+ /** Empty when the session runs without a profile, which is a valid choice. */
3
+ profile: string;
4
+ majorMode: string;
5
+ domains: string[];
6
+ /** True while a mode switch is journaled but not yet applied. */
7
+ pending: boolean;
8
+ }
9
+
10
+ export const emptySelection: Selection = { profile: '', majorMode: '', domains: [], pending: false };
11
+
12
+ interface Segment {
13
+ text: string;
14
+ rgb: [number, number, number] | null;
15
+ }
16
+
17
+ // The escape is the point of this parser, so the control character is
18
+ // deliberate; spelled as a Unicode escape to keep it readable.
19
+ // oxlint-disable-next-line no-control-regex
20
+ const SGR = /\u001B\[([0-9;]*)m/gu;
21
+
22
+ /** Splits an ANSI string into runs of text carrying the truecolor in force. */
23
+ export function ansiSegments(input: string): Segment[] {
24
+ const segments: Segment[] = [];
25
+ let current: [number, number, number] | null = null;
26
+ let index = 0;
27
+
28
+ SGR.lastIndex = 0;
29
+ for (let match = SGR.exec(input); match !== null; match = SGR.exec(input)) {
30
+ const text = input.slice(index, match.index);
31
+ if (text) segments.push({ text, rgb: current });
32
+ const codes = match[1].split(';').map((code) => Number.parseInt(code, 10));
33
+ if (codes[0] === 38 && codes[1] === 2) current = [codes[2], codes[3], codes[4]];
34
+ else if (codes[0] === 39 || codes[0] === 0 || Number.isNaN(codes[0])) current = null;
35
+ index = match.index + match[0].length;
36
+ }
37
+ const tail = input.slice(index);
38
+ if (tail) segments.push({ text: tail, rgb: current });
39
+ return segments;
40
+ }
41
+
42
+ export function stripAnsi(input: string): string {
43
+ return input.replace(SGR, '');
44
+ }
45
+
46
+ // The values doom-pi-dark.json binds to `warning` and `accent`. DoomPi ships one
47
+ // theme, so comparing against both and taking the nearer one reports pending
48
+ // accurately, and falls back to "not pending" when it cannot tell.
49
+ const WARNING: [number, number, number] = [236, 190, 123];
50
+ const ACCENT: [number, number, number] = [81, 175, 239];
51
+
52
+ function distance(a: [number, number, number], b: [number, number, number]): number {
53
+ return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
54
+ }
55
+
56
+ /**
57
+ * Reads DoomPi's footer status line.
58
+ *
59
+ * The line is the only place the session publishes its selection, so this
60
+ * parses the shape `*profile*:[major-mode]:domains` structurally and uses the
61
+ * colour of the mode segment solely to tell a pending switch from a settled one.
62
+ */
63
+ export function parseSelection(statusText: string): Selection {
64
+ const segments = ansiSegments(statusText);
65
+ const plain = stripAnsi(statusText).trim();
66
+ if (!plain) return emptySelection;
67
+
68
+ const profileMatch = /\*([^*]+)\*/u.exec(plain);
69
+ const modeMatch = /\[([^\]]+)\]/u.exec(plain);
70
+
71
+ const afterMode = modeMatch ? plain.slice(plain.indexOf(modeMatch[0]) + modeMatch[0].length) : '';
72
+ const domains = afterMode
73
+ .replace(/^:/u, '')
74
+ .split(',')
75
+ .map((entry) => entry.trim())
76
+ .filter(Boolean);
77
+
78
+ const modeSegment = segments.find((segment) => segment.text.includes('['));
79
+ const pending =
80
+ modeSegment?.rgb !== undefined && modeSegment?.rgb !== null
81
+ ? distance(modeSegment.rgb, WARNING) < distance(modeSegment.rgb, ACCENT)
82
+ : false;
83
+
84
+ return {
85
+ profile: profileMatch ? profileMatch[1] : '',
86
+ majorMode: modeMatch ? modeMatch[1] : '',
87
+ domains,
88
+ pending,
89
+ };
90
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ type ThreadRenderer = (sessionId: string, threadId: string) => ReactNode;
4
+
5
+ let render: ThreadRenderer | undefined;
6
+
7
+ /**
8
+ * Holds the host's thread view for the plugin props to render.
9
+ *
10
+ * The view is a feature built on the timeline, while the props are assembled
11
+ * below it; the composition root binds the one to the other, the same
12
+ * indirection the transport uses for the socket.
13
+ */
14
+ export function bindThreadRenderer(renderer: ThreadRenderer): void {
15
+ render = renderer;
16
+ }
17
+
18
+ export function releaseThreadRenderer(): void {
19
+ render = undefined;
20
+ }
21
+
22
+ /** The bound view, or nothing while none is bound (before the app mounts, and in unit tests). */
23
+ export function renderThread(sessionId: string, threadId: string): ReactNode {
24
+ return render?.(sessionId, threadId) ?? null;
25
+ }
@@ -0,0 +1,21 @@
1
+ import type { ToolMessageRenderProps, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
2
+ import type { ToolEntry } from './sessionModel.ts';
3
+
4
+ /** The props a tool's message renderer receives: the slot actions plus the call and its newest result. */
5
+ export function toolMessageProps(
6
+ slotProps: WebPluginSlotProps,
7
+ entry: ToolEntry,
8
+ statuses: Readonly<Record<string, string>>,
9
+ ): ToolMessageRenderProps {
10
+ return {
11
+ ...slotProps,
12
+ toolCallId: entry.toolCallId,
13
+ toolName: entry.name,
14
+ args: entry.args,
15
+ statuses,
16
+ result: entry.result,
17
+ output: entry.output,
18
+ running: entry.running,
19
+ isError: entry.isError,
20
+ };
21
+ }
@@ -0,0 +1,30 @@
1
+ import { sessionCommand } from '../../types/hub.ts';
2
+
3
+ type Frame = Record<string, unknown>;
4
+
5
+ let send: ((frame: object) => void) | undefined;
6
+
7
+ /**
8
+ * Holds the live socket sender.
9
+ *
10
+ * The socket is owned by the composition root but commands are issued from
11
+ * feature code, and this keeps that one indirection explicit instead of
12
+ * threading a sender through every component.
13
+ */
14
+ export function bindTransport(sender: (frame: object) => void): void {
15
+ send = sender;
16
+ }
17
+
18
+ export function releaseTransport(): void {
19
+ send = undefined;
20
+ }
21
+
22
+ /** Sends one command frame to a session's agent, enveloped for the hub. */
23
+ export function sendFrame(sessionId: string, frame: Frame): void {
24
+ send?.(sessionCommand(sessionId, frame));
25
+ }
26
+
27
+ /** Sends one hub-level frame (subscribe, unsubscribe) as-is. */
28
+ export function sendHubFrame(frame: object): void {
29
+ send?.(frame);
30
+ }
@@ -0,0 +1,73 @@
1
+ type Frame = Record<string, unknown>;
2
+
3
+ export interface SessionSocket {
4
+ send(frame: object): void;
5
+ close(): void;
6
+ }
7
+
8
+ export interface SessionSocketHandlers {
9
+ onFrame(frame: Frame): void;
10
+ onOpen(): void;
11
+ onClose(): void;
12
+ }
13
+
14
+ const RECONNECT_MS = 700;
15
+
16
+ export function sessionSocketUrl(location: { protocol: string; host: string }): string {
17
+ const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
18
+ return `${scheme}//${location.host}/api/session`;
19
+ }
20
+
21
+ /**
22
+ * Keeps a WebSocket to the bridge open for the life of the page.
23
+ *
24
+ * Reconnecting is unconditional because the bridge, not the browser, decides
25
+ * whether the session is still there; the page's job is to keep offering.
26
+ */
27
+ export function createSessionSocket(url: string, handlers: SessionSocketHandlers): SessionSocket {
28
+ let socket: WebSocket | undefined;
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ let stopped = false;
31
+
32
+ const open = (): void => {
33
+ if (stopped) return;
34
+ const next = new WebSocket(url);
35
+ socket = next;
36
+
37
+ next.addEventListener('open', () => handlers.onOpen());
38
+
39
+ next.addEventListener('message', (event: MessageEvent<string>) => {
40
+ if (typeof event.data !== 'string') return;
41
+ let parsed: unknown;
42
+ try {
43
+ parsed = JSON.parse(event.data);
44
+ } catch {
45
+ return;
46
+ }
47
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
48
+ handlers.onFrame(parsed as Frame);
49
+ }
50
+ });
51
+
52
+ next.addEventListener('close', () => {
53
+ handlers.onClose();
54
+ if (stopped) return;
55
+ timer = setTimeout(open, RECONNECT_MS);
56
+ });
57
+
58
+ next.addEventListener('error', () => next.close());
59
+ };
60
+
61
+ open();
62
+
63
+ return {
64
+ send(frame) {
65
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
66
+ },
67
+ close() {
68
+ stopped = true;
69
+ if (timer) clearTimeout(timer);
70
+ socket?.close();
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,17 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { Providers } from './app/Providers.tsx';
4
+ import { applyStoredTheme } from './stores/themeStore.ts';
5
+ import './styles/app.css';
6
+
7
+ const container = document.getElementById('root');
8
+ if (!container) throw new Error('The cockpit needs a #root element.');
9
+
10
+ // Before the first paint, so the remembered theme never flashes the default.
11
+ applyStoredTheme();
12
+
13
+ createRoot(container).render(
14
+ <StrictMode>
15
+ <Providers />
16
+ </StrictMode>,
17
+ );
Binary file
Binary file
Binary file
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="DoomPi">
2
+ <rect width="32" height="32" rx="7" fill="#1c1f24"/>
3
+ <g fill="#ecbe7b">
4
+ <path d="M7.0 10.0 Q6.2 4.2 9.3 3.0 Q10.4 6.3 12.1 9.6 Z"/>
5
+ <path d="M25.0 10.0 Q25.8 4.2 22.7 3.0 Q21.6 6.3 19.9 9.6 Z"/>
6
+ </g>
7
+ <g fill="#c678dd">
8
+ <rect x="3.2" y="8.6" width="25.6" height="7.6" rx="3.4"/>
9
+ <rect x="6.9" y="14.0" width="5.5" height="13.0" rx="2.5"/>
10
+ <rect x="19.6" y="14.0" width="5.5" height="13.0" rx="2.5"/>
11
+ </g>
12
+ <g fill="#1c1f24">
13
+ <circle cx="10.9" cy="12.3" r="1.9"/>
14
+ <circle cx="21.1" cy="12.3" r="1.9"/>
15
+ </g>
16
+ </svg>
Binary file
@@ -0,0 +1,98 @@
1
+ import { Button } from '@agimon-ai/doompi-web-components';
2
+ import { useStore } from '@tanstack/react-store';
3
+ import { useNavigate, useParams } from '@tanstack/react-router';
4
+ import { useEffect } from 'react';
5
+ import { PluginSurface } from '../components/PluginSurface.tsx';
6
+ import { usePluginSlotProps } from '../stores/usePluginSlotProps.ts';
7
+ import { ActivityDock } from '../features/activity/ActivityDock.tsx';
8
+ import { RefusedCard } from '../features/connection/RefusedCard.tsx';
9
+ import { DialogOverlay } from '../features/dialogs/DialogOverlay.tsx';
10
+ import { CommandPalette } from '../features/leader/CommandPalette.tsx';
11
+ import { SelectionBar } from '../features/selection/SelectionBar.tsx';
12
+ import { Composer } from '../features/session/Composer.tsx';
13
+ import { SessionRail } from '../features/sessions/SessionRail.tsx';
14
+ import { Timeline } from '../features/session/Timeline.tsx';
15
+ import { TopBar } from '../features/status/TopBar.tsx';
16
+ import { HOST_SLOTS, webTabs } from '../lib/pluginRegistry.ts';
17
+ import { sessionsStore, setActiveSession } from '../stores/sessionsStore.ts';
18
+ import { findTransientTab, transientTabsStore } from '../stores/transientTabsStore.ts';
19
+ import { setDockOpen, uiStore } from '../stores/uiStore.ts';
20
+
21
+ export function CockpitPage() {
22
+ const dockOpen = useStore(uiStore, (state) => state.dockOpen);
23
+ const { sessionId, tabId } = useParams({ strict: false });
24
+ const navigate = useNavigate();
25
+ const slotProps = usePluginSlotProps(sessionId ?? null);
26
+ const order = useStore(sessionsStore, (state) => state.order);
27
+ const hydrated = useStore(sessionsStore, (state) => state.hydrated);
28
+ // A declared tab first, then one a plugin opened at runtime for this session.
29
+ const transientTab = useStore(transientTabsStore, (state) => findTransientTab(state, sessionId, tabId));
30
+ const tab = (tabId === undefined ? undefined : webTabs().find((entry) => entry.id === tabId)) ?? transientTab;
31
+
32
+ // The route is the source of focus; the store follows it.
33
+ useEffect(() => {
34
+ setActiveSession(sessionId ?? null);
35
+ }, [sessionId]);
36
+
37
+ // Landing on / focuses the first session; a focused session that
38
+ // disappeared falls back the same way, and an unknown tab id falls back to
39
+ // the conversation. Before hydration the URL is left alone so a deep link
40
+ // survives the socket connecting.
41
+ useEffect(() => {
42
+ if (!hydrated) return;
43
+ if (sessionId !== undefined && order.includes(sessionId)) {
44
+ if (tabId !== undefined && tab === undefined) {
45
+ void navigate({ to: '/session/$sessionId', params: { sessionId }, replace: true });
46
+ }
47
+ return;
48
+ }
49
+ const first = order[0];
50
+ if (first !== undefined) {
51
+ void navigate({ to: '/session/$sessionId', params: { sessionId: first }, replace: true });
52
+ } else if (sessionId !== undefined) {
53
+ void navigate({ to: '/', replace: true });
54
+ }
55
+ }, [hydrated, sessionId, tabId, tab, order, navigate]);
56
+
57
+ return (
58
+ <div data-testid="cockpit" className="relative flex h-full overflow-hidden">
59
+ <aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-doom-border bg-doom-rail">
60
+ <SessionRail />
61
+ </aside>
62
+ <main className="flex min-w-0 flex-1 flex-col">
63
+ <TopBar view={tab?.id ?? 'conversation'} />
64
+ {/* The composer and the selection bar address the session's agent, so
65
+ they belong to the conversation alone. A plugin panel is a view of
66
+ something else, and a prompt box under it would send somewhere the
67
+ reader is not looking. */}
68
+ {tab ? (
69
+ <tab.panel {...slotProps} />
70
+ ) : (
71
+ <>
72
+ <Timeline />
73
+ <Composer />
74
+ <SelectionBar />
75
+ </>
76
+ )}
77
+ </main>
78
+ {dockOpen ? (
79
+ <ActivityDock onClose={() => setDockOpen(false)} />
80
+ ) : (
81
+ <Button
82
+ variant="ghost"
83
+ data-testid="activity-show"
84
+ title="show the activity dock"
85
+ onClick={() => setDockOpen(true)}
86
+ className="h-auto shrink-0 rounded-none border-l border-doom-border bg-doom-rail px-2 py-3 text-[9px] tracking-widest text-doom-dim hover:bg-doom-rail"
87
+ style={{ writingMode: 'vertical-rl' }}
88
+ >
89
+ ACTIVITY
90
+ </Button>
91
+ )}
92
+ <DialogOverlay />
93
+ <RefusedCard />
94
+ <CommandPalette />
95
+ <PluginSurface slot={HOST_SLOTS.overlay} sessionId={sessionId ?? null} />
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,60 @@
1
+ import { Button } from '@agimon-ai/doompi-web-components';
2
+ import { Link, useNavigate, useParams } from '@tanstack/react-router';
3
+ import { useStore } from '@tanstack/react-store';
4
+ import { useEffect } from 'react';
5
+ import { SessionRail } from '../features/sessions/SessionRail.tsx';
6
+ import { AppearanceSettings } from '../features/settings/AppearanceSettings.tsx';
7
+ import { PluginSettings } from '../features/settings/PluginSettings.tsx';
8
+ import { ProviderSettings } from '../features/settings/ProviderSettings.tsx';
9
+ import { SettingsMenu } from '../features/settings/SettingsMenu.tsx';
10
+ import { DEFAULT_SETTINGS_SECTION, settingsSection } from '../lib/settingsSections.ts';
11
+ import { sessionsStore } from '../stores/sessionsStore.ts';
12
+
13
+ /**
14
+ * The settings pages, in the cockpit's frame: the rail stays so a session is
15
+ * one click away, and the main column becomes a section menu and its page.
16
+ * An unknown section falls back to the first one.
17
+ */
18
+ export function SettingsPage() {
19
+ const { section } = useParams({ strict: false });
20
+ const navigate = useNavigate();
21
+ const activeId = useStore(sessionsStore, (state) => state.activeId);
22
+ const current = settingsSection(section);
23
+
24
+ useEffect(() => {
25
+ if (current) return;
26
+ void navigate({ to: '/settings/$section', params: { section: DEFAULT_SETTINGS_SECTION }, replace: true });
27
+ }, [current, navigate]);
28
+
29
+ return (
30
+ <div data-testid="settings" className="relative flex h-full overflow-hidden">
31
+ <aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-doom-border bg-doom-rail">
32
+ <SessionRail />
33
+ </aside>
34
+ <main className="flex min-w-0 flex-1 flex-col">
35
+ <header className="flex h-12 shrink-0 items-center justify-between border-b border-doom-border px-5">
36
+ <span className="text-[13px] font-bold text-doom-hi">settings</span>
37
+ <Button asChild variant="ghost" size="sm">
38
+ {activeId !== null ? (
39
+ <Link to="/session/$sessionId" params={{ sessionId: activeId }} data-testid="settings-close">
40
+ back to session
41
+ </Link>
42
+ ) : (
43
+ <Link to="/" data-testid="settings-close">
44
+ back
45
+ </Link>
46
+ )}
47
+ </Button>
48
+ </header>
49
+ <div className="flex min-h-0 flex-1">
50
+ <SettingsMenu active={current?.id} />
51
+ <section data-testid="settings-content" className="min-w-0 flex-1 overflow-y-auto px-6 py-5">
52
+ {current?.id === 'providers' ? <ProviderSettings /> : null}
53
+ {current?.id === 'appearance' ? <AppearanceSettings /> : null}
54
+ {current?.id === 'plugins' ? <PluginSettings /> : null}
55
+ </section>
56
+ </div>
57
+ </main>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,49 @@
1
+ import { createRootRoute, createRoute, Outlet } from '@tanstack/react-router';
2
+ import { CockpitPage } from './CockpitPage.tsx';
3
+ import { SettingsPage } from './SettingsPage.tsx';
4
+
5
+ const rootRoute = createRootRoute({
6
+ component: () => <Outlet />,
7
+ });
8
+
9
+ const indexRoute = createRoute({
10
+ getParentRoute: () => rootRoute,
11
+ path: '/',
12
+ component: CockpitPage,
13
+ });
14
+
15
+ const sessionRoute = createRoute({
16
+ getParentRoute: () => rootRoute,
17
+ path: '/session/$sessionId',
18
+ component: CockpitPage,
19
+ });
20
+
21
+ // Every plugin tab lives under one parameterized segment, so the route tree
22
+ // stays static while the tab set comes from the plugin registry.
23
+ const sessionTabRoute = createRoute({
24
+ getParentRoute: () => rootRoute,
25
+ path: '/session/$sessionId/$tabId',
26
+ component: CockpitPage,
27
+ });
28
+
29
+ // Settings keep the same shape: one static segment, one parameter for the
30
+ // section, so adding a page never touches the route tree.
31
+ const settingsRoute = createRoute({
32
+ getParentRoute: () => rootRoute,
33
+ path: '/settings',
34
+ component: SettingsPage,
35
+ });
36
+
37
+ const settingsSectionRoute = createRoute({
38
+ getParentRoute: () => rootRoute,
39
+ path: '/settings/$section',
40
+ component: SettingsPage,
41
+ });
42
+
43
+ export const routeTree = rootRoute.addChildren([
44
+ indexRoute,
45
+ sessionRoute,
46
+ sessionTabRoute,
47
+ settingsRoute,
48
+ settingsSectionRoute,
49
+ ]);
@@ -0,0 +1,78 @@
1
+ import { Store } from '@tanstack/store';
2
+
3
+ /** A selection-bar axis name: the built-in 'mode'/'domains', or a plugin-declared axis such as 'profile'. */
4
+ export type MenuKind = string;
5
+
6
+ export interface MenuClaim {
7
+ /** The agent dialog rendered as the bar's popover instead of the modal. */
8
+ dialogId: string;
9
+ menu: MenuKind;
10
+ }
11
+
12
+ export interface MenuState {
13
+ /** Selection-bar button whose command is in flight, if any. */
14
+ pending: MenuKind | null;
15
+ /** When it was clicked; a stale anchor must not restyle an unrelated dialog. */
16
+ at: number;
17
+ /** The dialog the last click claimed, so the bar and the overlay agree who renders it. */
18
+ claimed: MenuClaim | null;
19
+ }
20
+
21
+ const MENU_FRESH_MS = 10_000;
22
+
23
+ export const menuStore = new Store<MenuState>({ pending: null, at: 0, claimed: null });
24
+
25
+ let expiry: ReturnType<typeof setTimeout> | undefined;
26
+
27
+ /**
28
+ * Remembers which selection-bar button asked the agent a question.
29
+ *
30
+ * The agent answers with an ordinary select dialog; this is the only signal
31
+ * that lets the bar render it as its popover menu rather than a centered
32
+ * modal. The button shows a pending state until the dialog arrives, the run
33
+ * settles, or the anchor goes stale.
34
+ */
35
+ export function setPendingMenu(kind: MenuKind): void {
36
+ menuStore.setState((state) => ({ ...state, pending: kind, at: Date.now() }));
37
+ if (expiry !== undefined) clearTimeout(expiry);
38
+ expiry = setTimeout(clearPendingMenu, MENU_FRESH_MS);
39
+ }
40
+
41
+ export function clearPendingMenu(): void {
42
+ if (expiry !== undefined) clearTimeout(expiry);
43
+ expiry = undefined;
44
+ menuStore.setState((state) => (state.pending === null ? state : { ...state, pending: null, at: 0 }));
45
+ }
46
+
47
+ /** The menu behind an arriving dialog, or null when none was asked for recently. */
48
+ export function pendingMenuFor(now: number): MenuKind | null {
49
+ const { pending, at } = menuStore.state;
50
+ if (pending === null || now - at > MENU_FRESH_MS) return null;
51
+ return pending;
52
+ }
53
+
54
+ /**
55
+ * Spends the pending anchor on an arriving select dialog. Called once per
56
+ * dialog, at frame time, so every surface reads the same verdict on its
57
+ * first render and the modal never flashes before the popover takes over.
58
+ */
59
+ export function claimDialogMenu(dialogId: string, now = Date.now()): MenuClaim | null {
60
+ const menu = pendingMenuFor(now);
61
+ const claimed = menu === null ? null : { dialogId, menu };
62
+ clearPendingMenu();
63
+ menuStore.setState((state) => ({ ...state, claimed }));
64
+ return claimed;
65
+ }
66
+
67
+ /** Forgets a claim once its dialog is gone. */
68
+ export function releaseDialogMenu(dialogId: string): void {
69
+ menuStore.setState((state) =>
70
+ state.claimed !== null && state.claimed.dialogId === dialogId ? { ...state, claimed: null } : state,
71
+ );
72
+ }
73
+
74
+ /** Test seam. */
75
+ export function resetMenuStore(): void {
76
+ clearPendingMenu();
77
+ menuStore.setState(() => ({ pending: null, at: 0, claimed: null }));
78
+ }