gitspace 0.2.0-rc.1

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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,23 @@
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
7
+ <meta name="apple-mobile-web-app-capable" content="yes" />
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
9
+ <meta name="format-detection" content="telephone=no" />
10
+ <title>Spaces Terminal</title>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "web",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@noble/ciphers": "^2.1.1",
14
+ "@noble/curves": "^2.0.1",
15
+ "@noble/hashes": "^2.0.1",
16
+ "ghostty-web": "^0.4.0",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0",
19
+ "react-router-dom": "^7.11.0"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.39.1",
23
+ "@tailwindcss/vite": "^4.1.18",
24
+ "@types/node": "^25.0.3",
25
+ "@types/react": "^19.2.5",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^5.1.1",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "globals": "^16.5.0",
32
+ "tailwindcss": "^4.1.18",
33
+ "typescript": "~5.9.3",
34
+ "typescript-eslint": "^8.46.4",
35
+ "vite": "^7.2.4"
36
+ }
37
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,604 @@
1
+ /** @jsxImportSource react */
2
+ import { useState, useEffect } from "react";
3
+ import { Terminal } from "./components/Terminal";
4
+ import { useTerminal } from "./hooks/useTerminal";
5
+ import { useRelayConnection } from "./hooks/useRelayConnection";
6
+ import { parseInviteFromHash } from "./lib/invite";
7
+
8
+ // Import shared components and hooks
9
+ import {
10
+ useMachineList,
11
+ useSpacesBrowser,
12
+ useFlow,
13
+ getDefaultShortcuts,
14
+ type MachineInfo,
15
+ } from "../../shared/components/index.js";
16
+ import { MachineListWeb } from "../../shared/components/MachineList.web.js";
17
+ import { SpacesBrowserWeb } from "../../shared/components/SpacesBrowser.web.js";
18
+ import { FlowWeb } from "../../shared/components/Flow.web.js";
19
+ import { useInbox } from "../../shared/components/Inbox.js";
20
+ import { InboxWeb } from "../../shared/components/Inbox.web.js";
21
+
22
+ type View = "machines" | "terminal";
23
+
24
+ export default function App() {
25
+ const [view, setView] = useState<View>("machines");
26
+ const [selectedMachine, setSelectedMachine] = useState<MachineInfo | null>(null);
27
+ const [showInbox, setShowInbox] = useState(false);
28
+ const [copied, setCopied] = useState(false);
29
+
30
+ // Invite params from URL
31
+ const [inviteParams, setInviteParams] = useState<{
32
+ machineId?: string;
33
+ inviteId?: string;
34
+ inviteToken?: string;
35
+ } | null>(null);
36
+
37
+ // Relay connection (for machine list)
38
+ const relay = useRelayConnection();
39
+
40
+ // Terminal connection (for PTY)
41
+ const terminal = useTerminal();
42
+
43
+ // Flow/Modal system
44
+ const flow = useFlow({
45
+ onError: (error) => console.error('Flow error:', error),
46
+ });
47
+
48
+ // Parse invite from URL hash on load
49
+ useEffect(() => {
50
+ const hash = window.location.hash;
51
+ if (hash.startsWith("#invite=")) {
52
+ parseInviteFromHash(hash).then((invite) => {
53
+ if (invite) {
54
+ setInviteParams({
55
+ machineId: invite.machineId,
56
+ inviteId: invite.inviteId,
57
+ inviteToken: invite.inviteToken,
58
+ });
59
+ }
60
+ });
61
+ }
62
+ }, []);
63
+
64
+ // Auto-connect on load (no token required for personal relays)
65
+ useEffect(() => {
66
+ if (relay.status === "disconnected") {
67
+ relay.connect();
68
+ }
69
+ }, []);
70
+
71
+ // Copy access command to clipboard
72
+ const copyAccessCommand = async () => {
73
+ if (relay.publicKey) {
74
+ const command = `gssh access add "${relay.publicKey}"`;
75
+ await navigator.clipboard.writeText(command);
76
+ setCopied(true);
77
+ setTimeout(() => setCopied(false), 2000);
78
+ }
79
+ };
80
+
81
+ // Handle machine selection - go directly to terminal/workspaces view
82
+ const handleMachineConnect = async (machine: MachineInfo) => {
83
+ if (!machine.online) return;
84
+
85
+ // Get WebSocket and identity from relay connection
86
+ const ws = relay.getWebSocket();
87
+ const identity = relay.identity;
88
+ if (!ws || !identity) {
89
+ console.error("No WebSocket or identity available");
90
+ return;
91
+ }
92
+
93
+ setSelectedMachine(machine);
94
+ setView("terminal");
95
+
96
+ // Connect to the machine using existing WebSocket (no new connection needed)
97
+ await terminal.connect({
98
+ ws,
99
+ identity,
100
+ machineId: machine.machineId,
101
+ inviteId: inviteParams?.inviteId,
102
+ inviteToken: inviteParams?.inviteToken,
103
+ });
104
+ };
105
+
106
+ // Handle back to machine list
107
+ const handleBackToMachines = () => {
108
+ terminal.disconnect();
109
+ setSelectedMachine(null);
110
+ setView("machines");
111
+ };
112
+
113
+ // Handle full disconnect (just refresh the page for simplicity)
114
+ const handleDisconnect = () => {
115
+ terminal.disconnect();
116
+ relay.disconnect();
117
+ setSelectedMachine(null);
118
+ setView("machines");
119
+ // Reconnect automatically
120
+ relay.connect();
121
+ };
122
+
123
+ // ========== Shared Hooks ==========
124
+
125
+ // Machine list hook - convert relay machines to shared MachineInfo format
126
+ const machineListProps = useMachineList({
127
+ machines: relay.machines,
128
+ status: relay.status,
129
+ error: relay.error,
130
+ publicKey: relay.publicKey,
131
+ onConnect: handleMachineConnect,
132
+ onRefresh: relay.refreshMachines,
133
+ });
134
+
135
+ // Handle attach session - show modal for new sessions
136
+ const handleAttachSession = (params: { sessionId?: string; workspaceId?: string }) => {
137
+ console.log('[App] handleAttachSession called with:', params);
138
+ if (params.sessionId) {
139
+ // Existing session - attach directly
140
+ console.log('[App] Attaching to existing session:', params.sessionId);
141
+ terminal.attachSession(params);
142
+ } else if (params.workspaceId) {
143
+ // New session - show input modal for name
144
+ flow.showInput({
145
+ title: 'New Session',
146
+ label: 'Session name (optional):',
147
+ placeholder: 'Leave empty for auto-generated name',
148
+ onSubmit: (name) => {
149
+ terminal.attachSession({
150
+ workspaceId: params.workspaceId,
151
+ sessionName: name || undefined
152
+ });
153
+ },
154
+ });
155
+ }
156
+ };
157
+
158
+ // Spaces browser hook
159
+ const spacesBrowserProps = useSpacesBrowser({
160
+ workspaces: terminal.workspaces,
161
+ sessions: terminal.sessions,
162
+ onRequestSessions: terminal.requestSessions,
163
+ onAttachSession: handleAttachSession,
164
+ onRefresh: terminal.requestWorkspaces,
165
+ onRefreshSessions: (workspaceIds) => {
166
+ workspaceIds.forEach(id => terminal.requestSessions(id));
167
+ },
168
+ onBack: handleBackToMachines,
169
+ machineName: selectedMachine?.label || selectedMachine?.machineId,
170
+ });
171
+
172
+ // Inbox hook
173
+ const inboxProps = useInbox({
174
+ items: terminal.inbox,
175
+ unreadCount: terminal.inboxUnreadCount,
176
+ onClearItem: async (id) => terminal.clearInboxItem(id),
177
+ onClearAll: async () => terminal.clearInboxItem(),
178
+ onMarkRead: async (id) => terminal.markInboxItemRead(id),
179
+ onAttachSession: async (sessionId) => {
180
+ setShowInbox(false);
181
+ terminal.attachSession({ sessionId });
182
+ },
183
+ onClose: () => setShowInbox(false),
184
+ });
185
+
186
+ // Request workspaces when connection is established and view is "terminal"
187
+ useEffect(() => {
188
+ if (view === "terminal" && terminal.status === "established" && terminal.mode === "browsing") {
189
+ terminal.requestWorkspaces();
190
+ }
191
+ }, [view, terminal.status, terminal.mode, terminal.requestWorkspaces]);
192
+
193
+ // ========== Keyboard Handlers ==========
194
+
195
+ // Machine list keyboard navigation
196
+ useEffect(() => {
197
+ if (view !== "machines") return;
198
+
199
+ const handleKeyDown = (e: KeyboardEvent) => {
200
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
201
+ return;
202
+ }
203
+
204
+ const key = e.key;
205
+ if (key === "ArrowUp" || key === "k") {
206
+ e.preventDefault();
207
+ machineListProps.moveUp();
208
+ } else if (key === "ArrowDown" || key === "j") {
209
+ e.preventDefault();
210
+ machineListProps.moveDown();
211
+ } else if (key === "Enter") {
212
+ e.preventDefault();
213
+ machineListProps.connectSelected();
214
+ } else if (key === "r") {
215
+ e.preventDefault();
216
+ machineListProps.refresh();
217
+ } else if (key === "c") {
218
+ e.preventDefault();
219
+ machineListProps.copyPublicKey();
220
+ } else if (key === "?") {
221
+ e.preventDefault();
222
+ flow.showHelp(getDefaultShortcuts());
223
+ }
224
+ };
225
+
226
+ window.addEventListener("keydown", handleKeyDown);
227
+ return () => window.removeEventListener("keydown", handleKeyDown);
228
+ }, [view, machineListProps, flow]);
229
+
230
+ // Inbox keyboard navigation
231
+ useEffect(() => {
232
+ if (!showInbox) return;
233
+
234
+ const handleKeyDown = (e: KeyboardEvent) => {
235
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
236
+ return;
237
+ }
238
+
239
+ const key = e.key;
240
+ if (key === "ArrowUp" || key === "k") {
241
+ e.preventDefault();
242
+ inboxProps.moveUp();
243
+ } else if (key === "ArrowDown" || key === "j") {
244
+ e.preventDefault();
245
+ inboxProps.moveDown();
246
+ } else if (key === "Enter") {
247
+ e.preventDefault();
248
+ if (inboxProps.isViewingThread) {
249
+ // In thread view, attach to session
250
+ inboxProps.attachToSession();
251
+ } else {
252
+ // In list view, open thread
253
+ inboxProps.openThread();
254
+ }
255
+ } else if (key === "Escape" || key === "q") {
256
+ e.preventDefault();
257
+ if (inboxProps.isViewingThread) {
258
+ inboxProps.closeThread();
259
+ } else {
260
+ setShowInbox(false);
261
+ }
262
+ } else if (key === "x") {
263
+ e.preventDefault();
264
+ if (inboxProps.isViewingThread) {
265
+ inboxProps.deleteThread();
266
+ } else {
267
+ inboxProps.deleteSelected();
268
+ }
269
+ } else if (key === "c") {
270
+ e.preventDefault();
271
+ inboxProps.clearAll();
272
+ } else if (key === "a") {
273
+ e.preventDefault();
274
+ inboxProps.attachToSession();
275
+ }
276
+ };
277
+
278
+ window.addEventListener("keydown", handleKeyDown);
279
+ return () => window.removeEventListener("keydown", handleKeyDown);
280
+ }, [showInbox, inboxProps.moveUp, inboxProps.moveDown, inboxProps.openThread, inboxProps.closeThread, inboxProps.deleteSelected, inboxProps.deleteThread, inboxProps.clearAll, inboxProps.attachToSession, inboxProps.isViewingThread]);
281
+
282
+ // Spaces browser keyboard navigation
283
+ useEffect(() => {
284
+ if (view !== "terminal" || terminal.status !== "established" || terminal.mode !== "browsing" || showInbox) {
285
+ return;
286
+ }
287
+
288
+ const handleKeyDown = (e: KeyboardEvent) => {
289
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
290
+ return;
291
+ }
292
+
293
+ const key = e.key;
294
+ if (key === "ArrowUp" || key === "k") {
295
+ e.preventDefault();
296
+ spacesBrowserProps.moveUp();
297
+ } else if (key === "ArrowDown" || key === "j") {
298
+ e.preventDefault();
299
+ spacesBrowserProps.moveDown();
300
+ } else if (key === "Enter") {
301
+ e.preventDefault();
302
+ spacesBrowserProps.activateSelected();
303
+ } else if (key === "n") {
304
+ // New session - uses same flow as clicking "+ New Session"
305
+ e.preventDefault();
306
+ spacesBrowserProps.createNewSession();
307
+ } else if (key === "r") {
308
+ e.preventDefault();
309
+ spacesBrowserProps.refresh();
310
+ } else if (key === "Escape" || key === "q") {
311
+ e.preventDefault();
312
+ spacesBrowserProps.back();
313
+ } else if (key === "?") {
314
+ e.preventDefault();
315
+ flow.showHelp(getDefaultShortcuts());
316
+ } else if (key === "x") {
317
+ // Kill session
318
+ e.preventDefault();
319
+ const selected = spacesBrowserProps.selectedItem;
320
+ if (selected?.type === 'session') {
321
+ flow.showConfirm({
322
+ title: 'Kill Session',
323
+ message: `Kill session "${selected.session.name}"?`,
324
+ variant: 'warning',
325
+ confirmLabel: 'Kill',
326
+ onConfirm: () => {
327
+ terminal.killSession(selected.session.id);
328
+ },
329
+ });
330
+ }
331
+ } else if (key === "d") {
332
+ // Delete workspace - require typing name to confirm
333
+ e.preventDefault();
334
+ const selected = spacesBrowserProps.selectedItem;
335
+ if (selected?.type === 'workspace') {
336
+ const sessionCount = selected.workspace.sessionCount || 0;
337
+ flow.showConfirmTyped({
338
+ title: 'Delete Workspace',
339
+ message: `Are you sure you want to delete workspace "${selected.workspace.name}"?`,
340
+ confirmText: selected.workspace.name,
341
+ warning: sessionCount > 0 ? `This will kill ${sessionCount} active session(s)!` : undefined,
342
+ onConfirm: () => {
343
+ terminal.deleteWorkspace(selected.workspace.projectName, selected.workspace.id);
344
+ },
345
+ });
346
+ }
347
+ } else if (key === "i") {
348
+ // Open inbox
349
+ e.preventDefault();
350
+ terminal.requestInbox();
351
+ setShowInbox(true);
352
+ }
353
+ };
354
+
355
+ window.addEventListener("keydown", handleKeyDown);
356
+ return () => window.removeEventListener("keydown", handleKeyDown);
357
+ }, [view, terminal.status, terminal.mode, spacesBrowserProps, flow]);
358
+
359
+ // Attached terminal mode keyboard handler (Ctrl+Esc to detach)
360
+ useEffect(() => {
361
+ if (view !== "terminal" || terminal.status !== "established" || terminal.mode !== "attached") {
362
+ return;
363
+ }
364
+
365
+ const handleKeyDown = (e: KeyboardEvent) => {
366
+ // Ctrl+Esc to detach from session
367
+ if (e.ctrlKey && e.key === "Escape") {
368
+ e.preventDefault();
369
+ terminal.detachSession();
370
+ }
371
+ };
372
+
373
+ window.addEventListener("keydown", handleKeyDown);
374
+ return () => window.removeEventListener("keydown", handleKeyDown);
375
+ }, [view, terminal.status, terminal.mode, terminal.detachSession]);
376
+
377
+ // ========== Spaces Browser View (browsing mode) ==========
378
+ if (view === "terminal" && terminal.status === "established" && terminal.mode === "browsing") {
379
+ // Show inbox if open
380
+ if (showInbox) {
381
+ return (
382
+ <>
383
+ <InboxWeb {...inboxProps} />
384
+ <FlowWeb flow={flow} />
385
+ </>
386
+ );
387
+ }
388
+
389
+ return (
390
+ <>
391
+ <div className="h-screen w-screen flex flex-col bg-gray-900">
392
+ <div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700 min-h-[52px] gap-2">
393
+ <div className="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
394
+ <button
395
+ onClick={handleBackToMachines}
396
+ className="text-sm text-gray-400 hover:text-white active:text-blue-400 py-2 pr-2 -ml-2 min-h-[44px] flex items-center flex-shrink-0"
397
+ >
398
+ ← <span className="hidden sm:inline ml-1">Machines</span>
399
+ </button>
400
+ <div className="text-sm text-gray-400 truncate hidden sm:block">
401
+ <span className="text-green-400">●</span>{" "}
402
+ {selectedMachine?.label || selectedMachine?.machineId}
403
+ </div>
404
+ </div>
405
+ <div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
406
+ <button
407
+ onClick={() => {
408
+ terminal.requestInbox();
409
+ setShowInbox(true);
410
+ }}
411
+ className="text-sm text-gray-400 hover:text-white active:text-blue-400 flex items-center gap-1 py-2 px-2 min-h-[44px]"
412
+ >
413
+ <span className="hidden sm:inline text-xs text-gray-500">[i]</span>
414
+ <span>Inbox</span>
415
+ {terminal.inboxUnreadCount > 0 && (
416
+ <span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-600 rounded-full text-white">
417
+ {terminal.inboxUnreadCount}
418
+ </span>
419
+ )}
420
+ </button>
421
+ <button
422
+ onClick={handleDisconnect}
423
+ className="px-3 py-2 text-sm bg-red-600 hover:bg-red-700 active:bg-red-800 rounded text-white min-h-[44px]"
424
+ >
425
+ <span className="hidden sm:inline">Disconnect</span>
426
+ <span className="sm:hidden">×</span>
427
+ </button>
428
+ </div>
429
+ </div>
430
+ <div className="flex-1 overflow-hidden">
431
+ <SpacesBrowserWeb {...spacesBrowserProps} />
432
+ </div>
433
+ </div>
434
+ <FlowWeb flow={flow} />
435
+ </>
436
+ );
437
+ }
438
+
439
+ // ========== Terminal View (attached mode) ==========
440
+ if (view === "terminal" && terminal.status === "established" && terminal.mode === "attached") {
441
+ return (
442
+ <div className="h-screen w-screen flex flex-col bg-gray-900">
443
+ <div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700 min-h-[52px] gap-2">
444
+ <div className="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
445
+ <button
446
+ onClick={terminal.detachSession}
447
+ className="text-sm text-gray-400 hover:text-white active:text-blue-400 py-2 pr-2 -ml-2 min-h-[44px] flex items-center flex-shrink-0"
448
+ >
449
+ ← <span className="hidden sm:inline ml-1">Workspaces</span>
450
+ </button>
451
+ <div className="text-sm text-gray-400 truncate">
452
+ <span className="text-green-400">●</span>{" "}
453
+ <span className="hidden sm:inline">{selectedMachine?.label || selectedMachine?.machineId}</span>
454
+ {terminal.attachedSessionName && (
455
+ <span className="text-gray-300">
456
+ <span className="hidden sm:inline text-gray-500 mx-1">/</span>
457
+ {terminal.attachedSessionName.split(':').pop()}
458
+ </span>
459
+ )}
460
+ </div>
461
+ </div>
462
+ <div className="flex items-center gap-2 flex-shrink-0">
463
+ <span className="text-xs text-gray-500 hidden sm:inline">Ctrl+Esc</span>
464
+ <button
465
+ onClick={terminal.detachSession}
466
+ className="px-3 py-2 text-sm bg-gray-700 hover:bg-gray-600 active:bg-gray-500 rounded text-white min-h-[44px]"
467
+ >
468
+ Detach
469
+ </button>
470
+ </div>
471
+ </div>
472
+ <div className="flex-1">
473
+ <Terminal
474
+ onData={terminal.send}
475
+ setWriteCallback={terminal.setWriteCallback}
476
+ onResize={terminal.resize}
477
+ />
478
+ </div>
479
+ </div>
480
+ );
481
+ }
482
+
483
+ // ========== Terminal Connecting View ==========
484
+ if (view === "terminal") {
485
+ const statusMessage = {
486
+ disconnected: "Disconnected",
487
+ connecting: "Connecting to relay...",
488
+ connected: "Connected, authenticating...",
489
+ handshaking: "Establishing secure connection...",
490
+ established: "Connected!",
491
+ error: "Connection failed",
492
+ }[terminal.status];
493
+
494
+ return (
495
+ <div className="h-screen w-screen flex flex-col items-center justify-center bg-gray-900 px-4">
496
+ <div className="text-center">
497
+ <div className="text-lg text-white mb-2 break-words">
498
+ Connecting to {selectedMachine?.label || selectedMachine?.machineId}
499
+ </div>
500
+ <div className="text-sm text-gray-400">{statusMessage}</div>
501
+ {terminal.status === "error" && (
502
+ <button
503
+ onClick={handleBackToMachines}
504
+ className="mt-4 px-6 py-3 text-base bg-gray-700 hover:bg-gray-600 active:bg-gray-500 rounded-lg text-white min-h-[48px]"
505
+ >
506
+ Back to Machines
507
+ </button>
508
+ )}
509
+ </div>
510
+ </div>
511
+ );
512
+ }
513
+
514
+ // ========== Machine List View ==========
515
+ // This is now the main/default view - shows machines and your identity
516
+ return (
517
+ <>
518
+ <div className="h-screen w-screen flex flex-col bg-gray-900">
519
+ {/* Header with identity info */}
520
+ <div className="bg-gray-800 px-4 py-3 border-b border-gray-700">
521
+ <div className="max-w-2xl mx-auto">
522
+ {/* Connection status */}
523
+ <div className="flex items-center justify-between mb-3">
524
+ <div className="flex items-center gap-2">
525
+ <span className={`w-2 h-2 rounded-full ${
526
+ relay.status === "connected" ? "bg-green-400" :
527
+ relay.status === "connecting" ? "bg-yellow-400 animate-pulse" :
528
+ "bg-red-400"
529
+ }`} />
530
+ <span className="text-sm text-gray-400">
531
+ {relay.status === "connected" ? "Connected" :
532
+ relay.status === "connecting" ? "Connecting..." :
533
+ "Disconnected"}
534
+ </span>
535
+ </div>
536
+ <button
537
+ onClick={relay.refreshMachines}
538
+ className="text-xs text-gray-500 hover:text-white px-2 py-1"
539
+ >
540
+ Refresh
541
+ </button>
542
+ </div>
543
+
544
+ {/* Your identity - prominent display */}
545
+ {relay.publicKey && (
546
+ <div className="bg-gray-900 rounded-lg p-3">
547
+ <div className="flex items-center justify-between mb-2">
548
+ <span className="text-xs text-gray-400">Your Browser Identity</span>
549
+ </div>
550
+ <code className="block text-xs text-green-400 break-all font-mono leading-relaxed mb-3">
551
+ {relay.publicKey}
552
+ </code>
553
+ <p className="text-xs text-gray-500 mb-2">
554
+ To get access, have the machine owner run:
555
+ </p>
556
+ <div className="flex items-center gap-2">
557
+ <code className="flex-1 text-xs text-gray-300 bg-gray-800 px-2 py-2 rounded font-mono overflow-x-auto">
558
+ gssh access add "{relay.publicKey.slice(0, 20)}..."
559
+ </code>
560
+ <button
561
+ onClick={copyAccessCommand}
562
+ className="text-xs text-blue-400 hover:text-blue-300 bg-gray-800 px-3 py-2 rounded whitespace-nowrap"
563
+ >
564
+ {copied ? "Copied!" : "Copy Command"}
565
+ </button>
566
+ </div>
567
+ </div>
568
+ )}
569
+ </div>
570
+ </div>
571
+
572
+ {/* Machine list */}
573
+ <div className="flex-1 overflow-auto">
574
+ {relay.status === "connecting" ? (
575
+ <div className="flex items-center justify-center h-full">
576
+ <div className="text-gray-400">Connecting to relay...</div>
577
+ </div>
578
+ ) : relay.machines.length === 0 ? (
579
+ <div className="flex items-center justify-center h-full p-4">
580
+ <div className="text-center max-w-md">
581
+ <div className="text-gray-400 mb-2">No machines available</div>
582
+ <p className="text-sm text-gray-500">
583
+ {relay.status === "connected"
584
+ ? "The machine may not be online. Check if 'gssh serve' is running."
585
+ : "Unable to connect to relay."}
586
+ </p>
587
+ </div>
588
+ </div>
589
+ ) : (
590
+ <MachineListWeb {...machineListProps} />
591
+ )}
592
+ </div>
593
+
594
+ {/* Footer */}
595
+ <div className="bg-gray-800 px-4 py-2 border-t border-gray-700">
596
+ <p className="text-xs text-gray-500 text-center">
597
+ End-to-end encrypted via X3DH
598
+ </p>
599
+ </div>
600
+ </div>
601
+ <FlowWeb flow={flow} />
602
+ </>
603
+ );
604
+ }