khal-os 1.260324.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (408) hide show
  1. package/.env.example +23 -0
  2. package/.genie/mailbox/cli-sent.jsonl +3 -0
  3. package/.genie/mailbox/ds1-wave2-engineer-1.json +15 -0
  4. package/.genie/mailbox/ds1-wave2-engineer-2.json +15 -0
  5. package/.genie/mailbox/ds1-wave2-engineer-3.json +15 -0
  6. package/.genie/state/os-observability.json +39 -0
  7. package/.genie/state/tmux-control-mode-terminal.json +28 -0
  8. package/.genie/wishes/genieos-one-theme/WISH.md +417 -0
  9. package/.genie/wishes/workos-prod-rbac/WISH.md +345 -0
  10. package/.github/workflows/ci.yml +39 -0
  11. package/.github/workflows/release.yml +78 -0
  12. package/.github/workflows/version.yml +122 -0
  13. package/.husky/pre-commit +1 -0
  14. package/.pnpm-approve-builds.json +1 -0
  15. package/CLAUDE.md +117 -0
  16. package/LICENSE +21 -0
  17. package/README.md +38 -0
  18. package/biome.json +124 -0
  19. package/bun.lock +1249 -0
  20. package/docs/workos-setup.md +116 -0
  21. package/ecosystem.config.cjs +26 -0
  22. package/instrumentation.ts +8 -0
  23. package/knip.json +35 -0
  24. package/nats.conf +7 -0
  25. package/next.config.ts +25 -0
  26. package/package.json +78 -0
  27. package/packages/dev3000-app/components.ts +12 -0
  28. package/packages/dev3000-app/manifest.ts +19 -0
  29. package/packages/dev3000-app/package.json +23 -0
  30. package/packages/dev3000-app/views/dev3000/Dev3000App.tsx +758 -0
  31. package/packages/dev3000-app/views/dev3000/ErrorsPanel.tsx +160 -0
  32. package/packages/dev3000-app/views/dev3000/dev3000-context.tsx +21 -0
  33. package/packages/dev3000-app/views/dev3000/index.ts +4 -0
  34. package/packages/dev3000-app/views/dev3000/schema.ts +55 -0
  35. package/packages/dev3000-app/views/dev3000/service/index.ts +358 -0
  36. package/packages/dev3000-app/views/dev3000/service/runtime +1 -0
  37. package/packages/dev3000-app/views/dev3000/subjects.ts +9 -0
  38. package/packages/dev3000-app/views/dev3000/types.ts +77 -0
  39. package/packages/files-app/components.ts +12 -0
  40. package/packages/files-app/manifest.ts +19 -0
  41. package/packages/files-app/package.json +23 -0
  42. package/packages/files-app/views/files/ContextMenu.tsx +151 -0
  43. package/packages/files-app/views/files/DeleteConfirmDialog.tsx +39 -0
  44. package/packages/files-app/views/files/FileItem.tsx +128 -0
  45. package/packages/files-app/views/files/FilesApp.tsx +509 -0
  46. package/packages/files-app/views/files/FilesListView.tsx +201 -0
  47. package/packages/files-app/views/files/FilesToolbar.tsx +117 -0
  48. package/packages/files-app/views/files/GridView.tsx +90 -0
  49. package/packages/files-app/views/files/InlineInput.tsx +131 -0
  50. package/packages/files-app/views/files/UploadOverlay.tsx +61 -0
  51. package/packages/files-app/views/files/schema.ts +49 -0
  52. package/packages/files-app/views/files/service/index.ts +184 -0
  53. package/packages/files-app/views/files/service/runtime +1 -0
  54. package/packages/files-app/views/files/use-files.ts +201 -0
  55. package/packages/files-app/views/files/use-upload.ts +105 -0
  56. package/packages/genie-app/components.ts +12 -0
  57. package/packages/genie-app/lib/subjects.ts +87 -0
  58. package/packages/genie-app/manifest.ts +19 -0
  59. package/packages/genie-app/package.json +29 -0
  60. package/packages/genie-app/views/genie/service/agent-lifecycle.ts +136 -0
  61. package/packages/genie-app/views/genie/service/cli.ts +114 -0
  62. package/packages/genie-app/views/genie/service/comms.ts +141 -0
  63. package/packages/genie-app/views/genie/service/directory.ts +167 -0
  64. package/packages/genie-app/views/genie/service/index.ts +219 -0
  65. package/packages/genie-app/views/genie/service/system.ts +123 -0
  66. package/packages/genie-app/views/genie/service/teams.ts +191 -0
  67. package/packages/genie-app/views/genie/service/terminal-proxy.ts +184 -0
  68. package/packages/genie-app/views/genie/service/tmux-control.ts +318 -0
  69. package/packages/genie-app/views/genie/service/wishes.ts +270 -0
  70. package/packages/genie-app/views/genie/ui/GenieApp.tsx +5 -0
  71. package/packages/genie-app/views/genie/ui/PaneCard.tsx +307 -0
  72. package/packages/genie-app/views/genie/ui/Sidebar.tsx +212 -0
  73. package/packages/genie-app/views/genie/ui/TabBar.tsx +70 -0
  74. package/packages/genie-app/views/genie/ui/WorkspaceCanvas.tsx +343 -0
  75. package/packages/genie-app/views/genie/ui/XTermPane.tsx +306 -0
  76. package/packages/genie-app/views/genie/ui/hooks/useNatsAction.ts +54 -0
  77. package/packages/genie-app/views/genie/ui/hooks/useNatsRequest.ts +68 -0
  78. package/packages/genie-app/views/genie/ui/panels/AgentsPanel.tsx +399 -0
  79. package/packages/genie-app/views/genie/ui/panels/ChatPanel.tsx +351 -0
  80. package/packages/genie-app/views/genie/ui/panels/SystemPanel.tsx +195 -0
  81. package/packages/genie-app/views/genie/ui/panels/TeamsPanel.tsx +560 -0
  82. package/packages/genie-app/views/genie/ui/panels/WishesPanel.tsx +424 -0
  83. package/packages/nats-viewer-app/components.ts +12 -0
  84. package/packages/nats-viewer-app/manifest.ts +18 -0
  85. package/packages/nats-viewer-app/package.json +14 -0
  86. package/packages/nats-viewer-app/views/nats-viewer/ActiveSubs.tsx +34 -0
  87. package/packages/nats-viewer-app/views/nats-viewer/MessageLog.tsx +247 -0
  88. package/packages/nats-viewer-app/views/nats-viewer/NatsViewer.tsx +209 -0
  89. package/packages/nats-viewer-app/views/nats-viewer/PublishPanel.tsx +111 -0
  90. package/packages/nats-viewer-app/views/nats-viewer/RequestPanel.tsx +165 -0
  91. package/packages/nats-viewer-app/views/nats-viewer/Sidebar.tsx +59 -0
  92. package/packages/nats-viewer-app/views/nats-viewer/SubjectCatalog.tsx +63 -0
  93. package/packages/nats-viewer-app/views/nats-viewer/SubscribeInput.tsx +59 -0
  94. package/packages/nats-viewer-app/views/nats-viewer/index.ts +5 -0
  95. package/packages/nats-viewer-app/views/nats-viewer/nats-viewer-context.tsx +31 -0
  96. package/packages/nats-viewer-app/views/nats-viewer/types.ts +7 -0
  97. package/packages/nats-viewer-app/views/nats-viewer/use-message-buffer.ts +55 -0
  98. package/packages/os-cli/package.json +18 -0
  99. package/packages/os-cli/src/commands/events.ts +176 -0
  100. package/packages/os-cli/src/commands/logs.ts +96 -0
  101. package/packages/os-cli/src/commands/status.ts +53 -0
  102. package/packages/os-cli/src/commands/traces.ts +115 -0
  103. package/packages/os-cli/src/index.ts +15 -0
  104. package/packages/os-cli/src/lib/formatter.ts +123 -0
  105. package/packages/os-cli/src/lib/nats.ts +16 -0
  106. package/packages/os-cli/src/lib/trace-tree.ts +144 -0
  107. package/packages/os-cli/tsconfig.json +12 -0
  108. package/packages/os-sdk/package.json +27 -0
  109. package/packages/os-sdk/src/api/handler.ts +67 -0
  110. package/packages/os-sdk/src/config.ts +68 -0
  111. package/packages/os-sdk/src/db/factory.test.ts +42 -0
  112. package/packages/os-sdk/src/db/factory.ts +72 -0
  113. package/packages/os-sdk/src/db/migrate.ts +140 -0
  114. package/packages/os-sdk/src/db/provision.ts +44 -0
  115. package/packages/os-sdk/src/index.ts +36 -0
  116. package/packages/os-sdk/src/service/console-intercept.ts +60 -0
  117. package/packages/os-sdk/src/service/logger.ts +88 -0
  118. package/packages/os-sdk/src/service/o11y-streams.ts +88 -0
  119. package/packages/os-sdk/src/service/runtime.ts +259 -0
  120. package/packages/os-sdk/src/service/trace.ts +71 -0
  121. package/packages/os-sdk/tsconfig.json +16 -0
  122. package/packages/os-ui/package.json +13 -0
  123. package/packages/os-ui/src/index.ts +29 -0
  124. package/packages/os-ui/src/server.ts +4 -0
  125. package/packages/os-ui/tsconfig.json +19 -0
  126. package/packages/settings-app/components.ts +12 -0
  127. package/packages/settings-app/manifest.ts +18 -0
  128. package/packages/settings-app/package.json +14 -0
  129. package/packages/settings-app/views/settings/Settings.tsx +492 -0
  130. package/packages/terminal-app/components.ts +12 -0
  131. package/packages/terminal-app/manifest.ts +20 -0
  132. package/packages/terminal-app/package.json +23 -0
  133. package/packages/terminal-app/views/terminal/schema.ts +82 -0
  134. package/packages/terminal-app/views/terminal/service/index.ts +133 -0
  135. package/packages/terminal-app/views/terminal/service/runtime +1 -0
  136. package/packages/terminal-app/views/terminal/service/session.ts +290 -0
  137. package/packages/terminal-app/views/terminal/service/shell-hooks/bashrc-hook.sh +21 -0
  138. package/packages/terminal-app/views/terminal/types.ts +26 -0
  139. package/packages/terminal-app/views/terminal/ui/MultiTerminalApp.tsx +615 -0
  140. package/packages/terminal-app/views/terminal/ui/SplitDragHandle.tsx +91 -0
  141. package/packages/terminal-app/views/terminal/ui/SplitPaneRenderer.tsx +112 -0
  142. package/packages/terminal-app/views/terminal/ui/TerminalPane.tsx +478 -0
  143. package/packages/terminal-app/views/terminal/ui/TerminalTabBar.tsx +131 -0
  144. package/pnpm-workspace.yaml +9 -0
  145. package/postcss.config.mjs +7 -0
  146. package/public/file.svg +1 -0
  147. package/public/globe.svg +1 -0
  148. package/public/icons/code-server.svg +6 -0
  149. package/public/icons/default.svg +5 -0
  150. package/public/icons/dusk/1password.svg +1 -0
  151. package/public/icons/dusk/activity_monitor.svg +1 -0
  152. package/public/icons/dusk/app_store.svg +1 -0
  153. package/public/icons/dusk/atom.svg +1 -0
  154. package/public/icons/dusk/brave.svg +1 -0
  155. package/public/icons/dusk/calculator.svg +1 -0
  156. package/public/icons/dusk/calendar.svg +1 -0
  157. package/public/icons/dusk/chrome.svg +1 -0
  158. package/public/icons/dusk/chrome2.svg +1 -0
  159. package/public/icons/dusk/dashboard.svg +13 -0
  160. package/public/icons/dusk/discord.svg +1 -0
  161. package/public/icons/dusk/dropbox.svg +1 -0
  162. package/public/icons/dusk/electron.svg +1 -0
  163. package/public/icons/dusk/figma.svg +1 -0
  164. package/public/icons/dusk/finder.svg +1 -0
  165. package/public/icons/dusk/finder2.svg +1 -0
  166. package/public/icons/dusk/finder3.svg +1 -0
  167. package/public/icons/dusk/firefox.svg +1 -0
  168. package/public/icons/dusk/framer.svg +1 -0
  169. package/public/icons/dusk/gimp.svg +1 -0
  170. package/public/icons/dusk/github_desktop.svg +1 -0
  171. package/public/icons/dusk/hyper.svg +1 -0
  172. package/public/icons/dusk/hyper3.svg +1 -0
  173. package/public/icons/dusk/intellij.svg +1 -0
  174. package/public/icons/dusk/iterm2.svg +1 -0
  175. package/public/icons/dusk/itunes.svg +1 -0
  176. package/public/icons/dusk/mail.svg +1 -0
  177. package/public/icons/dusk/messenger.svg +1 -0
  178. package/public/icons/dusk/mongodb.svg +1 -0
  179. package/public/icons/dusk/notes.svg +1 -0
  180. package/public/icons/dusk/notion.svg +1 -0
  181. package/public/icons/dusk/obs.svg +1 -0
  182. package/public/icons/dusk/pages.svg +1 -0
  183. package/public/icons/dusk/photos.svg +1 -0
  184. package/public/icons/dusk/postman.svg +1 -0
  185. package/public/icons/dusk/preview.svg +1 -0
  186. package/public/icons/dusk/reminders.svg +1 -0
  187. package/public/icons/dusk/safari.svg +1 -0
  188. package/public/icons/dusk/sequel_pro.svg +1 -0
  189. package/public/icons/dusk/sketch.svg +1 -0
  190. package/public/icons/dusk/skype.svg +1 -0
  191. package/public/icons/dusk/slack.svg +1 -0
  192. package/public/icons/dusk/slack2.svg +1 -0
  193. package/public/icons/dusk/spotify.svg +1 -0
  194. package/public/icons/dusk/steam.svg +1 -0
  195. package/public/icons/dusk/system_preferences.svg +1 -0
  196. package/public/icons/dusk/tableplus.svg +1 -0
  197. package/public/icons/dusk/teams.svg +1 -0
  198. package/public/icons/dusk/telegram.svg +1 -0
  199. package/public/icons/dusk/terminal.svg +1 -0
  200. package/public/icons/dusk/todoist.svg +1 -0
  201. package/public/icons/dusk/trash.svg +1 -0
  202. package/public/icons/dusk/trello.svg +1 -0
  203. package/public/icons/dusk/vivaldi.svg +1 -0
  204. package/public/icons/dusk/vlc.svg +1 -0
  205. package/public/icons/dusk/vscode.svg +1 -0
  206. package/public/icons/dusk/whatsapp.svg +1 -0
  207. package/public/icons/dusk/xeyes.svg +1 -0
  208. package/public/icons/dusk/zoom.svg +1 -0
  209. package/public/icons/files.svg +5 -0
  210. package/public/icons/pwa/icon-192.png +0 -0
  211. package/public/icons/pwa/icon-512.png +0 -0
  212. package/public/icons/settings.svg +14 -0
  213. package/public/icons/terminal.svg +5 -0
  214. package/public/icons/text-editor.svg +7 -0
  215. package/public/manifest.json +38 -0
  216. package/public/next.svg +1 -0
  217. package/public/sw.js +41 -0
  218. package/public/vercel.svg +1 -0
  219. package/public/wallpapers/default.svg +10 -0
  220. package/public/window.svg +1 -0
  221. package/scripts/generate-pwa-icons.mjs +33 -0
  222. package/scripts/install-nats.sh +37 -0
  223. package/sentry.client.config.ts +21 -0
  224. package/sentry.edge.config.ts +12 -0
  225. package/sentry.server.config.ts +12 -0
  226. package/src/app/api/files/download/route.ts +81 -0
  227. package/src/app/api/files/download-zip/route.ts +102 -0
  228. package/src/app/api/files/upload/route.ts +58 -0
  229. package/src/app/api/webhooks/workos/route.ts +98 -0
  230. package/src/app/auth/callback/route.ts +16 -0
  231. package/src/app/auth/logout/route.ts +15 -0
  232. package/src/app/desktop/desktop-shell.tsx +110 -0
  233. package/src/app/desktop/layout.tsx +8 -0
  234. package/src/app/desktop/page.tsx +24 -0
  235. package/src/app/favicon.ico +0 -0
  236. package/src/app/globals.css +7 -0
  237. package/src/app/layout.tsx +64 -0
  238. package/src/app/offline/page.tsx +83 -0
  239. package/src/app/page.tsx +5 -0
  240. package/src/app/standalone/[appId]/page.tsx +28 -0
  241. package/src/app/standalone/layout.tsx +10 -0
  242. package/src/components/app-icon.tsx +55 -0
  243. package/src/components/apps/_echo/schema.ts +14 -0
  244. package/src/components/apps/_echo/service/index.ts +42 -0
  245. package/src/components/apps/app-manifest.ts +97 -0
  246. package/src/components/apps/app-registry.ts +55 -0
  247. package/src/components/apps/dev3000/Dev3000App.tsx +224 -0
  248. package/src/components/apps/dev3000/ErrorsPanel.tsx +160 -0
  249. package/src/components/apps/dev3000/Sidebar.tsx +41 -0
  250. package/src/components/apps/dev3000/TimelineLog.tsx +173 -0
  251. package/src/components/apps/dev3000/dev3000-context.tsx +29 -0
  252. package/src/components/apps/dev3000/index.ts +4 -0
  253. package/src/components/apps/dev3000/schema.ts +48 -0
  254. package/src/components/apps/dev3000/service/index.ts +520 -0
  255. package/src/components/apps/dev3000/service/runtime +1 -0
  256. package/src/components/apps/dev3000/types.ts +15 -0
  257. package/src/components/apps/dev3000/use-message-buffer.ts +46 -0
  258. package/src/components/apps/files/ContextMenu.tsx +151 -0
  259. package/src/components/apps/files/DeleteConfirmDialog.tsx +78 -0
  260. package/src/components/apps/files/FileItem.tsx +128 -0
  261. package/src/components/apps/files/FilesApp.tsx +509 -0
  262. package/src/components/apps/files/FilesListView.tsx +201 -0
  263. package/src/components/apps/files/FilesToolbar.tsx +117 -0
  264. package/src/components/apps/files/GridView.tsx +90 -0
  265. package/src/components/apps/files/InlineInput.tsx +131 -0
  266. package/src/components/apps/files/UploadOverlay.tsx +61 -0
  267. package/src/components/apps/files/schema.ts +49 -0
  268. package/src/components/apps/files/service/index.ts +227 -0
  269. package/src/components/apps/files/service/runtime +1 -0
  270. package/src/components/apps/files/use-files.ts +201 -0
  271. package/src/components/apps/files/use-upload.ts +105 -0
  272. package/src/components/apps/nats-viewer/ActiveSubs.tsx +34 -0
  273. package/src/components/apps/nats-viewer/MessageLog.tsx +247 -0
  274. package/src/components/apps/nats-viewer/NatsViewer.tsx +209 -0
  275. package/src/components/apps/nats-viewer/PublishPanel.tsx +113 -0
  276. package/src/components/apps/nats-viewer/RequestPanel.tsx +167 -0
  277. package/src/components/apps/nats-viewer/Sidebar.tsx +62 -0
  278. package/src/components/apps/nats-viewer/SubjectCatalog.tsx +64 -0
  279. package/src/components/apps/nats-viewer/SubscribeInput.tsx +59 -0
  280. package/src/components/apps/nats-viewer/index.ts +5 -0
  281. package/src/components/apps/nats-viewer/nats-viewer-context.tsx +31 -0
  282. package/src/components/apps/nats-viewer/types.ts +7 -0
  283. package/src/components/apps/nats-viewer/use-message-buffer.ts +55 -0
  284. package/src/components/apps/settings/Settings.tsx +492 -0
  285. package/src/components/apps/terminal/schema.ts +82 -0
  286. package/src/components/apps/terminal/service/index.ts +189 -0
  287. package/src/components/apps/terminal/service/runtime +1 -0
  288. package/src/components/apps/terminal/service/session.ts +296 -0
  289. package/src/components/apps/terminal/service/shell-hooks/bashrc-hook.sh +21 -0
  290. package/src/components/apps/terminal/types.ts +26 -0
  291. package/src/components/apps/terminal/ui/MultiTerminalApp.tsx +617 -0
  292. package/src/components/apps/terminal/ui/SplitDragHandle.tsx +91 -0
  293. package/src/components/apps/terminal/ui/SplitPaneRenderer.tsx +112 -0
  294. package/src/components/apps/terminal/ui/TerminalPane.tsx +476 -0
  295. package/src/components/apps/terminal/ui/TerminalTabBar.tsx +131 -0
  296. package/src/components/desktop/AnimatedBackground.tsx +69 -0
  297. package/src/components/desktop/Desktop.tsx +79 -0
  298. package/src/components/desktop/DesktopBackground.tsx +16 -0
  299. package/src/components/desktop/DesktopIcon.tsx +49 -0
  300. package/src/components/desktop/ShortcutViewer.tsx +136 -0
  301. package/src/components/desktop/WindowRenderer.tsx +34 -0
  302. package/src/components/desktop/WindowSwitcher.tsx +42 -0
  303. package/src/components/notifications/NotificationCenter.tsx +153 -0
  304. package/src/components/notifications/NotificationToasts.tsx +66 -0
  305. package/src/components/notifications/OrphanSessionToast.tsx +293 -0
  306. package/src/components/os-primitives/collapsible-sidebar.tsx +226 -0
  307. package/src/components/os-primitives/dialog.tsx +76 -0
  308. package/src/components/os-primitives/empty-state.tsx +43 -0
  309. package/src/components/os-primitives/index.ts +21 -0
  310. package/src/components/os-primitives/list-view.tsx +155 -0
  311. package/src/components/os-primitives/property-panel.tsx +108 -0
  312. package/src/components/os-primitives/section-header.tsx +19 -0
  313. package/src/components/os-primitives/sidebar-nav.tsx +110 -0
  314. package/src/components/os-primitives/split-pane.tsx +146 -0
  315. package/src/components/os-primitives/status-badge.tsx +10 -0
  316. package/src/components/os-primitives/status-bar.tsx +100 -0
  317. package/src/components/os-primitives/toolbar.tsx +152 -0
  318. package/src/components/taskbar/AppLauncher.tsx +114 -0
  319. package/src/components/taskbar/RunningApps.tsx +71 -0
  320. package/src/components/taskbar/SystemTray.tsx +134 -0
  321. package/src/components/taskbar/Taskbar.tsx +45 -0
  322. package/src/components/taskbar/UserMenu.tsx +138 -0
  323. package/src/components/taskbar/WorkspaceSwitcher.tsx +9 -0
  324. package/src/components/ui/ContextMenu.tsx +130 -0
  325. package/src/components/ui/badge.tsx +39 -0
  326. package/src/components/ui/button.tsx +102 -0
  327. package/src/components/ui/command.tsx +165 -0
  328. package/src/components/ui/dropdown-menu.tsx +233 -0
  329. package/src/components/ui/input.tsx +48 -0
  330. package/src/components/ui/note.tsx +55 -0
  331. package/src/components/ui/separator.tsx +25 -0
  332. package/src/components/ui/spinner.tsx +42 -0
  333. package/src/components/ui/switch.tsx +36 -0
  334. package/src/components/ui/theme-provider.tsx +24 -0
  335. package/src/components/ui/theme-switcher.tsx +51 -0
  336. package/src/components/ui/tooltip.tsx +62 -0
  337. package/src/components/window/MobileWindowStack.tsx +218 -0
  338. package/src/components/window/SnapPreview.tsx +37 -0
  339. package/src/components/window/StandaloneFrame.tsx +170 -0
  340. package/src/components/window/Window.tsx +423 -0
  341. package/src/components/window/WindowContent.tsx +14 -0
  342. package/src/components/window/WindowControlsOverlay.tsx +89 -0
  343. package/src/components/window/WindowFrame.tsx +124 -0
  344. package/src/lib/auth/index.ts +27 -0
  345. package/src/lib/auth/roles.ts +50 -0
  346. package/src/lib/auth/types.ts +32 -0
  347. package/src/lib/auth/use-auth.ts +53 -0
  348. package/src/lib/auth/webhook-handler.ts +87 -0
  349. package/src/lib/auth/workos.ts +67 -0
  350. package/src/lib/constants.ts +1 -0
  351. package/src/lib/desktop/dedup.ts +57 -0
  352. package/src/lib/desktop/schema.ts +55 -0
  353. package/src/lib/files/filename-validation.ts +41 -0
  354. package/src/lib/files/safe-path.ts +49 -0
  355. package/src/lib/hooks/use-desktop-nats.ts +438 -0
  356. package/src/lib/hooks/use-is-mobile.ts +23 -0
  357. package/src/lib/hooks/use-launch-app.ts +79 -0
  358. package/src/lib/hooks/use-nats-notifications.ts +84 -0
  359. package/src/lib/hooks/use-nats.ts +60 -0
  360. package/src/lib/hooks/use-visual-viewport.ts +72 -0
  361. package/src/lib/icons/resolve-window-icon.ts +10 -0
  362. package/src/lib/keyboard/defaults.ts +146 -0
  363. package/src/lib/keyboard/types.ts +52 -0
  364. package/src/lib/keyboard/use-global-keybinds.ts +231 -0
  365. package/src/lib/nats-client.ts +255 -0
  366. package/src/lib/nats.ts +35 -0
  367. package/src/lib/notifications/schema.ts +12 -0
  368. package/src/lib/service-loader.ts +171 -0
  369. package/src/lib/subjects.ts +64 -0
  370. package/src/lib/utils.ts +6 -0
  371. package/src/lib/ws-bridge.ts +288 -0
  372. package/src/lib/ws-protocol.ts +53 -0
  373. package/src/lib/ws-server.ts +167 -0
  374. package/src/middleware.ts +57 -0
  375. package/src/stores/desktop-store.ts +112 -0
  376. package/src/stores/keybind-store.ts +66 -0
  377. package/src/stores/notification-store.ts +271 -0
  378. package/src/stores/theme-store.ts +25 -0
  379. package/src/stores/window-store.ts +294 -0
  380. package/src/theme/animations.css +68 -0
  381. package/src/theme/base.css +123 -0
  382. package/src/theme/controls.css +35 -0
  383. package/src/theme/design-tokens.css +276 -0
  384. package/src/theme/index.css +23 -0
  385. package/src/theme/menus.css +45 -0
  386. package/src/theme/status.css +41 -0
  387. package/src/theme/surfaces.css +94 -0
  388. package/src/theme/tailwind-map.css +138 -0
  389. package/src/theme/taskbar.css +25 -0
  390. package/src/theme/terminal.css +55 -0
  391. package/src/theme/typography.css +26 -0
  392. package/src/theme/utilities.css +156 -0
  393. package/src/theme/window.css +103 -0
  394. package/src/types/desktop-entry.ts +12 -0
  395. package/src/types/use-descendants.d.ts +13 -0
  396. package/src/types/window.ts +28 -0
  397. package/src/types.d.ts +9 -0
  398. package/tauri/Cargo.lock +5464 -0
  399. package/tauri/Cargo.toml +19 -0
  400. package/tauri/build.rs +3 -0
  401. package/tauri/capabilities/default.json +36 -0
  402. package/tauri/icons/128x128.png +0 -0
  403. package/tauri/icons/128x128@2x.png +0 -0
  404. package/tauri/icons/32x32.png +0 -0
  405. package/tauri/icons/icon.png +0 -0
  406. package/tauri/src/main.rs +396 -0
  407. package/tauri/tauri.conf.json +23 -0
  408. package/tsconfig.json +43 -0
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useNats } from '@/lib/hooks/use-nats';
5
+ import { SUBJECTS } from '@/lib/subjects';
6
+ import type { FsEntry, FsListResponse, FsWriteRequest, FsWriteResponse } from './schema';
7
+
8
+ /**
9
+ * Get the parent directory of a relative path.
10
+ * e.g. '/subdir/file.txt' -> '/subdir', '/file.txt' -> '/'
11
+ */
12
+ function parentDir(filePath: string): string {
13
+ const lastSlash = filePath.lastIndexOf('/');
14
+ if (lastSlash <= 0) return '/';
15
+ return filePath.slice(0, lastSlash);
16
+ }
17
+
18
+ /**
19
+ * Sort entries: directories first, then alphabetical by name (case-insensitive).
20
+ */
21
+ function sortEntries(entries: FsEntry[]): FsEntry[] {
22
+ return [...entries].sort((a, b) => {
23
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
24
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
25
+ });
26
+ }
27
+
28
+ export interface UseFilesReturn {
29
+ currentPath: string;
30
+ filesRoot: string;
31
+ entries: FsEntry[];
32
+ loading: boolean;
33
+ error: string | null;
34
+ navigateTo: (path: string) => void;
35
+ refresh: () => void;
36
+ goBack: () => void;
37
+ goUp: () => void;
38
+ canGoBack: boolean;
39
+ writeOp: (op: FsWriteRequest) => Promise<FsWriteResponse>;
40
+ }
41
+
42
+ export function useFiles(): UseFilesReturn {
43
+ const { request, subscribe, orgId, connected } = useNats();
44
+ const [currentPath, setCurrentPath] = useState('/');
45
+ const [filesRoot, setFilesRoot] = useState('');
46
+ const [entries, setEntries] = useState<FsEntry[]>([]);
47
+ const [loading, setLoading] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+ const [pathHistory, setPathHistory] = useState<string[]>([]);
50
+
51
+ // Track whether we've fetched at least once for this path
52
+ const lastFetchedPath = useRef<string | null>(null);
53
+
54
+ const fetchEntries = useCallback(
55
+ async (path: string) => {
56
+ if (!orgId || !connected) return;
57
+ setLoading(true);
58
+ setError(null);
59
+ try {
60
+ const response = (await request(SUBJECTS.fs.list(orgId), { path })) as FsListResponse & {
61
+ error?: string;
62
+ };
63
+ if (response.error) {
64
+ setError(response.error);
65
+ setEntries([]);
66
+ } else {
67
+ setEntries(sortEntries(response.entries));
68
+ setCurrentPath(response.path);
69
+ if (response.root) setFilesRoot(response.root);
70
+ lastFetchedPath.current = response.path;
71
+ }
72
+ } catch (err) {
73
+ setError((err as Error).message || 'Failed to list directory');
74
+ setEntries([]);
75
+ } finally {
76
+ setLoading(false);
77
+ }
78
+ },
79
+ [orgId, connected, request]
80
+ );
81
+
82
+ // Fetch entries when path changes or connection becomes available
83
+ useEffect(() => {
84
+ if (connected && orgId && lastFetchedPath.current !== currentPath) {
85
+ fetchEntries(currentPath);
86
+ }
87
+ }, [currentPath, connected, orgId, fetchEntries]);
88
+
89
+ // Initial fetch when first connected
90
+ useEffect(() => {
91
+ if (connected && orgId && lastFetchedPath.current === null) {
92
+ fetchEntries(currentPath);
93
+ }
94
+ }, [connected, orgId, currentPath, fetchEntries]);
95
+
96
+ // --- Live watch: subscribe to fs.watch events and auto-refresh on changes ---
97
+ // We use a ref for currentPath so the subscription callback always sees the latest value
98
+ // without needing to re-subscribe every time the path changes.
99
+ const currentPathRef = useRef(currentPath);
100
+ currentPathRef.current = currentPath;
101
+
102
+ // Debounce refresh calls from watch events to avoid rapid successive fetches
103
+ const watchRefreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
104
+
105
+ useEffect(() => {
106
+ if (!connected || !orgId) return;
107
+
108
+ const watchSubject = `khal.${orgId}.fs.watch.>`;
109
+ const unsub = subscribe(watchSubject, (data: unknown) => {
110
+ if (!data || typeof data !== 'object') return;
111
+ const event = data as Record<string, unknown>;
112
+ if (typeof event.type !== 'string' || typeof event.path !== 'string') return;
113
+
114
+ // Check if this event's parent directory matches the currently viewed path
115
+ const eventParent = parentDir(event.path as string);
116
+ if (eventParent !== currentPathRef.current) return;
117
+
118
+ // Debounce: coalesce rapid events (e.g. bulk operations) into a single refresh
119
+ if (watchRefreshTimer.current) {
120
+ clearTimeout(watchRefreshTimer.current);
121
+ }
122
+ watchRefreshTimer.current = setTimeout(() => {
123
+ watchRefreshTimer.current = null;
124
+ lastFetchedPath.current = null;
125
+ fetchEntries(currentPathRef.current);
126
+ }, 300);
127
+ });
128
+
129
+ return () => {
130
+ unsub();
131
+ if (watchRefreshTimer.current) {
132
+ clearTimeout(watchRefreshTimer.current);
133
+ watchRefreshTimer.current = null;
134
+ }
135
+ };
136
+ }, [connected, orgId, subscribe, fetchEntries]);
137
+
138
+ const navigateTo = useCallback(
139
+ (path: string) => {
140
+ setPathHistory((prev) => [...prev, currentPath]);
141
+ setCurrentPath(path);
142
+ lastFetchedPath.current = null; // Force re-fetch
143
+ },
144
+ [currentPath]
145
+ );
146
+
147
+ const refresh = useCallback(() => {
148
+ lastFetchedPath.current = null; // Force re-fetch
149
+ fetchEntries(currentPath);
150
+ }, [currentPath, fetchEntries]);
151
+
152
+ const goBack = useCallback(() => {
153
+ if (pathHistory.length === 0) return;
154
+ const previous = pathHistory[pathHistory.length - 1];
155
+ setPathHistory((prev) => prev.slice(0, -1));
156
+ setCurrentPath(previous);
157
+ lastFetchedPath.current = null;
158
+ }, [pathHistory]);
159
+
160
+ const goUp = useCallback(() => {
161
+ if (currentPath === '/') return;
162
+ const segments = currentPath.split('/').filter(Boolean);
163
+ segments.pop();
164
+ const parentPath = segments.length === 0 ? '/' : `/${segments.join('/')}`;
165
+ navigateTo(parentPath);
166
+ }, [currentPath, navigateTo]);
167
+
168
+ const writeOp = useCallback(
169
+ async (op: FsWriteRequest): Promise<FsWriteResponse> => {
170
+ if (!orgId || !connected) {
171
+ return { ok: false, error: 'Not connected' };
172
+ }
173
+ try {
174
+ const response = (await request(SUBJECTS.fs.write(orgId), op)) as FsWriteResponse;
175
+ if (response.ok) {
176
+ // Refresh after successful write
177
+ lastFetchedPath.current = null;
178
+ fetchEntries(currentPath);
179
+ }
180
+ return response;
181
+ } catch (err) {
182
+ return { ok: false, error: (err as Error).message || 'Write operation failed' };
183
+ }
184
+ },
185
+ [orgId, connected, request, currentPath, fetchEntries]
186
+ );
187
+
188
+ return {
189
+ currentPath,
190
+ filesRoot,
191
+ entries,
192
+ loading,
193
+ error,
194
+ navigateTo,
195
+ refresh,
196
+ goBack,
197
+ goUp,
198
+ canGoBack: pathHistory.length > 0,
199
+ writeOp,
200
+ };
201
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ export interface UploadState {
6
+ uploading: boolean;
7
+ progress: number; // 0-100
8
+ fileName: string | null;
9
+ error: string | null;
10
+ }
11
+
12
+ const INITIAL_STATE: UploadState = {
13
+ uploading: false,
14
+ progress: 0,
15
+ fileName: null,
16
+ error: null,
17
+ };
18
+
19
+ function uploadFile(file: File, targetPath: string): Promise<void> {
20
+ return new Promise((resolve, reject) => {
21
+ const xhr = new XMLHttpRequest();
22
+ const formData = new FormData();
23
+ formData.append('file', file);
24
+ formData.append('path', targetPath);
25
+
26
+ xhr.open('POST', '/api/files/upload');
27
+
28
+ xhr.onload = () => {
29
+ if (xhr.status >= 200 && xhr.status < 300) {
30
+ try {
31
+ const response = JSON.parse(xhr.responseText);
32
+ if (response.ok) {
33
+ resolve();
34
+ } else {
35
+ reject(new Error(response.error || 'Upload failed'));
36
+ }
37
+ } catch {
38
+ reject(new Error('Invalid server response'));
39
+ }
40
+ } else {
41
+ reject(new Error(`Upload failed (${xhr.status})`));
42
+ }
43
+ };
44
+
45
+ xhr.onerror = () => {
46
+ reject(new Error('Network error'));
47
+ };
48
+
49
+ xhr.send(formData);
50
+ });
51
+ }
52
+
53
+ export function useUpload(currentPath: string, onComplete: () => void) {
54
+ const [uploadState, setUploadState] = useState<UploadState>(INITIAL_STATE);
55
+
56
+ const uploadFiles = useCallback(
57
+ async (files: FileList | File[]) => {
58
+ const fileArray = Array.from(files);
59
+ if (fileArray.length === 0) return;
60
+
61
+ setUploadState({
62
+ uploading: true,
63
+ progress: 0,
64
+ fileName: fileArray.length === 1 ? fileArray[0].name : `${fileArray.length} files`,
65
+ error: null,
66
+ });
67
+
68
+ let completed = 0;
69
+
70
+ try {
71
+ for (const file of fileArray) {
72
+ setUploadState((prev) => ({
73
+ ...prev,
74
+ fileName: fileArray.length === 1 ? file.name : `${file.name} (${completed + 1}/${fileArray.length})`,
75
+ }));
76
+
77
+ await uploadFile(file, currentPath);
78
+ completed++;
79
+
80
+ setUploadState((prev) => ({
81
+ ...prev,
82
+ progress: Math.round((completed / fileArray.length) * 100),
83
+ }));
84
+ }
85
+
86
+ setUploadState(INITIAL_STATE);
87
+ onComplete();
88
+ } catch (err) {
89
+ setUploadState({
90
+ uploading: false,
91
+ progress: 0,
92
+ fileName: null,
93
+ error: err instanceof Error ? err.message : 'Upload failed',
94
+ });
95
+ }
96
+ },
97
+ [currentPath, onComplete]
98
+ );
99
+
100
+ const clearError = useCallback(() => {
101
+ setUploadState(INITIAL_STATE);
102
+ }, []);
103
+
104
+ return { uploadState, uploadFiles, clearError };
105
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { X } from 'lucide-react';
4
+ import { useNatsViewer } from './nats-viewer-context';
5
+
6
+ export function ActiveSubs() {
7
+ const { subscriptions, removeSubscription } = useNatsViewer();
8
+ const subjects = Array.from(subscriptions).sort();
9
+
10
+ if (subjects.length === 0) {
11
+ return <p className="px-1 text-[11px] italic text-gray-700">No active subscriptions</p>;
12
+ }
13
+
14
+ return (
15
+ <div className="flex flex-col gap-0.5">
16
+ {subjects.map((subject) => (
17
+ <div
18
+ key={subject}
19
+ className="group flex items-center gap-1.5 rounded px-1.5 py-0.5 transition-colors hover:bg-gray-alpha-100"
20
+ >
21
+ <span className="inline-block h-2 w-2 shrink-0 rounded-full bg-green-500" />
22
+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-900">{subject}</span>
23
+ <button
24
+ onClick={() => removeSubscription(subject)}
25
+ className="shrink-0 rounded p-0.5 text-gray-600 opacity-0 transition-all hover:bg-red-100 hover:text-red-600 group-hover:opacity-100"
26
+ aria-label={`Unsubscribe from ${subject}`}
27
+ >
28
+ <X className="h-3 w-3" />
29
+ </button>
30
+ </div>
31
+ ))}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,247 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { EmptyState } from '@/components/os-primitives/empty-state';
5
+ import type { LogEntry } from './types';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Hash-to-color for subject segments
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const SUBJECT_COLORS = [
12
+ 'text-blue-400',
13
+ 'text-green-400',
14
+ 'text-amber-400',
15
+ 'text-purple-400',
16
+ 'text-pink-400',
17
+ 'text-cyan-400',
18
+ 'text-orange-400',
19
+ 'text-teal-400',
20
+ 'text-red-400',
21
+ 'text-indigo-400',
22
+ ];
23
+
24
+ function hashColor(segment: string): string {
25
+ let hash = 0;
26
+ for (let i = 0; i < segment.length; i++) {
27
+ hash = (hash * 31 + segment.charCodeAt(i)) | 0;
28
+ }
29
+ return SUBJECT_COLORS[Math.abs(hash) % SUBJECT_COLORS.length];
30
+ }
31
+
32
+ function subjectColor(subject: string): string {
33
+ // Color by first segment after "khal."
34
+ const parts = subject.split('.');
35
+ const key = parts.length > 1 ? parts[1] : parts[0];
36
+ return hashColor(key);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Time formatting
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function formatTimestamp(ts: number): string {
44
+ const d = new Date(ts);
45
+ const h = String(d.getHours()).padStart(2, '0');
46
+ const m = String(d.getMinutes()).padStart(2, '0');
47
+ const s = String(d.getSeconds()).padStart(2, '0');
48
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
49
+ return `${h}:${m}:${s}.${ms}`;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Base64 detection and decoding
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const BASE64_RE = /^[A-Za-z0-9+/]{4,}={0,2}$/;
57
+
58
+ function isBase64(value: string): boolean {
59
+ if (value.length < 4) return false;
60
+ return BASE64_RE.test(value.trim());
61
+ }
62
+
63
+ function tryDecodeBase64(value: string): string | null {
64
+ try {
65
+ const decoded = atob(value.trim());
66
+ // Check if the decoded result is valid UTF-8 text (printable)
67
+ const isPrintable = /^[\x20-\x7E\t\n\r]+$/.test(decoded);
68
+ return isPrintable ? decoded : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function decodeBase64InPayload(payload: unknown): unknown {
75
+ if (typeof payload === 'string' && isBase64(payload)) {
76
+ const decoded = tryDecodeBase64(payload);
77
+ return decoded !== null ? decoded : payload;
78
+ }
79
+ if (Array.isArray(payload)) {
80
+ return payload.map(decodeBase64InPayload);
81
+ }
82
+ if (payload !== null && typeof payload === 'object') {
83
+ const result: Record<string, unknown> = {};
84
+ for (const [key, value] of Object.entries(payload)) {
85
+ result[key] = decodeBase64InPayload(value);
86
+ }
87
+ return result;
88
+ }
89
+ return payload;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Payload truncation
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function truncatePayload(payload: unknown, maxLen = 120): string {
97
+ try {
98
+ const str = JSON.stringify(payload);
99
+ return str.length > maxLen ? `${str.slice(0, maxLen)}...` : str;
100
+ } catch {
101
+ return String(payload);
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // MessageRow
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function MessageRow({ entry, index }: { entry: LogEntry; index: number }) {
110
+ const [expanded, setExpanded] = useState(false);
111
+ const [decodeBase64, setDecodeBase64] = useState(false);
112
+
113
+ const displayPayload = decodeBase64 ? decodeBase64InPayload(entry.payload) : entry.payload;
114
+
115
+ return (
116
+ <div>
117
+ <button
118
+ type="button"
119
+ onClick={() => setExpanded(!expanded)}
120
+ className={`flex w-full items-baseline gap-2 px-2 py-0.5 text-left text-[11px] leading-5 transition-colors hover:bg-gray-alpha-200 ${
121
+ index % 2 === 0 ? 'bg-transparent' : 'bg-gray-alpha-50'
122
+ }`}
123
+ >
124
+ {/* Timestamp */}
125
+ <span className="shrink-0 font-mono text-gray-700">{formatTimestamp(entry.timestamp)}</span>
126
+
127
+ {/* Direction arrow */}
128
+ <span className={`shrink-0 font-mono ${entry.direction === 'in' ? 'text-green-500' : 'text-blue-500'}`}>
129
+ {entry.direction === 'in' ? '\u2190' : '\u2192'}
130
+ </span>
131
+
132
+ {/* Subject */}
133
+ <span className={`shrink-0 font-mono font-medium ${subjectColor(entry.subject)}`}>{entry.subject}</span>
134
+
135
+ {/* Payload preview */}
136
+ <span className="min-w-0 truncate font-mono text-gray-600">{truncatePayload(entry.payload)}</span>
137
+ </button>
138
+
139
+ {/* Expanded detail */}
140
+ {expanded && (
141
+ <div className="mx-2 mb-1 rounded border border-gray-alpha-200 bg-gray-alpha-50 p-2">
142
+ <div className="mb-1 flex items-center gap-2">
143
+ <button
144
+ type="button"
145
+ onClick={() => setDecodeBase64(!decodeBase64)}
146
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
147
+ decodeBase64 ? 'bg-blue-600 text-white' : 'bg-gray-alpha-200 text-gray-800 hover:bg-gray-alpha-300'
148
+ }`}
149
+ >
150
+ Decode Base64
151
+ </button>
152
+ </div>
153
+ <pre className="overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-4 text-gray-1000">
154
+ {JSON.stringify(displayPayload, null, 2)}
155
+ </pre>
156
+ </div>
157
+ )}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // MessageLog
164
+ // ---------------------------------------------------------------------------
165
+
166
+ interface MessageLogProps {
167
+ entries: LogEntry[];
168
+ filter: string;
169
+ paused: boolean;
170
+ }
171
+
172
+ export function MessageLog({ entries, filter, paused }: MessageLogProps) {
173
+ const containerRef = useRef<HTMLDivElement>(null);
174
+ const wasAtBottomRef = useRef(true);
175
+ const prevEntryCountRef = useRef(0);
176
+
177
+ // Filter entries
178
+ const filtered = useMemo(() => {
179
+ if (!filter) return entries;
180
+ const lower = filter.toLowerCase();
181
+ return entries.filter((e) => {
182
+ if (e.subject.toLowerCase().includes(lower)) return true;
183
+ try {
184
+ const payloadStr = JSON.stringify(e.payload).toLowerCase();
185
+ return payloadStr.includes(lower);
186
+ } catch {
187
+ return false;
188
+ }
189
+ });
190
+ }, [entries, filter]);
191
+
192
+ // Track scroll position to know if we're at the bottom
193
+ const handleScroll = () => {
194
+ const el = containerRef.current;
195
+ if (!el) return;
196
+ const threshold = 30;
197
+ wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
198
+ };
199
+
200
+ // Auto-scroll when new entries arrive and not paused
201
+ useEffect(() => {
202
+ const el = containerRef.current;
203
+ if (!el) return;
204
+
205
+ const hasNewEntries = filtered.length > prevEntryCountRef.current;
206
+ prevEntryCountRef.current = filtered.length;
207
+
208
+ if (!paused && hasNewEntries && wasAtBottomRef.current) {
209
+ el.scrollTop = el.scrollHeight;
210
+ }
211
+ }, [filtered.length, paused]);
212
+
213
+ // When unpausing, scroll to bottom
214
+ useEffect(() => {
215
+ if (!paused) {
216
+ const el = containerRef.current;
217
+ if (el) {
218
+ el.scrollTop = el.scrollHeight;
219
+ wasAtBottomRef.current = true;
220
+ }
221
+ }
222
+ }, [paused]);
223
+
224
+ if (filtered.length === 0 && !filter) {
225
+ return (
226
+ <div className="flex h-full items-center justify-center">
227
+ <EmptyState title="No messages yet" description="Subscribe to a NATS subject to start seeing messages." />
228
+ </div>
229
+ );
230
+ }
231
+
232
+ if (filtered.length === 0 && filter) {
233
+ return (
234
+ <div className="flex h-full items-center justify-center">
235
+ <EmptyState title="No matching messages" description={`No entries match "${filter}".`} />
236
+ </div>
237
+ );
238
+ }
239
+
240
+ return (
241
+ <div ref={containerRef} className="h-full overflow-auto" onScroll={handleScroll}>
242
+ {filtered.map((entry, i) => (
243
+ <MessageRow key={entry.id} entry={entry} index={i} />
244
+ ))}
245
+ </div>
246
+ );
247
+ }