the universal sandbox runtime for agents and humans. pocketenv.io
sandbox openclaw agent claude-code vercel-sandbox deno-sandbox cloudflare-sandbox atproto sprites daytona
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add SSH-backed web terminal and API routes

Add a server SSH router (ssh2) with
connect/stream/input/resize/disconnect
endpoints and register the ssh2 dependency. Add a React terminal
component
and modal using react-xtermjs with SSE-based streaming and a Cascadia
Code
font import. Rename sandbox field base -> baseSandbox in
getActorSandboxes.

+771 -7
+22
apps/api/bun.lock
··· 43 43 "ramda": "^0.32.0", 44 44 "redis": "^5.10.0", 45 45 "redlock": "^5.0.0-beta.2", 46 + "ssh2": "^1.16.0", 46 47 "unstorage": "^1.17.4", 47 48 "zod": "^4.3.6", 48 49 "zx": "^8.8.5", ··· 56 57 "@types/lodash": "^4.17.23", 57 58 "@types/morgan": "^1.9.10", 58 59 "@types/pg": "^8.16.0", 60 + "@types/ssh2": "^1.15.4", 59 61 "drizzle-kit": "^0.31.9", 60 62 "tsx": "^4.21.0", 61 63 }, ··· 284 286 285 287 "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], 286 288 289 + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], 290 + 287 291 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 288 292 289 293 "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], ··· 293 297 "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], 294 298 295 299 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 300 + 301 + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], 296 302 297 303 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 298 304 ··· 308 314 309 315 "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], 310 316 317 + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], 318 + 311 319 "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], 312 320 313 321 "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], ··· 325 333 "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], 326 334 327 335 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], 336 + 337 + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], 328 338 329 339 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 330 340 ··· 372 382 373 383 "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], 374 384 385 + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], 386 + 375 387 "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], 376 388 377 389 "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], ··· 592 604 593 605 "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 594 606 607 + "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], 608 + 595 609 "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], 596 610 597 611 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], ··· 760 774 761 775 "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 762 776 777 + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], 778 + 763 779 "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], 764 780 765 781 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], ··· 792 808 793 809 "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], 794 810 811 + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], 812 + 795 813 "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], 796 814 797 815 "types-ramda": ["types-ramda@0.31.0", "", { "dependencies": { "ts-toolbelt": "^9.6.0" } }, "sha512-vaoC35CRC3xvL8Z6HkshDbi6KWM1ezK0LHN0YyxXWUn9HKzBNg/T3xSGlJZjCYspnOD3jE7bcizsp0bUXZDxnQ=="], ··· 891 909 "@atproto/xrpc-server/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 892 910 893 911 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], 912 + 913 + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], 894 914 895 915 "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], 896 916 ··· 995 1015 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], 996 1016 997 1017 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], 1018 + 1019 + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 998 1020 999 1021 "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1000 1022
+2
apps/api/package.json
··· 52 52 "ramda": "^0.32.0", 53 53 "redis": "^5.10.0", 54 54 "redlock": "^5.0.0-beta.2", 55 + "ssh2": "^1.16.0", 55 56 "unstorage": "^1.17.4", 56 57 "zod": "^4.3.6", 57 58 "zx": "^8.8.5" ··· 65 66 "@types/lodash": "^4.17.23", 66 67 "@types/morgan": "^1.9.10", 67 68 "@types/pg": "^8.16.0", 69 + "@types/ssh2": "^1.15.4", 68 70 "drizzle-kit": "^0.31.9", 69 71 "tsx": "^4.21.0" 70 72 }
+2
apps/api/src/index.ts
··· 7 7 import { createServer } from "lexicon"; 8 8 import chalk from "chalk"; 9 9 import API from "./xrpc"; 10 + import sshRouter from "./ssh"; 10 11 11 12 let server = createServer({ 12 13 validateResponse: false, ··· 47 48 }); 48 49 49 50 app.use(bsky); 51 + app.use(sshRouter); 50 52 app.use(server.xrpc.router); 51 53 52 54 app.listen(process.env.POCKETENV_XPRC_PORT || 8789, () => {
+214
apps/api/src/ssh/index.ts
··· 1 + import express, { Router } from "express"; 2 + import { Client } from "ssh2"; 3 + import { randomUUID } from "node:crypto"; 4 + import { consola } from "consola"; 5 + 6 + interface SSHSession { 7 + client: Client; 8 + stream: NodeJS.ReadWriteStream | null; 9 + sseRes: import("express").Response | null; 10 + } 11 + 12 + const sessions = new Map<string, SSHSession>(); 13 + 14 + const SSH_HOST = process.env.SSH_HOST || "example.com"; 15 + const SSH_PORT = Number(process.env.SSH_PORT || 22); 16 + const SSH_USERNAME = process.env.SSH_USERNAME || "user"; 17 + const SSH_PASSWORD = process.env.SSH_PASSWORD || "123"; 18 + 19 + const router = Router(); 20 + 21 + router.use(express.json()); 22 + 23 + /** 24 + * POST /ssh/connect 25 + * Creates a new SSH session and returns the sessionId. 26 + * Optionally accepts { cols, rows } in the body. 27 + */ 28 + router.post("/ssh/connect", (req, res) => { 29 + const sessionId = randomUUID(); 30 + consola.log(req.body); 31 + const cols = req.body?.cols || 80; 32 + const rows = req.body?.rows || 24; 33 + 34 + const client = new Client(); 35 + 36 + const session: SSHSession = { 37 + client, 38 + stream: null, 39 + sseRes: null, 40 + }; 41 + 42 + sessions.set(sessionId, session); 43 + 44 + client.on("ready", () => { 45 + consola.success(`SSH session ${sessionId} connected`); 46 + 47 + client.shell({ cols, rows, term: "xterm-256color" }, (err, stream) => { 48 + if (err) { 49 + consola.error(`SSH shell error for session ${sessionId}:`, err); 50 + sessions.delete(sessionId); 51 + res.status(500).json({ error: "Failed to open shell" }); 52 + return; 53 + } 54 + 55 + session.stream = stream; 56 + 57 + stream.on("data", (data: Buffer) => { 58 + if (session.sseRes && !session.sseRes.writableEnded) { 59 + const encoded = Buffer.from(data).toString("base64"); 60 + session.sseRes.write(`data: ${encoded}\n\n`); 61 + } 62 + }); 63 + 64 + stream.on("close", () => { 65 + consola.info(`SSH stream closed for session ${sessionId}`); 66 + if (session.sseRes && !session.sseRes.writableEnded) { 67 + session.sseRes.write(`event: close\ndata: closed\n\n`); 68 + session.sseRes.end(); 69 + } 70 + client.end(); 71 + sessions.delete(sessionId); 72 + }); 73 + 74 + stream.stderr.on("data", (data: Buffer) => { 75 + if (session.sseRes && !session.sseRes.writableEnded) { 76 + const encoded = Buffer.from(data).toString("base64"); 77 + session.sseRes.write(`data: ${encoded}\n\n`); 78 + } 79 + }); 80 + 81 + res.json({ sessionId }); 82 + }); 83 + }); 84 + 85 + client.on("error", (err) => { 86 + consola.error(`SSH connection error for session ${sessionId}:`, err); 87 + if (session.sseRes && !session.sseRes.writableEnded) { 88 + session.sseRes.write( 89 + `event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`, 90 + ); 91 + session.sseRes.end(); 92 + } 93 + sessions.delete(sessionId); 94 + // Only respond if headers haven't been sent 95 + if (!res.headersSent) { 96 + res 97 + .status(500) 98 + .json({ error: "SSH connection failed", message: err.message }); 99 + } 100 + }); 101 + 102 + client.connect({ 103 + host: SSH_HOST, 104 + port: SSH_PORT, 105 + username: SSH_USERNAME, 106 + password: SSH_PASSWORD, 107 + }); 108 + }); 109 + 110 + /** 111 + * GET /ssh/stream/:sessionId 112 + * SSE endpoint that streams SSH output to the client. 113 + */ 114 + router.get("/ssh/stream/:sessionId", (req, res) => { 115 + const { sessionId } = req.params; 116 + const session = sessions.get(sessionId); 117 + 118 + if (!session) { 119 + res.status(404).json({ error: "Session not found" }); 120 + return; 121 + } 122 + 123 + // Set SSE headers 124 + res.setHeader("Content-Type", "text/event-stream"); 125 + res.setHeader("Cache-Control", "no-cache"); 126 + res.setHeader("Connection", "keep-alive"); 127 + res.setHeader("X-Accel-Buffering", "no"); 128 + res.flushHeaders(); 129 + 130 + // Send initial connected event 131 + res.write(`event: connected\ndata: ${sessionId}\n\n`); 132 + 133 + session.sseRes = res; 134 + 135 + // Handle client disconnect 136 + req.on("close", () => { 137 + consola.info(`SSE client disconnected for session ${sessionId}`); 138 + session.sseRes = null; 139 + }); 140 + }); 141 + 142 + /** 143 + * POST /ssh/input/:sessionId 144 + * Sends keyboard input to the SSH session. 145 + * Body: { data: string } 146 + */ 147 + router.post("/ssh/input/:sessionId", (req, res) => { 148 + const { sessionId } = req.params; 149 + const session = sessions.get(sessionId); 150 + 151 + if (!session || !session.stream) { 152 + res.status(404).json({ error: "Session not found" }); 153 + return; 154 + } 155 + 156 + const { data } = req.body; 157 + if (data) { 158 + session.stream.write(data); 159 + } 160 + 161 + res.json({ ok: true }); 162 + }); 163 + 164 + /** 165 + * POST /ssh/resize/:sessionId 166 + * Resizes the SSH terminal. 167 + * Body: { cols: number, rows: number } 168 + */ 169 + router.post("/ssh/resize/:sessionId", (req, res) => { 170 + const { sessionId } = req.params; 171 + const session = sessions.get(sessionId); 172 + 173 + if (!session || !session.stream) { 174 + res.status(404).json({ error: "Session not found" }); 175 + return; 176 + } 177 + 178 + const { cols, rows } = req.body; 179 + if (cols && rows) { 180 + (session.stream as any).setWindow(rows, cols, 0, 0); 181 + } 182 + 183 + res.json({ ok: true }); 184 + }); 185 + 186 + /** 187 + * DELETE /ssh/disconnect/:sessionId 188 + * Disconnects the SSH session. 189 + */ 190 + router.delete("/ssh/disconnect/:sessionId", (req, res) => { 191 + const { sessionId } = req.params; 192 + const session = sessions.get(sessionId); 193 + 194 + if (!session) { 195 + res.status(404).json({ error: "Session not found" }); 196 + return; 197 + } 198 + 199 + if (session.stream) { 200 + session.stream.end(); 201 + } 202 + session.client.end(); 203 + 204 + if (session.sseRes && !session.sseRes.writableEnded) { 205 + session.sseRes.end(); 206 + } 207 + 208 + sessions.delete(sessionId); 209 + consola.info(`SSH session ${sessionId} disconnected`); 210 + 211 + res.json({ ok: true }); 212 + }); 213 + 214 + export default router;
+1 -1
apps/api/src/xrpc/io/pocketenv/actor/getActorSandboxes.ts
··· 89 89 sandboxes: sandboxes.map((sandbox) => ({ 90 90 id: sandbox.id, 91 91 name: sandbox.name, 92 - base: sandbox.base, 92 + baseSandbox: sandbox.base as string, 93 93 displayName: sandbox.displayName, 94 94 description: sandbox.description!, 95 95 logo: sandbox.logo!,
+3
apps/web/bun.lock
··· 28 28 "react-content-loader": "^7.1.2", 29 29 "react-dom": "^19.2.0", 30 30 "react-hook-form": "^7.71.1", 31 + "react-xtermjs": "^1.0.10", 31 32 "zod": "^4.3.6", 32 33 }, 33 34 "devDependencies": { ··· 731 732 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 732 733 733 734 "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], 735 + 736 + "react-xtermjs": ["react-xtermjs@1.0.10", "", { "peerDependencies": { "@xterm/xterm": "^5.5.0" } }, "sha512-+xpKEKbmsypWzRKE0FR1LNIGcI8gx+R6VMHe8IQW7iTbgeqp3Qg7SbiVNOzR+Ovb1QK4DPA3KqsIQV+XP0iRUA=="], 734 737 735 738 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], 736 739
+1
apps/web/package.json
··· 33 33 "react-content-loader": "^7.1.2", 34 34 "react-dom": "^19.2.0", 35 35 "react-hook-form": "^7.71.1", 36 + "react-xtermjs": "^1.0.10", 36 37 "zod": "^4.3.6" 37 38 }, 38 39 "devDependencies": {
+282
apps/web/src/components/terminal/Terminal.tsx
··· 1 + import { useEffect, useRef, useMemo, useCallback, useState } from "react"; 2 + import { useXTerm } from "react-xtermjs"; 3 + import { FitAddon } from "@xterm/addon-fit"; 4 + import { API_URL } from "../../consts"; 5 + 6 + const darkTheme = { 7 + background: "#06051d", 8 + foreground: "#e5e5e5", 9 + cursor: "#e5e5e5", 10 + cursorAccent: "#06051d", 11 + selectionBackground: "rgba(99, 102, 241, 0.3)", 12 + scrollbarSliderBackground: "rgba(255, 255, 255, 0.12)", 13 + scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.25)", 14 + scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.35)", 15 + black: "#06051d", 16 + red: "#ef4444", 17 + green: "#22c55e", 18 + yellow: "#f59e0b", 19 + blue: "#6366f1", 20 + magenta: "#7c3aed", 21 + cyan: "#3abff8", 22 + white: "#e5e5e5", 23 + brightBlack: "#a0a0a0", 24 + brightRed: "#f87171", 25 + brightGreen: "#4ade80", 26 + brightYellow: "#fbbf24", 27 + brightBlue: "#818cf8", 28 + brightMagenta: "#a78bfa", 29 + brightCyan: "#67e8f9", 30 + brightWhite: "#ffffff", 31 + }; 32 + 33 + const lightTheme = { 34 + background: "#ffffff", 35 + foreground: "#1f2937", 36 + cursor: "#1f2937", 37 + cursorAccent: "#ffffff", 38 + selectionBackground: "rgba(99, 102, 241, 0.3)", 39 + scrollbarSliderBackground: "rgba(0, 0, 0, 0.12)", 40 + scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", 41 + scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.35)", 42 + black: "#1f2937", 43 + red: "#ef4444", 44 + green: "#22c55e", 45 + yellow: "#f59e0b", 46 + blue: "#6366f1", 47 + magenta: "#7c3aed", 48 + cyan: "#0891b2", 49 + white: "#f9fafb", 50 + brightBlack: "#6b7280", 51 + brightRed: "#f87171", 52 + brightGreen: "#4ade80", 53 + brightYellow: "#fbbf24", 54 + brightBlue: "#818cf8", 55 + brightMagenta: "#a78bfa", 56 + brightCyan: "#22d3ee", 57 + brightWhite: "#ffffff", 58 + }; 59 + 60 + interface TerminalContentProps { 61 + isDarkMode: boolean; 62 + } 63 + 64 + function TerminalContent({ isDarkMode }: TerminalContentProps) { 65 + const sessionIdRef = useRef<string | null>(null); 66 + const eventSourceRef = useRef<EventSource | null>(null); 67 + const fitAddonRef = useRef<FitAddon | null>(null); 68 + 69 + const theme = isDarkMode ? darkTheme : lightTheme; 70 + 71 + const options = useMemo( 72 + () => ({ 73 + cursorBlink: true, 74 + cursorStyle: "block" as const, 75 + fontFamily: 76 + '"Cascadia Code", "JetBrains Mono", "Fira Code", Menlo, Monaco, monospace', 77 + fontSize: 14, 78 + lineHeight: 1.2, 79 + letterSpacing: 0, 80 + theme, 81 + allowProposedApi: true, 82 + scrollback: 5000, 83 + }), 84 + [theme], 85 + ); 86 + 87 + const { ref, instance } = useXTerm({ options }); 88 + 89 + const sendInput = useCallback(async (data: string) => { 90 + const sid = sessionIdRef.current; 91 + if (!sid) return; 92 + try { 93 + await fetch(`${API_URL}/ssh/input/${sid}`, { 94 + method: "POST", 95 + headers: { "Content-Type": "application/json" }, 96 + body: JSON.stringify({ data }), 97 + }); 98 + } catch { 99 + // Silently ignore input errors (session may have closed) 100 + } 101 + }, []); 102 + 103 + const sendResize = useCallback(async (cols: number, rows: number) => { 104 + const sid = sessionIdRef.current; 105 + if (!sid) return; 106 + try { 107 + await fetch(`${API_URL}/ssh/resize/${sid}`, { 108 + method: "POST", 109 + headers: { "Content-Type": "application/json" }, 110 + body: JSON.stringify({ cols, rows }), 111 + }); 112 + } catch { 113 + // Silently ignore resize errors 114 + } 115 + }, []); 116 + 117 + useEffect(() => { 118 + if (!instance) return; 119 + 120 + const fitAddon = new FitAddon(); 121 + fitAddonRef.current = fitAddon; 122 + instance.loadAddon(fitAddon); 123 + 124 + // Fit after a small delay to ensure container is sized 125 + const fitTimer = setTimeout(() => { 126 + try { 127 + fitAddon.fit(); 128 + } catch { 129 + // ignore fit errors on mount 130 + } 131 + }, 100); 132 + 133 + // Handle window resize 134 + const handleResize = () => { 135 + try { 136 + fitAddon.fit(); 137 + } catch { 138 + // ignore 139 + } 140 + }; 141 + window.addEventListener("resize", handleResize); 142 + 143 + // Send terminal resize to SSH when xterm resizes 144 + const resizeDisposable = instance.onResize(({ cols, rows }) => { 145 + sendResize(cols, rows); 146 + }); 147 + 148 + const connect = async () => { 149 + try { 150 + const cols = instance.cols; 151 + const rows = instance.rows; 152 + 153 + const response = await fetch(`${API_URL}/ssh/connect`, { 154 + method: "POST", 155 + headers: { "Content-Type": "application/json" }, 156 + body: JSON.stringify({ cols, rows }), 157 + }); 158 + 159 + if (!response.ok) { 160 + const err = await response.json(); 161 + instance.write( 162 + `\x1b[38;5;203mSSH connection failed: ${err.message || err.error}\x1b[0m\r\n`, 163 + ); 164 + return; 165 + } 166 + 167 + const { sessionId } = await response.json(); 168 + sessionIdRef.current = sessionId; 169 + 170 + // Open SSE stream 171 + const es = new EventSource(`${API_URL}/ssh/stream/${sessionId}`); 172 + eventSourceRef.current = es; 173 + 174 + es.addEventListener("connected", () => { 175 + // SSE connected, terminal is ready 176 + instance.focus(); 177 + }); 178 + 179 + es.onmessage = (event) => { 180 + // Data is base64-encoded 181 + const bytes = atob(event.data); 182 + const arr = new Uint8Array(bytes.length); 183 + for (let i = 0; i < bytes.length; i++) { 184 + arr[i] = bytes.charCodeAt(i); 185 + } 186 + const text = new TextDecoder().decode(arr); 187 + instance.write(text); 188 + }; 189 + 190 + es.addEventListener("close", () => { 191 + instance.write("\r\n\x1b[38;5;250mSSH session closed.\x1b[0m\r\n"); 192 + es.close(); 193 + eventSourceRef.current = null; 194 + sessionIdRef.current = null; 195 + }); 196 + 197 + es.addEventListener("error", (e) => { 198 + // EventSource error can be a reconnect or a real error 199 + if (es.readyState === EventSource.CLOSED) { 200 + instance.write("\r\n\x1b[38;5;203mSSH connection lost.\x1b[0m\r\n"); 201 + eventSourceRef.current = null; 202 + sessionIdRef.current = null; 203 + } 204 + }); 205 + } catch (err: any) { 206 + instance.write( 207 + `\x1b[38;5;203mFailed to connect: ${err.message}\x1b[0m\r\n`, 208 + ); 209 + } 210 + }; 211 + 212 + connect(); 213 + 214 + // Forward keyboard input to SSH 215 + const dataDisposable = instance.onData((data) => { 216 + sendInput(data); 217 + }); 218 + 219 + return () => { 220 + clearTimeout(fitTimer); 221 + window.removeEventListener("resize", handleResize); 222 + dataDisposable.dispose(); 223 + resizeDisposable.dispose(); 224 + 225 + // Cleanup SSH session 226 + if (eventSourceRef.current) { 227 + eventSourceRef.current.close(); 228 + eventSourceRef.current = null; 229 + } 230 + if (sessionIdRef.current) { 231 + // Fire-and-forget disconnect 232 + fetch(`${API_URL}/ssh/disconnect/${sessionIdRef.current}`, { 233 + method: "DELETE", 234 + }).catch(() => {}); 235 + sessionIdRef.current = null; 236 + } 237 + }; 238 + }, [instance, sendInput, sendResize]); 239 + 240 + return ( 241 + <div 242 + ref={ref} 243 + style={{ 244 + width: "100%", 245 + height: "100%", 246 + padding: "8px", 247 + }} 248 + /> 249 + ); 250 + } 251 + 252 + function Terminal() { 253 + const [isDarkMode, setIsDarkMode] = useState( 254 + document.documentElement.classList.contains("dark"), 255 + ); 256 + 257 + useEffect(() => { 258 + const observer = new MutationObserver((mutations) => { 259 + mutations.forEach((mutation) => { 260 + if (mutation.attributeName === "class") { 261 + setIsDarkMode(document.documentElement.classList.contains("dark")); 262 + } 263 + }); 264 + }); 265 + 266 + observer.observe(document.documentElement, { 267 + attributes: true, 268 + attributeFilter: ["class"], 269 + }); 270 + 271 + return () => observer.disconnect(); 272 + }, []); 273 + 274 + return ( 275 + <TerminalContent 276 + key={isDarkMode ? "dark" : "light"} 277 + isDarkMode={isDarkMode} 278 + /> 279 + ); 280 + } 281 + 282 + export default Terminal;
+3
apps/web/src/components/terminal/index.tsx
··· 1 + import Terminal from "./Terminal"; 2 + 3 + export default Terminal;
+1
apps/web/src/index.css
··· 1 1 @import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"); 2 + @import url("https://fonts.googleapis.com/css2?family=Cascadia+Code:ital,wght@0,200..700;1,200..700&display=swap"); 2 3 3 4 @import "tailwindcss"; 4 5 @plugin "@iconify/tailwind4";
+33 -4
apps/web/src/pages/projects/Project/Project.tsx
··· 10 10 import { useQueryClient } from "@tanstack/react-query"; 11 11 import { useAtomValue } from "jotai"; 12 12 import { profileAtom } from "../../../atoms/profile"; 13 + import TerminalModal from "./TerminalModal"; 13 14 14 15 export type ProjectProps = { 15 16 sandbox: Sandbox; ··· 18 19 function Project({ sandbox }: ProjectProps) { 19 20 const navigate = useNavigate(); 20 21 const queryClient = useQueryClient(); 22 + const [modalOpen, setModalOpen] = useState(false); 21 23 const profile = useAtomValue(profileAtom); 22 24 const { mutateAsync: stopSandbox } = useStopSandboxMutation(); 23 25 const { mutateAsync: startSandbox } = useStartSandboxMutation(); ··· 43 45 setDisplayLoading(false); 44 46 }; 45 47 48 + const onOpenTerminal = (e: React.MouseEvent) => { 49 + e.stopPropagation(); 50 + // if (sandbox.status !== "RUNNING") return; 51 + setModalOpen(true); 52 + }; 53 + 46 54 const onOpenProject = () => { 47 55 navigate({ 48 56 to: `/${sandbox.uri.split("at://")[1].replace("io.pocketenv.", "")}`, ··· 56 64 return ( 57 65 <tr className="cursor-pointer" onClick={onOpenProject}> 58 66 <td>{sandbox.name}</td> 59 - <td>{sandbox.base}</td> 67 + <td>{sandbox.baseSandbox}</td> 60 68 <td> 61 69 <span 62 70 className={`badge badge-soft ${sandbox?.status === "RUNNING" ? "badge-success" : ""} rounded-full ${sandbox.status === "RUNNING" ? "bg-green-400/10" : "bg-white/15 rounded"}`} ··· 75 83 <td>{dayjs(sandbox.createdAt).format("M/D/YYYY, h:mm:ss A")}</td> 76 84 <td> 77 85 {!displayLoading && sandbox.status === "RUNNING" && ( 78 - <button className="btn btn-circle btn-text btn-sm" onClick={onStop}> 86 + <button 87 + className="btn btn-circle btn-text btn-sm bg-transparent outline-0" 88 + onClick={onStop} 89 + > 79 90 <span className="icon-[tabler--player-stop] size-5 hover:text-white"></span> 80 91 </button> 81 92 )} 82 93 {!displayLoading && sandbox.status !== "RUNNING" && ( 83 - <button className="btn btn-circle btn-text btn-sm" onClick={onPlay}> 94 + <button 95 + className="btn btn-circle btn-text btn-sm bg-transparent outline-0" 96 + onClick={onPlay} 97 + > 84 98 <span className="icon-[tabler--player-play] size-5 hover:text-white"></span> 85 99 </button> 86 100 )} ··· 88 102 <span className="loading loading-spinner loading-sm btn-text mr-[10px]"></span> 89 103 )} 90 104 <button 91 - className="btn btn-circle btn-text btn-sm" 105 + className={`btn btn-circle btn-text btn-sm bg-transparent outline-0 ${sandbox.status !== "RUNNING" ? "opacity-50" : ""}`} 106 + onClick={onOpenTerminal} 107 + > 108 + <span 109 + className={`icon-[mingcute--terminal-fill] size-5 ${sandbox.status !== "RUNNING" ? "" : "hover:text-white"}`} 110 + ></span> 111 + </button> 112 + <button 113 + className="btn btn-circle btn-text btn-sm bg-transparent outline-0" 92 114 onClick={onOpenContextMenu} 93 115 > 94 116 <span className="icon-[tabler--dots-vertical] size-5 hover:text-white"></span> 95 117 </button> 118 + <TerminalModal 119 + title={sandbox.name} 120 + isOpen={modalOpen} 121 + onClose={() => { 122 + setModalOpen(false); 123 + }} 124 + /> 96 125 </td> 97 126 </tr> 98 127 );
+139
apps/web/src/pages/projects/Project/TerminalModal/TerminalModal.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { createPortal } from "react-dom"; 3 + import Terminal from "../../../../components/terminal"; 4 + 5 + export type TerminalModalProps = { 6 + isOpen: boolean; 7 + onClose: () => void; 8 + title?: string; 9 + }; 10 + 11 + function TerminalModal({ isOpen, onClose, title }: TerminalModalProps) { 12 + const [isFullscreen, setIsFullscreen] = useState(false); 13 + 14 + useEffect(() => { 15 + const handleEscapeKey = (event: KeyboardEvent) => { 16 + if (event.key === "Escape" && isOpen) { 17 + if (isFullscreen) { 18 + setIsFullscreen(false); 19 + (document.activeElement as HTMLElement)?.blur(); 20 + } else { 21 + onClose(); 22 + } 23 + } 24 + }; 25 + 26 + document.addEventListener("keydown", handleEscapeKey); 27 + return () => { 28 + document.removeEventListener("keydown", handleEscapeKey); 29 + }; 30 + }, [isOpen, isFullscreen, onClose]); 31 + 32 + // Reset fullscreen when modal closes 33 + useEffect(() => { 34 + if (!isOpen) { 35 + // eslint-disable-next-line react-hooks/set-state-in-effect 36 + setIsFullscreen(false); 37 + } 38 + }, [isOpen]); 39 + 40 + const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => { 41 + e.stopPropagation(); 42 + if (e.target === e.currentTarget) { 43 + onClose(); 44 + } 45 + }; 46 + 47 + const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => { 48 + e.stopPropagation(); 49 + }; 50 + 51 + const handleCloseButton = (e: React.MouseEvent<HTMLButtonElement>) => { 52 + e.stopPropagation(); 53 + onClose(); 54 + }; 55 + 56 + const handleFullscreenToggle = (e: React.MouseEvent<HTMLButtonElement>) => { 57 + e.stopPropagation(); 58 + setIsFullscreen((prev) => !prev); 59 + setTimeout(() => window.dispatchEvent(new Event("resize")), 50); 60 + }; 61 + 62 + if (!isOpen) return null; 63 + 64 + return createPortal( 65 + <> 66 + <div 67 + className="overlay modal modal-middle overlay-open:opacity-100 overlay-open:duration-300 open opened" 68 + role="dialog" 69 + style={{ outline: "none", zIndex: 80 }} 70 + onClick={handleBackdropClick} 71 + onMouseDown={handleBackdropClick} 72 + > 73 + <div 74 + className={`overlay-animation-target modal-dialog overlay-open:duration-300 transition-all ease-out ${ 75 + isFullscreen 76 + ? "fixed inset-0 !m-0 !max-w-none !w-screen !h-screen !rounded-none" 77 + : "modal-dialog-xl overlay-open:mt-4 mt-12" 78 + }`} 79 + onClick={handleContentClick} 80 + onMouseDown={handleContentClick} 81 + style={isFullscreen ? { maxHeight: "100vh" } : undefined} 82 + > 83 + <div 84 + className={`modal-content ${isFullscreen ? "!rounded-none h-full" : ""}`} 85 + > 86 + <div className="modal-header"> 87 + <div className="flex-1 text-center">{title}</div> 88 + <button 89 + type="button" 90 + className="btn btn-text btn-circle btn-sm absolute start-2 top-3" 91 + aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} 92 + onClick={handleFullscreenToggle} 93 + onMouseDown={(e) => e.stopPropagation()} 94 + > 95 + <span 96 + className={ 97 + isFullscreen 98 + ? "icon-[qlementine-icons--fullscreen-exit-16] size-4.5" 99 + : "icon-[qlementine-icons--fullscreen-16] size-4.5" 100 + } 101 + ></span> 102 + </button> 103 + <button 104 + type="button" 105 + className="btn btn-text btn-circle btn-sm absolute end-3 top-3" 106 + aria-label="Close" 107 + onClick={handleCloseButton} 108 + onMouseDown={(e) => e.stopPropagation()} 109 + > 110 + <span className="icon-[tabler--x] size-4"></span> 111 + </button> 112 + </div> 113 + <div 114 + className="modal-body p-0 pl-2" 115 + style={ 116 + isFullscreen 117 + ? { height: "calc(100vh - 56px)" } 118 + : { height: "60vh" } 119 + } 120 + > 121 + <Terminal /> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + 127 + <div 128 + data-overlay-backdrop-template="" 129 + style={{ zIndex: 79 }} 130 + className="overlay-backdrop transition duration-300 fixed inset-0 bg-base-300/60 overflow-y-auto opacity-75" 131 + onClick={handleBackdropClick} 132 + onMouseDown={(e) => e.stopPropagation()} 133 + ></div> 134 + </>, 135 + document.body, 136 + ); 137 + } 138 + 139 + export default TerminalModal;
+3
apps/web/src/pages/projects/Project/TerminalModal/index.tsx
··· 1 + import TerminalModal from "./TerminalModal"; 2 + 3 + export default TerminalModal;
+1 -1
apps/web/src/pages/projects/Projects.tsx
··· 20 20 <th className="normal-case text-[14px]">State</th> 21 21 <th className="normal-case text-[14px]">Resources</th> 22 22 <th className="normal-case text-[14px]">Created At</th> 23 - <th className="normal-case text-[14px]"></th>{" "} 23 + <th className="normal-case text-[14px]"></th> 24 24 </tr> 25 25 </thead> 26 26 <tbody>
+64 -1
apps/web/src/pages/sandbox/Sandbox.tsx
··· 1 - import { useState } from "react"; 1 + import { useEffect, useState } from "react"; 2 2 import Navbar from "../../components/navbar"; 3 3 import SignIn from "../../components/signin"; 4 4 import { useLocation, useNavigate } from "@tanstack/react-router"; ··· 13 13 import { useAtomValue } from "jotai"; 14 14 import { profileAtom } from "../../atoms/profile"; 15 15 import { useQueryClient } from "@tanstack/react-query"; 16 + import Terminal from "../../components/terminal"; 16 17 17 18 dayjs.extend(relativeTime); 18 19 ··· 24 25 const { mutate: startSandbox } = useStartSandboxMutation(); 25 26 const isAuthenticated = !!localStorage.getItem("token"); 26 27 const [signInModalOpen, setSignInModalOpen] = useState(false); 28 + const [isFullscreen, setIsFullscreen] = useState(false); 27 29 const navigate = useNavigate(); 28 30 const location = useLocation(); 31 + 32 + useEffect(() => { 33 + const handleEscapeKey = (event: KeyboardEvent) => { 34 + if (event.key === "Escape" && isFullscreen) { 35 + setIsFullscreen(false); 36 + (document.activeElement as HTMLElement)?.blur(); 37 + setTimeout(() => window.dispatchEvent(new Event("resize")), 50); 38 + } 39 + }; 40 + document.addEventListener("keydown", handleEscapeKey); 41 + return () => document.removeEventListener("keydown", handleEscapeKey); 42 + }, [isFullscreen]); 29 43 30 44 const getSandboxIdFromPath = () => { 31 45 const path = location.pathname; ··· 201 215 </button> 202 216 )} 203 217 </div> 218 + 219 + {data?.sandbox?.status === "RUNNING" && 220 + ((profile && data?.sandbox?.owner?.did === profile.did) || 221 + !data?.sandbox?.owner) && ( 222 + <div 223 + className={ 224 + isFullscreen 225 + ? "fixed inset-0 z-50 bg-base-100 flex flex-col" 226 + : "relative mt-10" 227 + } 228 + > 229 + <button 230 + type="button" 231 + className={ 232 + isFullscreen 233 + ? "btn btn-text btn-circle btn-sm absolute top-2 right-2 z-10" 234 + : "btn btn-text btn-circle btn-sm absolute -top-6 right-0" 235 + } 236 + aria-label={ 237 + isFullscreen ? "Exit fullscreen" : "Fullscreen" 238 + } 239 + onClick={() => { 240 + setIsFullscreen((prev) => !prev); 241 + setTimeout( 242 + () => window.dispatchEvent(new Event("resize")), 243 + 50, 244 + ); 245 + }} 246 + onMouseDown={(e) => e.stopPropagation()} 247 + > 248 + <span 249 + className={ 250 + isFullscreen 251 + ? "icon-[qlementine-icons--fullscreen-exit-16] size-5" 252 + : "icon-[qlementine-icons--fullscreen-16] size-5" 253 + } 254 + ></span> 255 + </button> 256 + <div 257 + style={ 258 + isFullscreen 259 + ? { flex: 1, minHeight: 0 } 260 + : { height: "400px" } 261 + } 262 + > 263 + <Terminal /> 264 + </div> 265 + </div> 266 + )} 204 267 </div> 205 268 </> 206 269 )}