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,615 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { v4 as uuid } from 'uuid';
5
+ import { getNatsClient } from '@/lib/nats-client';
6
+ import { SUBJECTS } from '@/lib/subjects';
7
+ import { useWindowStore } from '@/stores/window-store';
8
+ import type { SplitNode, TerminalTab } from '../types';
9
+ import { SplitPaneRenderer } from './SplitPaneRenderer';
10
+ import { TerminalTabBar } from './TerminalTabBar';
11
+
12
+ /** Check if a split tree contains a pane with the given ID */
13
+ function treeContainsPane(node: SplitNode, paneId: string): boolean {
14
+ if (node.type === 'leaf') return node.id === paneId;
15
+ return treeContainsPane(node.children[0], paneId) || treeContainsPane(node.children[1], paneId);
16
+ }
17
+
18
+ /** Update ratio for a specific branch node, preserving references when unchanged */
19
+ function updateBranchRatio(node: SplitNode, nodeId: string, ratio: number): SplitNode {
20
+ if (node.type === 'leaf') return node;
21
+ if (node.id === nodeId) return { ...node, ratio };
22
+ const c0 = updateBranchRatio(node.children[0], nodeId, ratio);
23
+ const c1 = updateBranchRatio(node.children[1], nodeId, ratio);
24
+ if (c0 === node.children[0] && c1 === node.children[1]) return node;
25
+ return { ...node, children: [c0, c1] as [SplitNode, SplitNode] };
26
+ }
27
+
28
+ /** Recursively collect all pane IDs from a split tree */
29
+ function collectPaneIds(node: SplitNode): string[] {
30
+ if (node.type === 'leaf') return [node.id];
31
+ return [...collectPaneIds(node.children[0]), ...collectPaneIds(node.children[1])];
32
+ }
33
+
34
+ /** Recursively find and remove a pane from the tree, promoting siblings */
35
+ function removePaneFromTree(node: SplitNode, paneId: string): SplitNode | null {
36
+ if (node.type === 'leaf') {
37
+ return node.id === paneId ? null : node;
38
+ }
39
+ const newChild0 = removePaneFromTree(node.children[0], paneId);
40
+ const newChild1 = removePaneFromTree(node.children[1], paneId);
41
+ if (newChild0 === null) return newChild1;
42
+ if (newChild1 === null) return newChild0;
43
+ return { ...node, children: [newChild0, newChild1] };
44
+ }
45
+
46
+ /** Recursively collect all PTY session IDs from a split tree */
47
+ function collectPtySessionIds(node: SplitNode): string[] {
48
+ if (node.type === 'leaf') return node.ptySessionId ? [node.ptySessionId] : [];
49
+ return [...collectPtySessionIds(node.children[0]), ...collectPtySessionIds(node.children[1])];
50
+ }
51
+
52
+ // Module-level set — survives React Strict Mode double-mount (useRef resets between mounts)
53
+ const initedWindows = new Set<string>();
54
+
55
+ /**
56
+ * Multi-tab terminal app with split panes.
57
+ * Each tab has a recursive split tree of panes.
58
+ * Each leaf pane has its own PTY session and xterm instance.
59
+ *
60
+ * Keyboard shortcuts (via attachCustomKeyEventHandler):
61
+ * - Cmd+T: new tab
62
+ * - Cmd+W: close active pane (or tab if last pane, or window if last tab)
63
+ * - Cmd+D: split active pane vertically
64
+ * - Cmd+Shift+D: split active pane horizontally
65
+ * - Ctrl+Tab: next tab
66
+ * - Ctrl+Shift+Tab: prev tab
67
+ */
68
+ export function MultiTerminalApp(props: { windowId: string; meta?: Record<string, unknown> }) {
69
+ const [tabs, setTabs] = useState<TerminalTab[]>([]);
70
+ const [activeTabId, setActiveTabId] = useState<string>('');
71
+
72
+ // Track whether init has run (prevents meta effect from clearing persisted state on mount)
73
+ const initializedRef = useRef(false);
74
+
75
+ // One-shot guard: publish event.metaUpdated once when PTY sessions first appear
76
+ const metaPublishedRef = useRef(false);
77
+
78
+ // Keep a ref to latest tabs for use in callbacks without stale closures
79
+ const tabsRef = useRef(tabs);
80
+ tabsRef.current = tabs;
81
+
82
+ const updateWindowMeta = useWindowStore((s) => s.updateWindowMeta);
83
+ const closeWindow = useWindowStore((s) => s.closeWindow);
84
+
85
+ // Helper: create a new leaf node
86
+ const createLeafNode = useCallback((): SplitNode => {
87
+ return {
88
+ type: 'leaf',
89
+ id: uuid(),
90
+ ptySessionId: null,
91
+ cwd: null,
92
+ lastCommand: null,
93
+ };
94
+ }, []);
95
+
96
+ // Create a new tab
97
+ const createTab = useCallback(() => {
98
+ const leafNode = createLeafNode();
99
+ const newTab: TerminalTab = {
100
+ id: uuid(),
101
+ ptySessionId: null, // legacy, kept for compatibility
102
+ title: 'bash',
103
+ cwd: null,
104
+ lastCommand: null,
105
+ splitTree: leafNode,
106
+ focusedPaneId: leafNode.id,
107
+ };
108
+ setTabs((prev) => [...prev, newTab]);
109
+ setActiveTabId(newTab.id);
110
+ return newTab.id;
111
+ }, [createLeafNode]);
112
+
113
+ // Destroy PTY sessions directly via the NatsClient singleton (bypasses React hooks)
114
+ const destroyPtySessions = useCallback((sessionIds: string[]) => {
115
+ const client = getNatsClient();
116
+ for (const sessionId of sessionIds) {
117
+ client.publish(SUBJECTS.pty.destroy(client.orgId), { sessionId });
118
+ }
119
+ }, []);
120
+
121
+ // Close the focused pane in the active tab (Cmd+W)
122
+ const closePane = useCallback(() => {
123
+ // Read snapshot for side effects (PTY destroy) only
124
+ const activeTab = tabsRef.current.find((t) => t.id === activeTabId);
125
+ if (!activeTab) return;
126
+
127
+ const allPaneIds = collectPaneIds(activeTab.splitTree);
128
+
129
+ // If only one pane, close the tab
130
+ if (allPaneIds.length === 1) {
131
+ const sessionIds = collectPtySessionIds(activeTab.splitTree);
132
+ destroyPtySessions(sessionIds);
133
+
134
+ setTabs((prev) => {
135
+ const newTabs = prev.filter((t) => t.id !== activeTabId);
136
+
137
+ if (newTabs.length === 0) {
138
+ closeWindow(props.windowId);
139
+ return newTabs;
140
+ }
141
+
142
+ const idx = prev.findIndex((t) => t.id === activeTabId);
143
+ const nextIdx = idx > 0 ? idx - 1 : 0;
144
+ setActiveTabId(newTabs[nextIdx].id);
145
+
146
+ return newTabs;
147
+ });
148
+ return;
149
+ }
150
+
151
+ // Multiple panes — destroy the focused pane's PTY session (side effect from snapshot)
152
+ const focusedLeaf = (function findLeaf(node: SplitNode): SplitNode | null {
153
+ if (node.type === 'leaf') return node.id === activeTab.focusedPaneId ? node : null;
154
+ return findLeaf(node.children[0]) || findLeaf(node.children[1]);
155
+ })(activeTab.splitTree);
156
+ if (focusedLeaf && focusedLeaf.type === 'leaf' && focusedLeaf.ptySessionId) {
157
+ destroyPtySessions([focusedLeaf.ptySessionId]);
158
+ }
159
+
160
+ // Compute new tree from prev inside updater to avoid lost updates
161
+ setTabs((prev) =>
162
+ prev.map((t) => {
163
+ if (t.id !== activeTabId) return t;
164
+ const newTree = removePaneFromTree(t.splitTree, t.focusedPaneId);
165
+ if (!newTree) return t;
166
+ const newPaneIds = collectPaneIds(newTree);
167
+ const newFocusedPaneId = newPaneIds[0] || newTree.id;
168
+ return { ...t, splitTree: newTree, focusedPaneId: newFocusedPaneId };
169
+ })
170
+ );
171
+ }, [activeTabId, destroyPtySessions, closeWindow, props.windowId]);
172
+
173
+ // Close a specific tab by ID (tab X button)
174
+ const closeTab = useCallback(
175
+ (tabId: string) => {
176
+ const tab = tabsRef.current.find((t) => t.id === tabId);
177
+ if (!tab) return;
178
+
179
+ const sessionIds = collectPtySessionIds(tab.splitTree);
180
+ destroyPtySessions(sessionIds);
181
+
182
+ setTabs((prev) => {
183
+ const newTabs = prev.filter((t) => t.id !== tabId);
184
+
185
+ if (newTabs.length === 0) {
186
+ closeWindow(props.windowId);
187
+ return newTabs;
188
+ }
189
+
190
+ if (tabId === activeTabId) {
191
+ const idx = prev.findIndex((t) => t.id === tabId);
192
+ const nextIdx = idx > 0 ? idx - 1 : 0;
193
+ setActiveTabId(newTabs[nextIdx].id);
194
+ }
195
+
196
+ return newTabs;
197
+ });
198
+ },
199
+ [activeTabId, destroyPtySessions, closeWindow, props.windowId]
200
+ );
201
+
202
+ // Helper: split a pane in the tree
203
+ const splitPaneInTree = useCallback(
204
+ (node: SplitNode, paneId: string, direction: 'horizontal' | 'vertical'): SplitNode => {
205
+ if (node.type === 'leaf') {
206
+ if (node.id === paneId) {
207
+ const newLeaf = createLeafNode();
208
+ return {
209
+ type: 'branch',
210
+ id: uuid(),
211
+ direction,
212
+ children: [node, newLeaf],
213
+ ratio: 0.5,
214
+ };
215
+ }
216
+ return node;
217
+ }
218
+
219
+ return {
220
+ ...node,
221
+ children: [
222
+ splitPaneInTree(node.children[0], paneId, direction),
223
+ splitPaneInTree(node.children[1], paneId, direction),
224
+ ] as [SplitNode, SplitNode],
225
+ };
226
+ },
227
+ [createLeafNode]
228
+ );
229
+
230
+ // Split the focused pane — tree computed inside updater to avoid lost updates
231
+ const splitPane = useCallback(
232
+ (direction: 'horizontal' | 'vertical') => {
233
+ setTabs((prev) => {
234
+ const activeTab = prev.find((t) => t.id === activeTabId);
235
+ if (!activeTab) return prev;
236
+ const newTree = splitPaneInTree(activeTab.splitTree, activeTab.focusedPaneId, direction);
237
+ return prev.map((t) => (t.id === activeTabId ? { ...t, splitTree: newTree } : t));
238
+ });
239
+ },
240
+ [activeTabId, splitPaneInTree]
241
+ );
242
+
243
+ // Switch to next tab (Ctrl+Tab)
244
+ const nextTab = useCallback(() => {
245
+ const current = tabsRef.current;
246
+ if (current.length === 0) return;
247
+ const idx = current.findIndex((t) => t.id === activeTabId);
248
+ const nextIdx = (idx + 1) % current.length;
249
+ setActiveTabId(current[nextIdx].id);
250
+ }, [activeTabId]);
251
+
252
+ // Switch to previous tab (Ctrl+Shift+Tab)
253
+ const prevTab = useCallback(() => {
254
+ const current = tabsRef.current;
255
+ if (current.length === 0) return;
256
+ const idx = current.findIndex((t) => t.id === activeTabId);
257
+ const nextIdx = idx === 0 ? current.length - 1 : idx - 1;
258
+ setActiveTabId(current[nextIdx].id);
259
+ }, [activeTabId]);
260
+
261
+ // Initialize: restore from meta or create new tab.
262
+ // Uses module-level `initedWindows` set instead of useRef to survive React
263
+ // Strict Mode double-mount (refs reset between mounts, module state doesn't).
264
+ // Reads meta from the Zustand store directly (not props.meta) because
265
+ // updateWindowMeta writes via queueMicrotask which may not have flushed.
266
+ useEffect(() => {
267
+ if (tabs.length > 0 || initedWindows.has(props.windowId)) return;
268
+
269
+ const win = useWindowStore
270
+ .getState()
271
+ .getWindows()
272
+ .find((w) => w.id === props.windowId);
273
+ const meta = win?.meta;
274
+
275
+ // Synced window — wait for meta with PTY sessions to arrive
276
+ if (meta?._awaitingMeta) return;
277
+
278
+ initedWindows.add(props.windowId);
279
+
280
+ if (meta?.tabs && Array.isArray(meta.tabs) && meta.tabs.length > 0) {
281
+ const seen = new Set<string>();
282
+ const uniqueTabs = (meta.tabs as TerminalTab[]).filter((tab) => {
283
+ if (seen.has(tab.id)) return false;
284
+ seen.add(tab.id);
285
+ return true;
286
+ });
287
+ setTabs(uniqueTabs);
288
+ setActiveTabId((meta.activeTabId as string) || uniqueTabs[0].id);
289
+ } else {
290
+ createTab();
291
+ }
292
+ initializedRef.current = true;
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ }, []); // Run once on mount
295
+
296
+ // Clear initedWindows when the window is truly closed (not during Strict Mode remount).
297
+ // On Strict Mode remount the window still exists in the store, so we keep the guard.
298
+ useEffect(() => {
299
+ const wid = props.windowId;
300
+ return () => {
301
+ const exists = useWindowStore
302
+ .getState()
303
+ .getWindows()
304
+ .some((w) => w.id === wid);
305
+ if (!exists) initedWindows.delete(wid);
306
+ };
307
+ }, [props.windowId]);
308
+
309
+ // Watch for remote meta arrival (cross-tab PTY sharing)
310
+ useEffect(() => {
311
+ if (initializedRef.current || tabs.length > 0) return;
312
+
313
+ const unsub = useWindowStore.subscribe((state) => {
314
+ if (initializedRef.current) return;
315
+ const win = state.getWindows().find((w) => w.id === props.windowId);
316
+ if (!win?.meta?.tabs || !Array.isArray(win.meta.tabs)) return;
317
+ if (win.meta._awaitingMeta) return;
318
+
319
+ // Ensure at least one tab has a real PTY session (not null)
320
+ const metaTabs = win.meta.tabs as TerminalTab[];
321
+ const hasSession = metaTabs.some((t) => collectPtySessionIds(t.splitTree).length > 0);
322
+ if (!hasSession) return;
323
+
324
+ // Meta arrived with PTY sessions — initialize from shared sessions
325
+ initedWindows.add(props.windowId);
326
+ const seen = new Set<string>();
327
+ const uniqueTabs = metaTabs.filter((tab) => {
328
+ if (seen.has(tab.id)) return false;
329
+ seen.add(tab.id);
330
+ return true;
331
+ });
332
+ setTabs(uniqueTabs);
333
+ setActiveTabId((win.meta.activeTabId as string) || uniqueTabs[0].id);
334
+ initializedRef.current = true;
335
+ });
336
+
337
+ return unsub;
338
+ // eslint-disable-next-line react-hooks/exhaustive-deps
339
+ }, [props.windowId]);
340
+
341
+ // Timeout fallback: create fresh tabs if synced meta doesn't arrive in 3s
342
+ useEffect(() => {
343
+ if (initializedRef.current || tabs.length > 0) return;
344
+
345
+ const win = useWindowStore
346
+ .getState()
347
+ .getWindows()
348
+ .find((w) => w.id === props.windowId);
349
+ if (!win?.meta?._awaitingMeta) return;
350
+
351
+ const timeout = setTimeout(() => {
352
+ if (initializedRef.current || tabsRef.current.length > 0) return;
353
+ initedWindows.add(props.windowId);
354
+ createTab();
355
+ initializedRef.current = true;
356
+ }, 3000);
357
+
358
+ return () => clearTimeout(timeout);
359
+ // eslint-disable-next-line react-hooks/exhaustive-deps
360
+ }, [props.windowId]);
361
+
362
+ // Handle pane focus
363
+ const handlePaneFocus = useCallback((paneId: string) => {
364
+ setTabs((prev) =>
365
+ prev.map((t) => {
366
+ if (!treeContainsPane(t.splitTree, paneId)) return t;
367
+
368
+ const findPaneData = (node: SplitNode): { cwd: string | null; lastCommand: string | null } | null => {
369
+ if (node.type === 'leaf') {
370
+ return node.id === paneId ? { cwd: node.cwd, lastCommand: node.lastCommand } : null;
371
+ }
372
+ return findPaneData(node.children[0]) || findPaneData(node.children[1]);
373
+ };
374
+
375
+ const paneData = findPaneData(t.splitTree);
376
+
377
+ return {
378
+ ...t,
379
+ focusedPaneId: paneId,
380
+ cwd: paneData?.cwd ?? t.cwd,
381
+ lastCommand: paneData?.lastCommand ?? t.lastCommand,
382
+ };
383
+ })
384
+ );
385
+ }, []);
386
+
387
+ // Handle session ID change for a pane
388
+ const handleSessionIdChange = useCallback((paneId: string, sessionId: string) => {
389
+ setTabs((prev) =>
390
+ prev.map((t) => {
391
+ if (!treeContainsPane(t.splitTree, paneId)) return t;
392
+
393
+ const updateSessionId = (node: SplitNode): SplitNode => {
394
+ if (node.type === 'leaf') {
395
+ return node.id === paneId ? { ...node, ptySessionId: sessionId } : node;
396
+ }
397
+ return {
398
+ ...node,
399
+ children: [updateSessionId(node.children[0]), updateSessionId(node.children[1])] as [SplitNode, SplitNode],
400
+ };
401
+ };
402
+
403
+ return {
404
+ ...t,
405
+ splitTree: updateSessionId(t.splitTree),
406
+ };
407
+ })
408
+ );
409
+ }, []);
410
+
411
+ // Handle CWD change for a pane (OSC 7)
412
+ const handleCwdChange = useCallback((paneId: string, cwd: string) => {
413
+ setTabs((prev) =>
414
+ prev.map((t) => {
415
+ if (!treeContainsPane(t.splitTree, paneId)) return t;
416
+
417
+ const updateCwd = (node: SplitNode): SplitNode => {
418
+ if (node.type === 'leaf') {
419
+ return node.id === paneId ? { ...node, cwd } : node;
420
+ }
421
+ return {
422
+ ...node,
423
+ children: [updateCwd(node.children[0]), updateCwd(node.children[1])] as [SplitNode, SplitNode],
424
+ };
425
+ };
426
+
427
+ const newTree = updateCwd(t.splitTree);
428
+ const newCwd = t.focusedPaneId === paneId ? cwd : t.cwd;
429
+
430
+ return {
431
+ ...t,
432
+ splitTree: newTree,
433
+ cwd: newCwd,
434
+ };
435
+ })
436
+ );
437
+ }, []);
438
+
439
+ // Handle last command change for a pane
440
+ const handleLastCommandChange = useCallback((paneId: string, command: string) => {
441
+ setTabs((prev) =>
442
+ prev.map((t) => {
443
+ if (!treeContainsPane(t.splitTree, paneId)) return t;
444
+
445
+ const updateLastCommand = (node: SplitNode): SplitNode => {
446
+ if (node.type === 'leaf') {
447
+ return node.id === paneId ? { ...node, lastCommand: command } : node;
448
+ }
449
+ return {
450
+ ...node,
451
+ children: [updateLastCommand(node.children[0]), updateLastCommand(node.children[1])] as [
452
+ SplitNode,
453
+ SplitNode,
454
+ ],
455
+ };
456
+ };
457
+
458
+ const newTree = updateLastCommand(t.splitTree);
459
+ const newLastCommand = t.focusedPaneId === paneId ? command : t.lastCommand;
460
+
461
+ return {
462
+ ...t,
463
+ splitTree: newTree,
464
+ lastCommand: newLastCommand,
465
+ };
466
+ })
467
+ );
468
+ }, []);
469
+
470
+ // Handle ratio change for a branch node
471
+ const handleRatioChange = useCallback((nodeId: string, ratio: number) => {
472
+ setTabs((prev) =>
473
+ prev.map((t) => {
474
+ const newTree = updateBranchRatio(t.splitTree, nodeId, ratio);
475
+ return newTree === t.splitTree ? t : { ...t, splitTree: newTree };
476
+ })
477
+ );
478
+ }, []);
479
+
480
+ // Keyboard shortcut handler
481
+ const handleKeyboardShortcut = useCallback(
482
+ (event: KeyboardEvent): boolean => {
483
+ const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
484
+ const cmdKey = isMac ? event.metaKey : event.ctrlKey;
485
+
486
+ // Cmd+T: new tab
487
+ if (cmdKey && event.key === 't' && !event.shiftKey && !event.altKey) {
488
+ event.preventDefault();
489
+ createTab();
490
+ return false;
491
+ }
492
+
493
+ // Cmd+W: close pane
494
+ if (cmdKey && event.key === 'w' && !event.shiftKey && !event.altKey) {
495
+ event.preventDefault();
496
+ closePane();
497
+ return false;
498
+ }
499
+
500
+ // Cmd+D: split vertical
501
+ if (cmdKey && event.key === 'd' && !event.shiftKey && !event.altKey) {
502
+ event.preventDefault();
503
+ splitPane('vertical');
504
+ return false;
505
+ }
506
+
507
+ // Cmd+Shift+D: split horizontal
508
+ if (cmdKey && event.key === 'D' && event.shiftKey && !event.altKey) {
509
+ event.preventDefault();
510
+ splitPane('horizontal');
511
+ return false;
512
+ }
513
+
514
+ // Ctrl+Tab: next tab (terminal-internal only)
515
+ if (event.ctrlKey && event.key === 'Tab' && !event.shiftKey && !event.metaKey) {
516
+ event.preventDefault();
517
+ nextTab();
518
+ return false;
519
+ }
520
+
521
+ // Ctrl+Shift+Tab: prev tab
522
+ if (event.ctrlKey && event.key === 'Tab' && event.shiftKey && !event.metaKey) {
523
+ event.preventDefault();
524
+ prevTab();
525
+ return false;
526
+ }
527
+
528
+ // Let xterm handle everything else
529
+ return true;
530
+ },
531
+ [createTab, closePane, splitPane, nextTab, prevTab]
532
+ );
533
+
534
+ // Update window meta when tabs change (deferred to avoid updating store during commit phase)
535
+ useEffect(() => {
536
+ if (!initializedRef.current || tabs.length === 0) return;
537
+
538
+ // One-shot: publish event.metaUpdated directly via NATS when PTY sessions
539
+ // first become available. This enables cross-tab PTY session sharing —
540
+ // other tabs waiting for sessions can reattach instead of creating new ones.
541
+ if (!metaPublishedRef.current) {
542
+ const sessionIds = tabs.flatMap((t) => collectPtySessionIds(t.splitTree));
543
+ if (sessionIds.length > 0) {
544
+ metaPublishedRef.current = true;
545
+ const client = getNatsClient();
546
+ if (client.userId) {
547
+ client.publish(SUBJECTS.desktop.event.metaUpdated(client.orgId, client.userId), {
548
+ windowId: props.windowId,
549
+ meta: { tabs, activeTabId },
550
+ });
551
+ }
552
+ }
553
+ }
554
+
555
+ queueMicrotask(() => {
556
+ updateWindowMeta(props.windowId, { tabs, activeTabId });
557
+ });
558
+ }, [tabs, activeTabId, props.windowId, updateWindowMeta]);
559
+
560
+ // Collect all session IDs from current tabs ref
561
+ const collectAllSessionIds = useCallback(() => {
562
+ return tabsRef.current.flatMap((tab) => collectPtySessionIds(tab.splitTree));
563
+ }, []);
564
+
565
+ // Watch for window closing flag (set by closeWindow from the X button).
566
+ // Uses a zustand store subscription so it fires synchronously — before the
567
+ // microtask that removes the window and unmounts the component.
568
+ useEffect(() => {
569
+ const unsub = useWindowStore.subscribe((state, prevState) => {
570
+ const wsId = state.activeWorkspaceId;
571
+ if (!wsId) return;
572
+ const win = (state.windowsByWorkspace[wsId] || []).find((w) => w.id === props.windowId);
573
+ const prevWin = (prevState.windowsByWorkspace[wsId] || []).find((w) => w.id === props.windowId);
574
+ if (win?.closing && !prevWin?.closing) {
575
+ destroyPtySessions(collectAllSessionIds());
576
+ }
577
+ });
578
+ return unsub;
579
+ }, [props.windowId, destroyPtySessions, collectAllSessionIds]);
580
+
581
+ return (
582
+ <div
583
+ className="h-full w-full flex flex-col"
584
+ style={{ background: 'var(--os-terminal-bg, var(--os-surface-sunken))' }}
585
+ >
586
+ <TerminalTabBar
587
+ tabs={tabs}
588
+ activeTabId={activeTabId}
589
+ onTabClick={setActiveTabId}
590
+ onTabClose={closeTab}
591
+ onNewTab={createTab}
592
+ />
593
+ <div data-no-drag className="flex-1 overflow-hidden relative">
594
+ {tabs.map((tab) => (
595
+ <div
596
+ key={tab.id}
597
+ className="absolute inset-0"
598
+ style={{ visibility: tab.id === activeTabId ? 'visible' : 'hidden' }}
599
+ >
600
+ <SplitPaneRenderer
601
+ node={tab.splitTree}
602
+ focusedPaneId={tab.id === activeTabId ? tab.focusedPaneId : ''}
603
+ onPaneFocus={handlePaneFocus}
604
+ onSessionIdChange={handleSessionIdChange}
605
+ onKeyboardShortcut={handleKeyboardShortcut}
606
+ onRatioChange={handleRatioChange}
607
+ onCwdChange={handleCwdChange}
608
+ onLastCommandChange={handleLastCommandChange}
609
+ />
610
+ </div>
611
+ ))}
612
+ </div>
613
+ </div>
614
+ );
615
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+
5
+ interface SplitDragHandleProps {
6
+ direction: 'horizontal' | 'vertical';
7
+ onRatioChange: (ratio: number) => void;
8
+ }
9
+
10
+ /**
11
+ * Draggable divider between split panes.
12
+ * Vertical split = horizontal drag handle (side by side panes).
13
+ * Horizontal split = vertical drag handle (top/bottom panes).
14
+ */
15
+ export function SplitDragHandle({ direction, onRatioChange }: SplitDragHandleProps) {
16
+ const isDraggingRef = useRef(false);
17
+ const containerRef = useRef<HTMLDivElement | null>(null);
18
+
19
+ const handleMouseDown = useCallback(
20
+ (e: React.MouseEvent) => {
21
+ e.preventDefault();
22
+ isDraggingRef.current = true;
23
+
24
+ // Find the parent split container
25
+ let parent = e.currentTarget.parentElement;
26
+ while (parent && !parent.classList.contains('split-container')) {
27
+ parent = parent.parentElement;
28
+ }
29
+ containerRef.current = parent as HTMLDivElement;
30
+
31
+ const handleMouseMove = (moveEvent: MouseEvent) => {
32
+ if (!isDraggingRef.current || !containerRef.current) return;
33
+
34
+ const rect = containerRef.current.getBoundingClientRect();
35
+ let ratio: number;
36
+
37
+ if (direction === 'vertical') {
38
+ // Vertical split: side by side, calculate X position
39
+ const x = moveEvent.clientX - rect.left;
40
+ ratio = x / rect.width;
41
+ } else {
42
+ // Horizontal split: top/bottom, calculate Y position
43
+ const y = moveEvent.clientY - rect.top;
44
+ ratio = y / rect.height;
45
+ }
46
+
47
+ // Clamp ratio between 0.1 and 0.9 to prevent extremely small panes
48
+ ratio = Math.max(0.1, Math.min(0.9, ratio));
49
+ onRatioChange(ratio);
50
+ };
51
+
52
+ const handleMouseUp = () => {
53
+ isDraggingRef.current = false;
54
+ containerRef.current = null;
55
+ document.removeEventListener('mousemove', handleMouseMove);
56
+ document.removeEventListener('mouseup', handleMouseUp);
57
+ };
58
+
59
+ document.addEventListener('mousemove', handleMouseMove);
60
+ document.addEventListener('mouseup', handleMouseUp);
61
+ },
62
+ [direction, onRatioChange]
63
+ );
64
+
65
+ const isVerticalSplit = direction === 'vertical';
66
+
67
+ return (
68
+ <div
69
+ className={`
70
+ split-handle
71
+ ${isVerticalSplit ? 'w-1 h-full cursor-col-resize' : 'h-1 w-full cursor-row-resize'}
72
+ transition-colors
73
+ relative
74
+ `}
75
+ style={{ background: 'var(--os-border-default)' }}
76
+ onMouseEnter={(e) => {
77
+ (e.currentTarget as HTMLElement).style.background = 'var(--os-split-handle-accent)';
78
+ }}
79
+ onMouseLeave={(e) => {
80
+ (e.currentTarget as HTMLElement).style.background = 'var(--os-border-default)';
81
+ }}
82
+ onMouseDown={handleMouseDown}
83
+ >
84
+ {/* Visual indicator on hover */}
85
+ <div
86
+ className="absolute inset-0 opacity-0 hover:opacity-20 transition-opacity"
87
+ style={{ background: 'var(--os-split-handle-accent)' }}
88
+ />
89
+ </div>
90
+ );
91
+ }